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.
Files changed (117) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
  4. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
  5. package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
  6. package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
  8. package/browser-runtime/ai/browser-speech.mjs +2 -0
  9. package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
  10. package/browser-runtime/ai/model-controller.mjs +285 -95
  11. package/browser-runtime/ai/speech-worker-client.mjs +247 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
  13. package/browser-runtime/event-manager.mjs +1097 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/event-manager.md +155 -27
  16. package/docs/reference/README.md +27 -27
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1162 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +6 -3
  21. package/docs/reference/behavioral-testing.md +29 -6
  22. package/docs/reference/cli.md +117 -9
  23. package/docs/reference/core/arcane-ai-contracts.md +1 -1
  24. package/docs/reference/event-manager.md +577 -32
  25. package/docs/reference/inventory/package-api.json +478 -2
  26. package/docs/reference/inventory/runtime-components.json +108 -44
  27. package/docs/reference/inventory/runtime-modules.json +131 -53
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +157 -43
  30. package/docs/reference/runtime-components.md +258 -83
  31. package/docs/reference/runtime-modules.md +613 -77
  32. package/docs/reference/sdk-api.md +1014 -25
  33. package/package.json +5 -4
  34. package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
  35. package/runtime/arcane/components/app-bar.html +34 -13
  36. package/runtime/arcane/components/assistant-panel.html +110 -57
  37. package/runtime/arcane/components/calculator.html +7 -4
  38. package/runtime/arcane/components/chart.html +58 -17
  39. package/runtime/arcane/components/chat.html +606 -136
  40. package/runtime/arcane/components/conversation-view.html +13 -6
  41. package/runtime/arcane/components/dashboard-config.html +96 -59
  42. package/runtime/arcane/components/data-maintenance.html +69 -14
  43. package/runtime/arcane/components/data-view.html +53 -7
  44. package/runtime/arcane/components/directory-picker.html +118 -32
  45. package/runtime/arcane/components/document-inspector.html +47 -10
  46. package/runtime/arcane/components/file-drop.html +81 -35
  47. package/runtime/arcane/components/file-inspector.html +72 -22
  48. package/runtime/arcane/components/file-manager.html +374 -79
  49. package/runtime/arcane/components/integration-settings.html +12 -5
  50. package/runtime/arcane/components/local-ai-status.html +48 -19
  51. package/runtime/arcane/components/markdown-document.html +161 -68
  52. package/runtime/arcane/components/markdown-editor.html +110 -33
  53. package/runtime/arcane/components/media-embed.html +8 -5
  54. package/runtime/arcane/components/modal.html +15 -5
  55. package/runtime/arcane/components/output-panel.html +28 -23
  56. package/runtime/arcane/components/preferences-form.html +22 -4
  57. package/runtime/arcane/components/record-timeline.html +18 -2
  58. package/runtime/arcane/components/relationship-board.html +23 -3
  59. package/runtime/arcane/components/screen-capture.html +10 -4
  60. package/runtime/arcane/components/source-code-viewer.html +76 -9
  61. package/runtime/arcane/components/source-explanation.html +23 -3
  62. package/runtime/arcane/components/speech.html +462 -384
  63. package/runtime/arcane/components/summary-strip.html +22 -11
  64. package/runtime/arcane/components/table.html +39 -21
  65. package/runtime/arcane/components/task-progress.html +79 -21
  66. package/runtime/arcane/components/terminal-workspace.html +7 -4
  67. package/runtime/arcane/components/theme-editor.html +7 -3
  68. package/runtime/arcane/components/unified-inbox.html +9 -4
  69. package/runtime/arcane/components/voice-transcription.html +639 -98
  70. package/runtime/arcane/components/weather-widget.html +5 -3
  71. package/runtime/arcane/components/web-navigator.html +48 -8
  72. package/runtime/arcane/entities/Chat.js +1 -1
  73. package/runtime/arcane/entities/User.js +110 -23
  74. package/runtime/arcane/modules/AI.js +2109 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
  76. package/runtime/arcane/modules/AIRuntimeState.js +109 -52
  77. package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
  78. package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
  79. package/runtime/arcane/modules/CalculatorEngine.js +63 -3
  80. package/runtime/arcane/modules/CommunicationAppController.js +588 -28
  81. package/runtime/arcane/modules/CommunicationHub.js +590 -10
  82. package/runtime/arcane/modules/ComponentContracts.js +470 -0
  83. package/runtime/arcane/modules/ConversationTimebox.js +152 -33
  84. package/runtime/arcane/modules/DBLS.js +40 -7
  85. package/runtime/arcane/modules/DBOPFS.js +35 -11
  86. package/runtime/arcane/modules/DataMaintenance.js +12 -2
  87. package/runtime/arcane/modules/Errors.js +65 -7
  88. package/runtime/arcane/modules/HTMLImport.js +198 -14
  89. package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
  90. package/runtime/arcane/modules/Mail.js +738 -115
  91. package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
  92. package/runtime/arcane/modules/MailTransport.mjs +197 -39
  93. package/runtime/arcane/modules/Ollama.js +36 -1
  94. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
  95. package/runtime/arcane/modules/PreferenceStore.js +367 -33
  96. package/runtime/arcane/modules/RecordReviewStore.js +322 -23
  97. package/runtime/arcane/modules/ScreenCapture.js +1397 -15
  98. package/runtime/arcane/modules/SpeechPlayback.js +438 -41
  99. package/runtime/arcane/modules/TerminalClient.js +277 -12
  100. package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
  101. package/runtime/arcane/modules/ThemeManager.js +39 -7
  102. package/runtime/arcane/modules/TimeGuard.js +131 -20
  103. package/runtime/arcane/modules/WaitForComponent.js +386 -33
  104. package/schemas/arcane-lock.schema.json +2 -2
  105. package/src/cli/main.mjs +435 -9
  106. package/src/event-manager.mjs +1097 -1
  107. package/src/import-map.mjs +21 -3
  108. package/src/index.mjs +13 -0
  109. package/src/installed-sdk-runtime.mjs +112 -0
  110. package/src/mail-api.mjs +22 -0
  111. package/src/mail-credentials.mjs +667 -0
  112. package/src/mail-server.mjs +1769 -0
  113. package/src/mail.mjs +261 -0
  114. package/src/sdk-browser-runtime.mjs +85 -41
  115. package/src/testing-loader.mjs +7 -0
  116. package/src/toolchain.mjs +3 -0
  117. 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
