arcane-os 0.2.2 → 0.3.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 +29 -0
- package/README.md +8 -8
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
- package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
- package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
- package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
- package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
- package/browser-runtime/ai/browser-speech.mjs +2 -0
- package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
- package/browser-runtime/ai/model-controller.mjs +285 -95
- package/browser-runtime/ai/speech-worker-client.mjs +247 -32
- package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
- package/browser-runtime/event-manager.mjs +1097 -1
- package/docs/architecture.md +2 -2
- package/docs/event-manager.md +155 -27
- package/docs/reference/README.md +27 -27
- package/docs/reference/ai/browser-speech-package-authority.json +835 -0
- package/docs/reference/ai/browser-speech.md +1162 -246
- package/docs/reference/ai/browser-wasm.md +18 -7
- package/docs/reference/availability-and-normalization.md +6 -3
- package/docs/reference/behavioral-testing.md +29 -6
- package/docs/reference/cli.md +117 -9
- package/docs/reference/core/arcane-ai-contracts.md +1 -1
- package/docs/reference/event-manager.md +577 -32
- package/docs/reference/inventory/package-api.json +478 -2
- package/docs/reference/inventory/runtime-components.json +108 -44
- package/docs/reference/inventory/runtime-modules.json +131 -53
- package/docs/reference/mail.md +316 -0
- package/docs/reference/protocols.md +157 -43
- package/docs/reference/runtime-components.md +258 -83
- package/docs/reference/runtime-modules.md +613 -77
- package/docs/reference/sdk-api.md +1014 -25
- package/package.json +5 -4
- package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
- package/runtime/arcane/components/app-bar.html +34 -13
- package/runtime/arcane/components/assistant-panel.html +110 -57
- package/runtime/arcane/components/calculator.html +7 -4
- package/runtime/arcane/components/chart.html +58 -17
- package/runtime/arcane/components/chat.html +606 -136
- package/runtime/arcane/components/conversation-view.html +13 -6
- package/runtime/arcane/components/dashboard-config.html +96 -59
- package/runtime/arcane/components/data-maintenance.html +69 -14
- package/runtime/arcane/components/data-view.html +53 -7
- package/runtime/arcane/components/directory-picker.html +118 -32
- package/runtime/arcane/components/document-inspector.html +47 -10
- package/runtime/arcane/components/file-drop.html +81 -35
- package/runtime/arcane/components/file-inspector.html +72 -22
- package/runtime/arcane/components/file-manager.html +374 -79
- package/runtime/arcane/components/integration-settings.html +12 -5
- package/runtime/arcane/components/local-ai-status.html +48 -19
- package/runtime/arcane/components/markdown-document.html +161 -68
- package/runtime/arcane/components/markdown-editor.html +110 -33
- package/runtime/arcane/components/media-embed.html +8 -5
- package/runtime/arcane/components/modal.html +15 -5
- package/runtime/arcane/components/output-panel.html +28 -23
- package/runtime/arcane/components/preferences-form.html +22 -4
- package/runtime/arcane/components/record-timeline.html +18 -2
- package/runtime/arcane/components/relationship-board.html +23 -3
- package/runtime/arcane/components/screen-capture.html +10 -4
- package/runtime/arcane/components/source-code-viewer.html +76 -9
- package/runtime/arcane/components/source-explanation.html +23 -3
- package/runtime/arcane/components/speech.html +462 -384
- package/runtime/arcane/components/summary-strip.html +22 -11
- package/runtime/arcane/components/table.html +39 -21
- package/runtime/arcane/components/task-progress.html +79 -21
- package/runtime/arcane/components/terminal-workspace.html +7 -4
- package/runtime/arcane/components/theme-editor.html +7 -3
- package/runtime/arcane/components/unified-inbox.html +9 -4
- package/runtime/arcane/components/voice-transcription.html +639 -98
- package/runtime/arcane/components/weather-widget.html +5 -3
- package/runtime/arcane/components/web-navigator.html +48 -8
- package/runtime/arcane/entities/Chat.js +1 -1
- package/runtime/arcane/entities/User.js +110 -23
- package/runtime/arcane/modules/AI.js +2109 -130
- package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
- package/runtime/arcane/modules/AIRuntimeState.js +109 -52
- package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
- package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
- package/runtime/arcane/modules/CalculatorEngine.js +63 -3
- package/runtime/arcane/modules/CommunicationAppController.js +588 -28
- package/runtime/arcane/modules/CommunicationHub.js +590 -10
- package/runtime/arcane/modules/ComponentContracts.js +470 -0
- package/runtime/arcane/modules/ConversationTimebox.js +152 -33
- package/runtime/arcane/modules/DBLS.js +40 -7
- package/runtime/arcane/modules/DBOPFS.js +35 -11
- package/runtime/arcane/modules/DataMaintenance.js +12 -2
- package/runtime/arcane/modules/Errors.js +65 -7
- package/runtime/arcane/modules/HTMLImport.js +198 -14
- package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
- package/runtime/arcane/modules/Mail.js +738 -115
- package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
- package/runtime/arcane/modules/MailTransport.mjs +197 -39
- package/runtime/arcane/modules/Ollama.js +36 -1
- package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
- package/runtime/arcane/modules/PreferenceStore.js +367 -33
- package/runtime/arcane/modules/RecordReviewStore.js +322 -23
- package/runtime/arcane/modules/ScreenCapture.js +1397 -15
- package/runtime/arcane/modules/SpeechPlayback.js +438 -41
- package/runtime/arcane/modules/TerminalClient.js +277 -12
- package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
- package/runtime/arcane/modules/ThemeManager.js +39 -7
- package/runtime/arcane/modules/TimeGuard.js +131 -20
- package/runtime/arcane/modules/WaitForComponent.js +386 -33
- package/schemas/arcane-lock.schema.json +2 -2
- package/src/cli/main.mjs +435 -9
- package/src/event-manager.mjs +1097 -1
- package/src/import-map.mjs +21 -3
- package/src/index.mjs +13 -0
- package/src/installed-sdk-runtime.mjs +112 -0
- package/src/mail-api.mjs +22 -0
- package/src/mail-credentials.mjs +667 -0
- package/src/mail-server.mjs +1769 -0
- package/src/mail.mjs +261 -0
- package/src/sdk-browser-runtime.mjs +85 -41
- package/src/testing-loader.mjs +7 -0
- package/src/toolchain.mjs +3 -0
- package/src/workspace.mjs +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {createArcaneEventSource} from 'arcane-os/event-manager';
|
|
2
|
+
|
|
1
3
|
export const AI_RUNTIME_PROTOCOL = 'arcane-ai-runtime-state/1';
|
|
2
4
|
export const AI_RUNTIME_STATE_EVENT = 'arcane-ai-runtime-state';
|
|
3
5
|
export const AI_RUNTIME_INTENT_EVENT = 'arcane-ai-runtime-intent';
|
|
@@ -87,7 +89,34 @@ const MAX_IDENTIFIER_LENGTH = 128;
|
|
|
87
89
|
const MAX_PROGRESS_UNIT_LENGTH = 32;
|
|
88
90
|
const MAX_ERROR_MESSAGE_LENGTH = 512;
|
|
89
91
|
|
|
90
|
-
|
|
92
|
+
/**
|
|
93
|
+
* @deprecated Subscribe through the focused runtime helpers or arcaneEvents.
|
|
94
|
+
* This state-free EventTarget compatibility view delegates to the module's
|
|
95
|
+
* canonical source; it does not own a second listener registry or event bus.
|
|
96
|
+
*/
|
|
97
|
+
const aiRuntimeEventCompatibilityView = {
|
|
98
|
+
addEventListener(type, listener, options) {
|
|
99
|
+
return aiRuntimeEventSource.addEventListener(type, listener, options);
|
|
100
|
+
},
|
|
101
|
+
removeEventListener(type, listener, options) {
|
|
102
|
+
return aiRuntimeEventSource.removeEventListener(type, listener, options);
|
|
103
|
+
},
|
|
104
|
+
dispatchEvent(event) {
|
|
105
|
+
return aiRuntimeEventSource.dispatchEvent(event);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const aiRuntimeEventSource = createArcaneEventSource(
|
|
109
|
+
aiRuntimeEventCompatibilityView,
|
|
110
|
+
{
|
|
111
|
+
source: 'ai-runtime-state',
|
|
112
|
+
eventTypes: Object.freeze([
|
|
113
|
+
AI_RUNTIME_STATE_EVENT,
|
|
114
|
+
AI_RUNTIME_INTENT_EVENT,
|
|
115
|
+
AI_RUNTIME_STARTUP_EVENT
|
|
116
|
+
])
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
export const aiRuntimeEvents = Object.freeze(aiRuntimeEventCompatibilityView);
|
|
91
120
|
|
|
92
121
|
function fail(message) {
|
|
93
122
|
throw new TypeError(`ARCANE_AI_RUNTIME_STATE_INVALID: ${message}`);
|
|
@@ -316,6 +345,50 @@ function unavailableRole(role) {
|
|
|
316
345
|
);
|
|
317
346
|
}
|
|
318
347
|
|
|
348
|
+
function publicAIRuntimeState(snapshot, role = null) {
|
|
349
|
+
const roleState = role === null ? null : snapshot.roles[role];
|
|
350
|
+
return Object.freeze(
|
|
351
|
+
{
|
|
352
|
+
revision: snapshot.revision,
|
|
353
|
+
...(roleState
|
|
354
|
+
? {
|
|
355
|
+
role,
|
|
356
|
+
state: roleState.state,
|
|
357
|
+
...(roleState.operationId === null
|
|
358
|
+
? {}
|
|
359
|
+
: {operationId: roleState.operationId}),
|
|
360
|
+
...(roleState.progress === null
|
|
361
|
+
? {}
|
|
362
|
+
: {progress: roleState.progress}),
|
|
363
|
+
...(roleState.error === null
|
|
364
|
+
? {}
|
|
365
|
+
: {code: roleState.error.code})
|
|
366
|
+
}
|
|
367
|
+
: {count: AI_RUNTIME_ROLES.length})
|
|
368
|
+
}
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function publicAIRuntimeIntent(intent) {
|
|
373
|
+
return Object.freeze(
|
|
374
|
+
{
|
|
375
|
+
role: intent.role,
|
|
376
|
+
action: intent.action,
|
|
377
|
+
reason: intent.reason
|
|
378
|
+
}
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function publicAIRuntimeStartup(report) {
|
|
383
|
+
return Object.freeze(
|
|
384
|
+
{
|
|
385
|
+
revision: report.currentRevision,
|
|
386
|
+
role: 'llm',
|
|
387
|
+
state: report.roles.llm.state.state
|
|
388
|
+
}
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
319
392
|
function initialSnapshot() {
|
|
320
393
|
return Object.freeze(
|
|
321
394
|
{
|
|
@@ -503,37 +576,25 @@ function assertListener(listener) {
|
|
|
503
576
|
|
|
504
577
|
function subscribe(eventName, listener, normalized, currentValue) {
|
|
505
578
|
assertListener(listener);
|
|
506
|
-
let active = !normalized.signal?.aborted;
|
|
507
|
-
|
|
508
|
-
function unsubscribeAIRuntimeEvent() {
|
|
509
|
-
if (!active) {
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
active = false;
|
|
514
|
-
aiRuntimeEvents.removeEventListener(eventName, forwardAIRuntimeEvent);
|
|
515
|
-
normalized.signal?.removeEventListener('abort', unsubscribeAIRuntimeEvent);
|
|
516
|
-
}
|
|
517
579
|
|
|
518
580
|
function forwardAIRuntimeEvent(event) {
|
|
519
581
|
listener(event.detail);
|
|
520
582
|
}
|
|
521
583
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
{
|
|
531
|
-
once: true
|
|
532
|
-
}
|
|
584
|
+
const unsubscribeAIRuntimeEvent = aiRuntimeEventSource.on(
|
|
585
|
+
eventName,
|
|
586
|
+
forwardAIRuntimeEvent,
|
|
587
|
+
normalized.signal
|
|
588
|
+
? {
|
|
589
|
+
signal: normalized.signal
|
|
590
|
+
}
|
|
591
|
+
: undefined
|
|
533
592
|
);
|
|
534
593
|
|
|
535
594
|
try {
|
|
536
|
-
if (normalized.
|
|
595
|
+
if (!normalized.signal?.aborted
|
|
596
|
+
&& normalized.emitCurrent
|
|
597
|
+
&& currentValue !== undefined) {
|
|
537
598
|
listener(currentValue);
|
|
538
599
|
}
|
|
539
600
|
} catch (error) {
|
|
@@ -587,13 +648,12 @@ export function publishAIRuntimeRoleState(role, completeRecord) {
|
|
|
587
648
|
roles: Object.freeze(nextRoles)
|
|
588
649
|
}
|
|
589
650
|
);
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
)
|
|
651
|
+
aiRuntimeEventSource.dispatch(
|
|
652
|
+
AI_RUNTIME_STATE_EVENT,
|
|
653
|
+
currentSnapshot,
|
|
654
|
+
{
|
|
655
|
+
publicDetail: publicAIRuntimeState(currentSnapshot, role)
|
|
656
|
+
}
|
|
597
657
|
);
|
|
598
658
|
return currentSnapshot;
|
|
599
659
|
}
|
|
@@ -624,13 +684,12 @@ export function publishAIRuntimeRolesState(completeRecords) {
|
|
|
624
684
|
roles: nextRoles
|
|
625
685
|
}
|
|
626
686
|
);
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
)
|
|
687
|
+
aiRuntimeEventSource.dispatch(
|
|
688
|
+
AI_RUNTIME_STATE_EVENT,
|
|
689
|
+
currentSnapshot,
|
|
690
|
+
{
|
|
691
|
+
publicDetail: publicAIRuntimeState(currentSnapshot)
|
|
692
|
+
}
|
|
634
693
|
);
|
|
635
694
|
return currentSnapshot;
|
|
636
695
|
}
|
|
@@ -656,13 +715,12 @@ export function requestAIRuntimeIntent(intent) {
|
|
|
656
715
|
reason: intent.reason
|
|
657
716
|
}
|
|
658
717
|
);
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
)
|
|
718
|
+
aiRuntimeEventSource.dispatch(
|
|
719
|
+
AI_RUNTIME_INTENT_EVENT,
|
|
720
|
+
publishedIntent,
|
|
721
|
+
{
|
|
722
|
+
publicDetail: publicAIRuntimeIntent(publishedIntent)
|
|
723
|
+
}
|
|
666
724
|
);
|
|
667
725
|
return publishedIntent;
|
|
668
726
|
}
|
|
@@ -747,13 +805,12 @@ export function startAIRuntime(options) {
|
|
|
747
805
|
);
|
|
748
806
|
barrierResolved = true;
|
|
749
807
|
resolveBarrier(report);
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
)
|
|
808
|
+
aiRuntimeEventSource.dispatch(
|
|
809
|
+
AI_RUNTIME_STARTUP_EVENT,
|
|
810
|
+
report,
|
|
811
|
+
{
|
|
812
|
+
publicDetail: publicAIRuntimeStartup(report)
|
|
813
|
+
}
|
|
757
814
|
);
|
|
758
815
|
}
|
|
759
816
|
|
|
@@ -1,25 +1,398 @@
|
|
|
1
|
+
import {createArcaneEventSource} from 'arcane-os/event-manager';
|
|
1
2
|
import ApiModelRecord from '../entities/ApiModelRecord.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
export const API_MODEL_EVENTS=Object.freeze({
|
|
5
|
+
requestStarted:'api-model-request',
|
|
6
|
+
requestSucceeded:'api-model-success',
|
|
7
|
+
requestFailed:'api-model-error'
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const API_MODEL_EVENT_TYPES=Object.freeze(Object.values(API_MODEL_EVENTS));
|
|
11
|
+
|
|
12
|
+
export const API_MODEL_ERRORS=Object.freeze({
|
|
13
|
+
requestAborted:Object.freeze({code:'ARCANE_API_MODEL_REQUEST_ABORTED',reason:'api-model-request-aborted'}),
|
|
14
|
+
cacheReadFailed:Object.freeze({code:'ARCANE_API_MODEL_CACHE_READ_FAILED',reason:'api-model-cache-read-rejected'}),
|
|
15
|
+
cacheWriteFailed:Object.freeze({code:'ARCANE_API_MODEL_CACHE_WRITE_FAILED',reason:'api-model-cache-write-rejected'}),
|
|
16
|
+
databaseDisposed:Object.freeze({code:'ARCANE_API_MODEL_DATABASE_DISPOSED',reason:'api-model-database-disposed'}),
|
|
17
|
+
requestFetchFailed:Object.freeze({code:'ARCANE_API_MODEL_REQUEST_FETCH_FAILED',reason:'api-model-request-fetch-rejected'}),
|
|
18
|
+
requestOptionsInvalid:Object.freeze({code:'ARCANE_API_MODEL_REQUEST_OPTIONS_INVALID',reason:'api-model-request-options-invalid'}),
|
|
19
|
+
responseContractInvalid:Object.freeze({code:'ARCANE_API_MODEL_RESPONSE_CONTRACT_INVALID',reason:'api-model-response-contract-mismatch'}),
|
|
20
|
+
responseJSONInvalid:Object.freeze({code:'ARCANE_API_MODEL_RESPONSE_JSON_INVALID',reason:'api-model-response-json-invalid'}),
|
|
21
|
+
responseParseFailed:Object.freeze({code:'ARCANE_API_MODEL_RESPONSE_PARSE_FAILED',reason:'api-model-response-parse-rejected'}),
|
|
22
|
+
responseStatusRejected:Object.freeze({code:'ARCANE_API_MODEL_RESPONSE_STATUS_REJECTED',reason:'api-model-response-status-rejected'})
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function endpoint(value){
|
|
26
|
+
const url=new URL(String(value||''));
|
|
27
|
+
if(!['http:','https:'].includes(url.protocol))throw new TypeError('API model endpoints must use HTTP or HTTPS.');
|
|
28
|
+
return url.href;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function appendParameters(url,parameters={}){
|
|
32
|
+
for(const [key,value] of Object.entries(parameters||{})){
|
|
33
|
+
if(value===undefined||value===null||value==='')continue;
|
|
34
|
+
url.searchParams.set(key,Array.isArray(value)?value.join(','):String(value));
|
|
35
|
+
}
|
|
36
|
+
return url;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function publicEndpoint(url){
|
|
40
|
+
const safe=new URL(url);
|
|
41
|
+
for(const key of [...safe.searchParams.keys()]){
|
|
42
|
+
if(/(?:auth|key|password|secret|token)/i.test(key))safe.searchParams.set(key,'[redacted]');
|
|
43
|
+
}
|
|
44
|
+
return safe.href;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function signalLike(value){
|
|
48
|
+
return value===undefined
|
|
49
|
+
||value===null
|
|
50
|
+
||(
|
|
51
|
+
typeof value==='object'
|
|
52
|
+
&&typeof value.aborted==='boolean'
|
|
53
|
+
&&typeof value.addEventListener==='function'
|
|
54
|
+
&&typeof value.removeEventListener==='function'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function errorMessage(value,fallback){
|
|
59
|
+
if(typeof value?.message==='string'&&value.message.trim())return value.message;
|
|
60
|
+
if(typeof value==='string'&&value.trim())return value;
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function defineErrorContract(error,contract,cause){
|
|
65
|
+
const priorCode=typeof error?.code==='string'&&error.code?error.code:null;
|
|
66
|
+
try{
|
|
67
|
+
if(priorCode&&priorCode!==contract.code&&!Object.hasOwn(error,'providerCode')){
|
|
68
|
+
Object.defineProperty(error,'providerCode',{configurable:true,enumerable:false,value:priorCode,writable:true});
|
|
69
|
+
}
|
|
70
|
+
Object.defineProperty(error,'code',{configurable:true,enumerable:false,value:contract.code,writable:true});
|
|
71
|
+
Object.defineProperty(error,'reason',{configurable:true,enumerable:false,value:contract.reason,writable:true});
|
|
72
|
+
if(cause!==undefined&&cause!==error&&!('cause' in error)){
|
|
73
|
+
Object.defineProperty(error,'cause',{configurable:true,enumerable:false,value:cause,writable:true});
|
|
74
|
+
}
|
|
75
|
+
return error;
|
|
76
|
+
}catch{
|
|
77
|
+
const replacement=new Error(errorMessage(error,'The API model operation failed.'));
|
|
78
|
+
replacement.code=contract.code;
|
|
79
|
+
replacement.reason=contract.reason;
|
|
80
|
+
if(priorCode&&priorCode!==contract.code)replacement.providerCode=priorCode;
|
|
81
|
+
if(cause!==undefined)replacement.cause=cause;
|
|
82
|
+
else if(error!==undefined)replacement.cause=error;
|
|
83
|
+
return replacement;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function apiModelError(error,contract,message){
|
|
88
|
+
const candidate=error&&(typeof error==='object'||typeof error==='function')
|
|
89
|
+
?error
|
|
90
|
+
:new Error(errorMessage(error,message));
|
|
91
|
+
return defineErrorContract(candidate,contract,error!==candidate?error:undefined);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function disposedError(){
|
|
95
|
+
return apiModelError(
|
|
96
|
+
new Error('The API model database has been disposed.'),
|
|
97
|
+
API_MODEL_ERRORS.databaseDisposed,
|
|
98
|
+
'The API model database has been disposed.'
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function requestOptions(value){
|
|
103
|
+
if(value===undefined)return Object.freeze({operationId:null,signal:null});
|
|
104
|
+
if(!value||typeof value!=='object'||Array.isArray(value)){
|
|
105
|
+
throw apiModelError(
|
|
106
|
+
new TypeError('API model request options must be an object.'),
|
|
107
|
+
API_MODEL_ERRORS.requestOptionsInvalid,
|
|
108
|
+
'API model request options must be an object.'
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if(!signalLike(value.signal)){
|
|
112
|
+
throw apiModelError(
|
|
113
|
+
new TypeError('API model request signal must be an AbortSignal.'),
|
|
114
|
+
API_MODEL_ERRORS.requestOptionsInvalid,
|
|
115
|
+
'API model request signal must be an AbortSignal.'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const operationId=value.operationId??null;
|
|
119
|
+
if(operationId!==null&&(
|
|
120
|
+
typeof operationId!=='string'
|
|
121
|
+
||operationId.trim()!==operationId
|
|
122
|
+
||operationId.length<1
|
|
123
|
+
||operationId.length>256
|
|
124
|
+
)){
|
|
125
|
+
throw apiModelError(
|
|
126
|
+
new TypeError('API model operationId must contain 1-256 non-edge-whitespace characters.'),
|
|
127
|
+
API_MODEL_ERRORS.requestOptionsInvalid,
|
|
128
|
+
'API model operationId is invalid.'
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return Object.freeze({operationId,signal:value.signal??null});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function linkAbortSignal(signal,controller,cleanup){
|
|
135
|
+
if(signal===null)return;
|
|
136
|
+
function abortLinkedOperation(){
|
|
137
|
+
if(!controller.signal.aborted)controller.abort(signal.reason);
|
|
138
|
+
}
|
|
139
|
+
if(signal.aborted){
|
|
140
|
+
abortLinkedOperation();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
signal.addEventListener('abort',abortLinkedOperation,{once:true});
|
|
144
|
+
cleanup.push(function removeLinkedAbortListener(){
|
|
145
|
+
signal.removeEventListener('abort',abortLinkedOperation);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function operationError(error,stage,signal){
|
|
150
|
+
if(signal.aborted){
|
|
151
|
+
const reason=signal.reason;
|
|
152
|
+
if(reason
|
|
153
|
+
&&typeof reason==='object'
|
|
154
|
+
&&typeof reason.code==='string'
|
|
155
|
+
&&reason.code
|
|
156
|
+
&&typeof reason.reason==='string'
|
|
157
|
+
&&reason.reason)return reason;
|
|
158
|
+
return apiModelError(reason??error,API_MODEL_ERRORS.requestAborted,'The API model request was aborted.');
|
|
159
|
+
}
|
|
160
|
+
if(stage==='fetch')return apiModelError(error,API_MODEL_ERRORS.requestFetchFailed,'The API model request failed.');
|
|
161
|
+
if(stage==='response')return apiModelError(error,API_MODEL_ERRORS.responseContractInvalid,'The API model response contract is invalid.');
|
|
162
|
+
if(stage==='response-json')return apiModelError(error,API_MODEL_ERRORS.responseJSONInvalid,'The API model response body is not valid JSON.');
|
|
163
|
+
if(stage==='response-status')return apiModelError(error,API_MODEL_ERRORS.responseStatusRejected,'The API model response status rejected the request.');
|
|
164
|
+
if(stage==='response-parse')return apiModelError(error,API_MODEL_ERRORS.responseParseFailed,'The API model response parser failed.');
|
|
165
|
+
return apiModelError(error,API_MODEL_ERRORS.cacheWriteFailed,'The API model cache write failed.');
|
|
166
|
+
}
|
|
7
167
|
|
|
8
168
|
export default class ApiModelDatabase extends EventTarget{
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
169
|
+
#disposed=false;
|
|
170
|
+
#events;
|
|
171
|
+
#operationSequence=0;
|
|
172
|
+
#operations=new Set();
|
|
173
|
+
|
|
174
|
+
constructor({endpoint:source,parser=value=>value,fetchImpl=globalThis.fetch,cache=null,request={}}={}){
|
|
175
|
+
super();
|
|
176
|
+
this.endpoint=endpoint(source);
|
|
177
|
+
if(typeof parser!=='function')throw new TypeError('API model parser must be a function.');
|
|
178
|
+
if(typeof fetchImpl!=='function')throw new TypeError('API model fetch implementation must be a function.');
|
|
179
|
+
this.parser=parser;
|
|
180
|
+
this.fetchImpl=fetchImpl;
|
|
181
|
+
this.cache=cache;
|
|
182
|
+
this.request={...request};
|
|
183
|
+
this.latest=null;
|
|
184
|
+
this.#events=createArcaneEventSource(this,{
|
|
185
|
+
source:'api-model-database',
|
|
186
|
+
eventTypes:API_MODEL_EVENT_TYPES
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
addEventListener(type,listener,options){return this.#events.addEventListener(type,listener,options);}
|
|
191
|
+
removeEventListener(type,listener,options){return this.#events.removeEventListener(type,listener,options);}
|
|
192
|
+
on(type,listener,options){return this.#events.on(type,listener,options);}
|
|
193
|
+
dispatchEvent(value){return this.#events.dispatchEvent(value);}
|
|
194
|
+
|
|
195
|
+
#assertOpen(){if(this.#disposed)throw disposedError();}
|
|
196
|
+
|
|
197
|
+
#publishError(operation,error){
|
|
198
|
+
if(operation.errorPublished||this.#events.disposed)return false;
|
|
199
|
+
operation.errorPublished=true;
|
|
200
|
+
this.#events.dispatch(
|
|
201
|
+
API_MODEL_EVENTS.requestFailed,
|
|
202
|
+
Object.freeze({
|
|
203
|
+
requestId:operation.operationId,
|
|
204
|
+
endpoint:operation.visibleEndpoint,
|
|
205
|
+
error,
|
|
206
|
+
reason:error.reason
|
|
207
|
+
}),
|
|
208
|
+
{
|
|
209
|
+
operationId:operation.operationId,
|
|
210
|
+
publicDetail:Object.freeze({code:error.code,reason:error.reason})
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#startOperation(visibleEndpoint,options){
|
|
217
|
+
const controller=new AbortController();
|
|
218
|
+
const cleanup=[];
|
|
219
|
+
const requestSignal=this.request?.signal??null;
|
|
220
|
+
if(!signalLike(requestSignal)){
|
|
221
|
+
throw apiModelError(
|
|
222
|
+
new TypeError('The configured API model request signal must be an AbortSignal.'),
|
|
223
|
+
API_MODEL_ERRORS.requestOptionsInvalid,
|
|
224
|
+
'The configured API model request signal must be an AbortSignal.'
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
linkAbortSignal(requestSignal,controller,cleanup);
|
|
228
|
+
if(options.signal!==requestSignal)linkAbortSignal(options.signal,controller,cleanup);
|
|
229
|
+
const operationId=options.operationId
|
|
230
|
+
??`${this.#events.instanceId}:fetch:${(++this.#operationSequence).toString(36)}`;
|
|
231
|
+
const operation={
|
|
232
|
+
cleanup,
|
|
233
|
+
controller,
|
|
234
|
+
errorPublished:false,
|
|
235
|
+
operationId,
|
|
236
|
+
settled:false,
|
|
237
|
+
terminalError:null,
|
|
238
|
+
visibleEndpoint
|
|
239
|
+
};
|
|
240
|
+
this.#operations.add(operation);
|
|
241
|
+
return operation;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#finishOperation(operation){
|
|
245
|
+
operation.settled=true;
|
|
246
|
+
this.#operations.delete(operation);
|
|
247
|
+
for(const remove of operation.cleanup.splice(0))remove();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
setEndpoint(value){
|
|
251
|
+
this.#assertOpen();
|
|
252
|
+
this.endpoint=endpoint(value);
|
|
253
|
+
return this.endpoint;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async fetch(parameters={},context={},optionsValue={}){
|
|
257
|
+
this.#assertOpen();
|
|
258
|
+
const options=requestOptions(optionsValue);
|
|
259
|
+
const url=appendParameters(new URL(this.endpoint),parameters);
|
|
260
|
+
const visibleEndpoint=publicEndpoint(url);
|
|
261
|
+
const operation=this.#startOperation(visibleEndpoint,options);
|
|
262
|
+
let started=false;
|
|
263
|
+
let stage='fetch';
|
|
14
264
|
try{
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
this.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
265
|
+
if(operation.controller.signal.aborted){
|
|
266
|
+
throw operationError(operation.controller.signal.reason,stage,operation.controller.signal);
|
|
267
|
+
}
|
|
268
|
+
started=true;
|
|
269
|
+
this.#events.dispatch(
|
|
270
|
+
API_MODEL_EVENTS.requestStarted,
|
|
271
|
+
Object.freeze({requestId:operation.operationId,endpoint:visibleEndpoint}),
|
|
272
|
+
{
|
|
273
|
+
operationId:operation.operationId,
|
|
274
|
+
publicDetail:Object.freeze({requestId:operation.operationId})
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
if(operation.controller.signal.aborted)throw operation.controller.signal.reason;
|
|
278
|
+
const request={...this.request};
|
|
279
|
+
delete request.signal;
|
|
280
|
+
const response=await this.fetchImpl(url,{
|
|
281
|
+
method:'GET',
|
|
282
|
+
...request,
|
|
283
|
+
headers:{Accept:'application/json',...(request.headers||{})},
|
|
284
|
+
signal:operation.controller.signal
|
|
285
|
+
});
|
|
286
|
+
if(operation.controller.signal.aborted)throw operation.controller.signal.reason;
|
|
287
|
+
stage='response';
|
|
288
|
+
if(!response||typeof response!=='object'||typeof response.json!=='function'){
|
|
289
|
+
throw new TypeError('The API model fetch implementation returned an invalid response.');
|
|
290
|
+
}
|
|
291
|
+
stage='response-json';
|
|
292
|
+
const raw=await response.json();
|
|
293
|
+
if(operation.controller.signal.aborted)throw operation.controller.signal.reason;
|
|
294
|
+
stage='response-status';
|
|
295
|
+
if(!response.ok){
|
|
296
|
+
const reason=typeof raw?.reason==='string'&&raw.reason.trim()
|
|
297
|
+
?raw.reason
|
|
298
|
+
:typeof raw?.error==='string'&&raw.error.trim()
|
|
299
|
+
?raw.error
|
|
300
|
+
:`API request failed (${response.status}).`;
|
|
301
|
+
throw new Error(reason);
|
|
302
|
+
}
|
|
303
|
+
stage='response-parse';
|
|
304
|
+
const value=await this.parser(raw,{
|
|
305
|
+
context,
|
|
306
|
+
endpoint:url.href,
|
|
307
|
+
response:{status:response.status,headers:response.headers}
|
|
308
|
+
});
|
|
309
|
+
if(operation.controller.signal.aborted)throw operation.controller.signal.reason;
|
|
310
|
+
const record=new ApiModelRecord({
|
|
311
|
+
endpoint:visibleEndpoint,
|
|
312
|
+
value,
|
|
313
|
+
metadata:{requestId:operation.operationId,status:response.status}
|
|
314
|
+
});
|
|
315
|
+
stage='cache-write';
|
|
316
|
+
if(this.cache?.set){
|
|
317
|
+
await this.cache.set(
|
|
318
|
+
visibleEndpoint,
|
|
319
|
+
record.toJSON(),
|
|
320
|
+
Object.freeze({signal:operation.controller.signal})
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if(operation.controller.signal.aborted)throw operation.controller.signal.reason;
|
|
324
|
+
this.#assertOpen();
|
|
325
|
+
operation.settled=true;
|
|
326
|
+
this.#operations.delete(operation);
|
|
327
|
+
this.latest=record;
|
|
328
|
+
this.#events.dispatch(
|
|
329
|
+
API_MODEL_EVENTS.requestSucceeded,
|
|
330
|
+
Object.freeze({requestId:operation.operationId,record}),
|
|
331
|
+
{
|
|
332
|
+
operationId:operation.operationId,
|
|
333
|
+
publicDetail:Object.freeze({requestId:operation.operationId})
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
return record;
|
|
337
|
+
}catch(error){
|
|
338
|
+
const normalized=operation.terminalError??operationError(error,stage,operation.controller.signal);
|
|
339
|
+
operation.settled=true;
|
|
340
|
+
this.#operations.delete(operation);
|
|
341
|
+
if(started)this.#publishError(operation,normalized);
|
|
342
|
+
throw normalized;
|
|
343
|
+
}finally{
|
|
344
|
+
this.#finishOperation(operation);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async cached(parameters={},optionsValue={}){
|
|
349
|
+
this.#assertOpen();
|
|
350
|
+
const options=requestOptions(optionsValue);
|
|
351
|
+
if(options.operationId!==null){
|
|
352
|
+
throw apiModelError(
|
|
353
|
+
new TypeError('cached() does not accept operationId.'),
|
|
354
|
+
API_MODEL_ERRORS.requestOptionsInvalid,
|
|
355
|
+
'cached() does not accept operationId.'
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if(options.signal?.aborted){
|
|
359
|
+
throw apiModelError(options.signal.reason,API_MODEL_ERRORS.requestAborted,'The API model cache read was aborted.');
|
|
360
|
+
}
|
|
361
|
+
const url=appendParameters(new URL(this.endpoint),parameters);
|
|
362
|
+
if(!this.cache?.get)return null;
|
|
363
|
+
let value;
|
|
364
|
+
try{
|
|
365
|
+
value=await this.cache.get(
|
|
366
|
+
publicEndpoint(url),
|
|
367
|
+
Object.freeze({signal:options.signal})
|
|
368
|
+
);
|
|
369
|
+
}catch(error){
|
|
370
|
+
if(options.signal?.aborted){
|
|
371
|
+
throw apiModelError(options.signal.reason??error,API_MODEL_ERRORS.requestAborted,'The API model cache read was aborted.');
|
|
372
|
+
}
|
|
373
|
+
throw apiModelError(error,API_MODEL_ERRORS.cacheReadFailed,'The API model cache read failed.');
|
|
374
|
+
}
|
|
375
|
+
this.#assertOpen();
|
|
376
|
+
if(options.signal?.aborted){
|
|
377
|
+
throw apiModelError(options.signal.reason,API_MODEL_ERRORS.requestAborted,'The API model cache read was aborted.');
|
|
378
|
+
}
|
|
379
|
+
return value?new ApiModelRecord(value):null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
dispose(){
|
|
383
|
+
if(this.#disposed)return false;
|
|
384
|
+
this.#disposed=true;
|
|
385
|
+
for(const operation of [...this.#operations]){
|
|
386
|
+
const error=disposedError();
|
|
387
|
+
operation.terminalError=error;
|
|
388
|
+
this.#publishError(operation,error);
|
|
389
|
+
if(!operation.controller.signal.aborted)operation.controller.abort(error);
|
|
390
|
+
this.#finishOperation(operation);
|
|
391
|
+
}
|
|
392
|
+
return this.#events.dispose();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
destroy(){return this.dispose();}
|
|
23
396
|
}
|
|
24
397
|
|
|
25
398
|
export {appendParameters,publicEndpoint};
|