- export const aiRuntimeEvents = new EventTarget();
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
- if (!active) {
523
- return unsubscribeAIRuntimeEvent;
524
- }
525
-
526
- aiRuntimeEvents.addEventListener(eventName, forwardAIRuntimeEvent);
527
- normalized.signal?.addEventListener(
528
- 'abort',
529
- unsubscribeAIRuntimeEvent,
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.emitCurrent && currentValue !== undefined) {
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
- aiRuntimeEvents.dispatchEvent(
591
- new CustomEvent(
592
- AI_RUNTIME_STATE_EVENT,
593
- {
594
- detail: currentSnapshot
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
- aiRuntimeEvents.dispatchEvent(
628
- new CustomEvent(
629
- AI_RUNTIME_STATE_EVENT,
630
- {
631
- detail: currentSnapshot
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
- aiRuntimeEvents.dispatchEvent(
660
- new CustomEvent(
661
- AI_RUNTIME_INTENT_EVENT,
662
- {
663
- detail: publishedIntent
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
- aiRuntimeEvents.dispatchEvent(
751
- new CustomEvent(
752
- AI_RUNTIME_STARTUP_EVENT,
753
- {
754
- detail: report
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
- function event(type,detail){return new CustomEvent(type,{detail});}
4
- function endpoint(value){const url=new URL(String(value||''));if(!['http:','https:'].includes(url.protocol))throw new TypeError('API model endpoints must use HTTP or HTTPS.');return url.href;}
5
- function appendParameters(url,parameters={}){for(const [key,value] of Object.entries(parameters||{})){if(value===undefined||value===null||value==='')continue;url.searchParams.set(key,Array.isArray(value)?value.join(','):String(value));}return url;}
6
- function publicEndpoint(url){const safe=new URL(url);for(const key of [...safe.searchParams.keys()])if(/(?:auth|key|password|secret|token)/i.test(key))safe.searchParams.set(key,'[redacted]');return safe.href;}
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
- constructor({endpoint:source,parser=value=>value,fetchImpl=globalThis.fetch,cache=null,request={}}={}){super();this.endpoint=endpoint(source);if(typeof parser!=='function')throw new TypeError('API model parser must be a function.');if(typeof fetchImpl!=='function')throw new TypeError('API model fetch implementation must be a function.');this.parser=parser;this.fetchImpl=fetchImpl;this.cache=cache;this.request={...request};this.latest=null;}
10
- setEndpoint(value){this.endpoint=endpoint(value);return this.endpoint;}
11
- async fetch(parameters={},context={}){
12
- const url=appendParameters(new URL(this.endpoint),parameters);const visibleEndpoint=publicEndpoint(url);const requestId=globalThis.crypto?.randomUUID?.()||`${Date.now()}-${Math.random().toString(16).slice(2)}`;
13
- this.dispatchEvent(event('api-model-request',{requestId,endpoint:visibleEndpoint}));
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
- const response=await this.fetchImpl(url,{method:'GET',...this.request,headers:{Accept:'application/json',...(this.request.headers||{})}});const raw=await response.json();
16
- if(!response.ok)throw new Error(raw?.reason||raw?.error||`API request failed (${response.status}).`);
17
- const value=await this.parser(raw,{context,endpoint:url.href,response:{status:response.status,headers:response.headers}});const record=new ApiModelRecord({endpoint:visibleEndpoint,value,metadata:{requestId,status:response.status}});this.latest=record;
18
- if(this.cache?.set)await this.cache.set(visibleEndpoint,record.toJSON());
19
- this.dispatchEvent(event('api-model-success',{requestId,record}));return record;
20
- }catch(error){this.dispatchEvent(event('api-model-error',{requestId,endpoint:visibleEndpoint,error}));throw error;}
21
- }
22
- async cached(parameters={}){const url=appendParameters(new URL(this.endpoint),parameters);if(!this.cache?.get)return null;const value=await this.cache.get(publicEndpoint(url));return value?new ApiModelRecord(value):null;}
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};