gloomberb 0.9.8 → 0.9.10
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/package.json +1 -1
- package/src/api-client/index.test.ts +3 -3
- package/src/api-client/index.ts +0 -6
- package/src/api-client/request.test.ts +63 -0
- package/src/api-client/request.ts +53 -5
- package/src/api-client/types.ts +0 -2
- package/src/app/pane-runtime/layout-placement.ts +0 -1
- package/src/app.tsx +1 -0
- package/src/brokers/account-cache.ts +2 -10
- package/src/brokers/broker-portfolio-sync.ts +0 -1
- package/src/brokers/broker-ticker-sync.ts +20 -16
- package/src/brokers/remote-broker-adapter.ts +3 -0
- package/src/capabilities/registry.test.ts +25 -0
- package/src/capabilities/registry.ts +22 -5
- package/src/cli/commands/automation.ts +1 -3
- package/src/cli/commands/command-utils.ts +0 -7
- package/src/cli/commands/overview.ts +2 -2
- package/src/cli/commands/plugins.ts +34 -17
- package/src/cli/index.ts +2 -2
- package/src/cli.test.ts +0 -157
- package/src/components/chart/comparison/renderer.ts +0 -1
- package/src/components/chart/comparison-stock-chart/render-output.ts +3 -8
- package/src/components/chart/comparison-stock-chart/view.tsx +0 -2
- package/src/components/chart/core/index.test.ts +0 -24
- package/src/components/chart/core/renderer.ts +0 -4
- package/src/components/chart/core/resolution.test.ts +0 -17
- package/src/components/chart/core/resolution.ts +0 -26
- package/src/components/chart/core/scroll.test.ts +0 -6
- package/src/components/chart/core/scroll.ts +0 -22
- package/src/components/chart/core/viewport.test.ts +2 -23
- package/src/components/chart/core/viewport.ts +0 -27
- package/src/components/chart/market-session.ts +1 -1
- package/src/components/chart/native/surface/hooks.ts +10 -3
- package/src/components/chart/stock-chart/data-runtime.ts +0 -1
- package/src/components/chart/stock-chart/viewport/effects.ts +0 -3
- package/src/components/command-bar/helpers.test.ts +1 -80
- package/src/components/command-bar/helpers.ts +1 -36
- package/src/components/command-bar/pane-settings.ts +1 -1
- package/src/components/command-bar/panel/runtime.ts +0 -2
- package/src/components/command-bar/routes/ticker-search/results.ts +0 -11
- package/src/components/command-bar/surface/index.test.tsx +0 -16
- package/src/components/command-bar/surface/index.tsx +0 -1
- package/src/components/feed-data-table/stack-view.test.tsx +36 -130
- package/src/components/feed-data-table/stack-view.tsx +0 -1
- package/src/components/layout/shell/index.test.tsx +2 -113
- package/src/components/layout/shell/index.tsx +0 -1
- package/src/components/layout/shell/menu.ts +0 -1
- package/src/components/layout/shell/terminal-pointer-runtime.ts +0 -1
- package/src/components/onboarding/onboarding-wizard.tsx +3 -3
- package/src/components/onboarding/wizard-keyboard.ts +2 -23
- package/src/components/price-selector-dialog.tsx +3 -3
- package/src/components/ticker/badge/text.tsx +0 -1
- package/src/components/ui/page-stack-view.tsx +1 -1
- package/src/components/ui/ui.test.tsx +0 -78
- package/src/core/state/app/layout-reducer.ts +0 -1
- package/src/data/config/layout.ts +3 -3
- package/src/data/resource-store.ts +6 -0
- package/src/data/sqlite/cache.test.ts +5 -0
- package/src/data/ticker-store.ts +0 -7
- package/src/market-data/coordinator/auxiliary.ts +1 -44
- package/src/market-data/coordinator/entries.ts +0 -1
- package/src/market-data/coordinator/index.test.ts +0 -10
- package/src/market-data/coordinator/index.ts +1 -18
- package/src/market-data/coordinator/quotes.ts +0 -6
- package/src/market-data/coordinator/subscriptions.test.ts +1 -0
- package/src/market-data/market/status.ts +19 -1
- package/src/market-data/query-store.ts +0 -4
- package/src/market-data/quotes/resolution.ts +2 -2
- package/src/market-data/request-types.ts +0 -5
- package/src/market-data/result-types.ts +0 -11
- package/src/market-data/selectors.ts +1 -5
- package/src/news/aggregator.ts +5 -1
- package/src/pane-settings.test.ts +0 -18
- package/src/plugins/builtin/account-management/form-components.tsx +0 -120
- package/src/plugins/builtin/account-management/model.ts +3 -27
- package/src/plugins/builtin/broker-manager/index.test.tsx +0 -32
- package/src/plugins/builtin/buildout/cells.tsx +1 -1
- package/src/plugins/builtin/buildout/detail/satellite-images.tsx +1 -1
- package/src/plugins/builtin/buildout/detail/ui.tsx +0 -1
- package/src/plugins/builtin/buildout/format.ts +2 -11
- package/src/plugins/builtin/chat/chat.test.tsx +2 -1
- package/src/plugins/builtin/chat/content/index.tsx +1 -2
- package/src/plugins/builtin/chat/content/transcript.tsx +0 -1
- package/src/plugins/builtin/chat/controller/index.ts +0 -10
- package/src/plugins/builtin/chat/controller.test.ts +5 -5
- package/src/plugins/builtin/chat/sidebar.test.tsx +15 -5
- package/src/plugins/builtin/chat/test-harness.tsx +2 -2
- package/src/plugins/builtin/cloud/plugin.tsx +1 -1
- package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -1
- package/src/plugins/builtin/congress-trades/model.ts +2 -7
- package/src/plugins/builtin/congress-trades/pane.tsx +0 -1
- package/src/plugins/builtin/earnings/index.tsx +0 -9
- package/src/plugins/builtin/econ/index.tsx +1 -5
- package/src/plugins/builtin/help/index.tsx +2 -2
- package/src/plugins/builtin/holders/pane.tsx +0 -5
- package/src/plugins/builtin/insider/index.tsx +1 -8
- package/src/plugins/builtin/kelly-sizer/index.test.tsx +0 -26
- package/src/plugins/builtin/kelly-sizer/index.tsx +0 -2
- package/src/plugins/builtin/market-movers/index.tsx +0 -2
- package/src/plugins/builtin/news/index.tsx +0 -1
- package/src/plugins/builtin/news/wire/news/table.tsx +0 -11
- package/src/plugins/builtin/options/view.test.tsx +1 -38
- package/src/plugins/builtin/plugin-groups.ts +17 -3
- package/src/plugins/builtin/portfolio-list/column-values.ts +15 -8
- package/src/plugins/builtin/portfolio-list/index.test.ts +1 -24
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +44 -7
- package/src/plugins/builtin/sec/filing-documents.ts +0 -11
- package/src/plugins/builtin/sec/index.tsx +0 -2
- package/src/plugins/builtin/substack/article-detail.tsx +1 -1
- package/src/plugins/builtin/thirteenf/api.ts +0 -15
- package/src/plugins/builtin/thirteenf/pane.tsx +1 -3
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/financials/tab.tsx +0 -7
- package/src/plugins/builtin/ticker-detail/quote-monitor/index.test.tsx +5 -34
- package/src/plugins/builtin/world-indices/index.tsx +0 -4
- package/src/plugins/builtin/world-indices/model.ts +0 -7
- package/src/plugins/ibkr/account-cache.ts +1 -1
- package/src/plugins/ibkr/portfolio-performance.ts +1 -1
- package/src/plugins/ibkr/trade/utils.ts +1 -7
- package/src/plugins/loader.ts +2 -1
- package/src/plugins/prediction-markets/detail.test.tsx +0 -34
- package/src/plugins/registry/contributions.ts +22 -11
- package/src/plugins/registry/index.test.ts +44 -0
- package/src/plugins/registry/index.ts +44 -26
- package/src/remote/controller.ts +1 -7
- package/src/remote/json-patch.ts +15 -1
- package/src/remote/server.ts +16 -6
- package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
- package/src/renderers/electrobun/bun/core-capabilities.ts +2 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +14 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.ts +8 -0
- package/src/renderers/electrobun/bun/desktop/initialization.ts +2 -1
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
- package/src/renderers/electrobun/view/data-table/dom.ts +0 -1
- package/src/renderers/electrobun/view/host/mouse.ts +0 -11
- package/src/renderers/electrobun/view/http-fetch.ts +15 -2
- package/src/renderers/electrobun/view/remote/asset-data-client.ts +9 -18
- package/src/renderers/electrobun/view/remote/capability-invoker.test.ts +50 -0
- package/src/renderers/electrobun/view/remote/capability-invoker.ts +36 -0
- package/src/renderers/opentui/host.tsx +11 -3
- package/src/renderers/opentui/input-host.tsx +5 -1
- package/src/sources/gloomberb-cloud/index.test.ts +0 -72
- package/src/sources/gloomberb-cloud/index.ts +1 -30
- package/src/sources/gloomberb-cloud/news.ts +0 -12
- package/src/sources/provider-router/financial-routes.ts +25 -3
- package/src/sources/provider-router/index.test.ts +167 -0
- package/src/sources/provider-router/search.ts +5 -0
- package/src/sources/provider-router/supplemental.ts +4 -3
- package/src/sources/yahoo-finance/history.ts +5 -1
- package/src/sources/yahoo-finance/options.ts +59 -14
- package/src/sources/yahoo-finance/quote-summary.ts +4 -1
- package/src/sources/yahoo-finance.test.ts +40 -21
- package/src/sources/yahoo-finance.ts +13 -8
- package/src/state/app/context/index.test.tsx +0 -81
- package/src/state/app/context/selector.test.tsx +6 -2
- package/src/sync/controller.test.ts +151 -0
- package/src/sync/controller.ts +92 -57
- package/src/sync/core-contributors.ts +45 -21
- package/src/sync/react.ts +6 -8
- package/src/sync/types.ts +3 -0
- package/src/test-support/data-provider.ts +0 -1
- package/src/ui/host.tsx +2 -1
- package/src/ui/index.tsx +1 -0
- package/src/updater.test.ts +0 -21
- package/src/updater.ts +20 -16
- package/src/utils/async-deadline.ts +26 -0
- package/src/utils/selection-clipboard.ts +2 -2
- package/src/version.ts +1 -1
- package/src/components/layout/pane/header.test.tsx +0 -39
- package/src/plugins/builtin/chat/channels.test.ts +0 -28
- package/src/plugins/builtin/chat/index.tsx +0 -4
- package/src/plugins/builtin/cloud-tweets/index.ts +0 -1
- package/src/plugins/builtin/earnings/index.test.ts +0 -12
- package/src/plugins/builtin/help/index.test.tsx +0 -242
- package/src/plugins/builtin/substack/article-detail.test.tsx +0 -10
- package/src/renderers/electrobun/bun/application-menu/index.test.ts +0 -21
- package/src/renderers/electrobun/bun/window/focus.test.ts +0 -35
- package/src/renderers/electrobun/view/host/mouse.test.ts +0 -37
- package/src/renderers/electrobun/view/host/text.test.ts +0 -22
- package/src/renderers/opentui/dialog-host.test.tsx +0 -44
- /package/src/{plugins/ibkr → utils}/hash.ts +0 -0
|
@@ -37,6 +37,11 @@ interface RegistryContributionsOptions {
|
|
|
37
37
|
wrapBrokerAdapter?: (broker: BrokerAdapter, pluginId: string) => BrokerAdapter;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function setUnique<T>(map: Map<string, T>, id: string, value: T): void {
|
|
41
|
+
if (map.has(id)) throw new Error(`Duplicate plugin contribution id: ${id}`);
|
|
42
|
+
map.set(id, value);
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
export class RegistryContributions {
|
|
41
46
|
readonly pluginItems = new Map<string, PluginItems>();
|
|
42
47
|
readonly commandOwners = new Map<string, string>();
|
|
@@ -82,30 +87,30 @@ export class RegistryContributions {
|
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
registerPane(pluginId: string, pane: PaneDef, items = this.getOrCreatePluginItems(pluginId)): void {
|
|
85
|
-
this.panesMap
|
|
90
|
+
setUnique(this.panesMap, pane.id, this.options.wrapPaneDef(pluginId, pane));
|
|
86
91
|
this.paneOwners.set(pane.id, pluginId);
|
|
87
92
|
items.panes.push(pane.id);
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
registerPaneTemplate(pluginId: string, template: PaneTemplateDef, items = this.getOrCreatePluginItems(pluginId)): void {
|
|
91
|
-
this.paneTemplatesMap
|
|
96
|
+
setUnique(this.paneTemplatesMap, template.id, template);
|
|
92
97
|
this.paneTemplateOwners.set(template.id, pluginId);
|
|
93
98
|
items.paneTemplates.push(template.id);
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
registerCommand(pluginId: string, command: CommandDef, items = this.getOrCreatePluginItems(pluginId)): void {
|
|
97
|
-
this.commandsMap
|
|
102
|
+
setUnique(this.commandsMap, command.id, command);
|
|
98
103
|
this.commandOwners.set(command.id, pluginId);
|
|
99
104
|
items.commands.push(command.id);
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
registerColumn(_pluginId: string, column: CustomColumnDef, items: PluginItems): void {
|
|
103
|
-
this.columnsMap
|
|
108
|
+
setUnique(this.columnsMap, column.id, column);
|
|
104
109
|
items.columns.push(column.id);
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
registerBroker(pluginId: string, broker: BrokerAdapter, items = this.getOrCreatePluginItems(pluginId)): void {
|
|
108
|
-
this.brokersMap
|
|
113
|
+
setUnique(this.brokersMap, broker.id, this.options.wrapBrokerAdapter?.(broker, pluginId) ?? broker);
|
|
109
114
|
items.brokers.push(broker.id);
|
|
110
115
|
}
|
|
111
116
|
|
|
@@ -115,19 +120,19 @@ export class RegistryContributions {
|
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
registerTickerResearchTab(pluginId: string, tab: TickerResearchTabDef, items = this.getOrCreatePluginItems(pluginId)): void {
|
|
118
|
-
this.tickerResearchTabsMap
|
|
123
|
+
setUnique(this.tickerResearchTabsMap, tab.id, this.options.wrapTickerResearchTabDef(pluginId, tab));
|
|
119
124
|
this.tickerResearchTabOwners.set(tab.id, pluginId);
|
|
120
125
|
items.tickerResearchTabs.push(tab.id);
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
registerShortcut(pluginId: string, shortcut: KeyboardShortcut, items = this.getOrCreatePluginItems(pluginId)): void {
|
|
124
|
-
this.shortcutsMap
|
|
129
|
+
setUnique(this.shortcutsMap, shortcut.id, shortcut);
|
|
125
130
|
this.shortcutOwners.set(shortcut.id, pluginId);
|
|
126
131
|
items.shortcuts.push(shortcut.id);
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
registerTickerAction(_pluginId: string, action: TickerAction, items: PluginItems): void {
|
|
130
|
-
this.tickerActionsMap
|
|
135
|
+
setUnique(this.tickerActionsMap, action.id, action);
|
|
131
136
|
items.tickerActions.push(action.id);
|
|
132
137
|
}
|
|
133
138
|
|
|
@@ -166,9 +171,15 @@ export class RegistryContributions {
|
|
|
166
171
|
}
|
|
167
172
|
for (const actionId of items.tickerActions) this.tickerActionsMap.delete(actionId);
|
|
168
173
|
for (const providerKey of items.contextMenuProviders) this.contextMenuProvidersMap.delete(providerKey);
|
|
169
|
-
|
|
170
|
-
for (const dispose of items.capabilityDisposers)
|
|
171
|
-
|
|
174
|
+
let disposeError: unknown;
|
|
175
|
+
for (const dispose of [...items.eventDisposers, ...items.capabilityDisposers, ...items.newsQueryWatchDisposers]) {
|
|
176
|
+
try {
|
|
177
|
+
dispose();
|
|
178
|
+
} catch (error) {
|
|
179
|
+
disposeError ??= error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
172
182
|
this.pluginItems.delete(pluginId);
|
|
183
|
+
if (disposeError) throw disposeError;
|
|
173
184
|
}
|
|
174
185
|
}
|
|
@@ -70,6 +70,49 @@ afterEach(() => {
|
|
|
70
70
|
currentPersistence = null;
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
describe("PluginRegistry lifecycle", () => {
|
|
74
|
+
test("rolls back rejected registrations and always removes owned contributions", async () => {
|
|
75
|
+
const registry = createRegistry();
|
|
76
|
+
const pane = {
|
|
77
|
+
id: "shared-pane",
|
|
78
|
+
name: "Shared",
|
|
79
|
+
component: () => null,
|
|
80
|
+
defaultPosition: "right" as const,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
await expect(registry.register({
|
|
84
|
+
id: "setup-failure",
|
|
85
|
+
name: "Setup failure",
|
|
86
|
+
version: "1.0.0",
|
|
87
|
+
panes: [pane],
|
|
88
|
+
setup: () => { throw new Error("setup failed"); },
|
|
89
|
+
})).rejects.toThrow("setup failed");
|
|
90
|
+
expect(registry.allPlugins.has("setup-failure")).toBe(false);
|
|
91
|
+
expect(registry.panes.has("shared-pane")).toBe(false);
|
|
92
|
+
|
|
93
|
+
await registry.register({ id: "owner", name: "Owner", version: "1.0.0", panes: [pane] });
|
|
94
|
+
await expect(registry.register({ id: "collision", name: "Collision", version: "1.0.0", panes: [pane] }))
|
|
95
|
+
.rejects.toThrow("Duplicate plugin contribution id");
|
|
96
|
+
expect(registry.panes.get("shared-pane")?.name).toBe("Shared");
|
|
97
|
+
|
|
98
|
+
await registry.register({
|
|
99
|
+
id: "throwing-dispose",
|
|
100
|
+
name: "Throwing dispose",
|
|
101
|
+
version: "1.0.0",
|
|
102
|
+
panes: [{
|
|
103
|
+
id: "disposable-pane",
|
|
104
|
+
name: "Disposable",
|
|
105
|
+
component: () => null,
|
|
106
|
+
defaultPosition: "right",
|
|
107
|
+
}],
|
|
108
|
+
dispose: () => { throw new Error("dispose failed"); },
|
|
109
|
+
});
|
|
110
|
+
expect(() => registry.unregister("throwing-dispose")).toThrow("dispose failed");
|
|
111
|
+
expect(registry.allPlugins.has("throwing-dispose")).toBe(false);
|
|
112
|
+
expect(registry.panes.has("disposable-pane")).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
73
116
|
describe("PluginRegistry context menu providers", () => {
|
|
74
117
|
test("registers and unregisters context menu providers", async () => {
|
|
75
118
|
const registry = createRegistry();
|
|
@@ -234,6 +277,7 @@ describe("PluginRegistry pane settings", () => {
|
|
|
234
277
|
settings: {},
|
|
235
278
|
}],
|
|
236
279
|
floating: [],
|
|
280
|
+
detached: [],
|
|
237
281
|
};
|
|
238
282
|
registry.getConfigFn = () => config;
|
|
239
283
|
registry.getLayoutFn = () => config.layout;
|
|
@@ -441,35 +441,47 @@ export class PluginRegistry implements PluginRuntimeAccess {
|
|
|
441
441
|
|
|
442
442
|
async register(plugin: GloomPlugin): Promise<void> {
|
|
443
443
|
this.registryLog.info(`Registering plugin: ${plugin.id} v${plugin.version ?? "?"}`);
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
444
|
+
if (this.plugins.has(plugin.id)) throw new Error(`Plugin already registered: ${plugin.id}`);
|
|
445
|
+
this.plugins.set(plugin.id, plugin);
|
|
446
|
+
try {
|
|
447
|
+
const items = this.contributions.getOrCreatePluginItems(plugin.id);
|
|
448
|
+
if (plugin.panes) {
|
|
449
|
+
for (const pane of plugin.panes) {
|
|
450
|
+
this.contributions.registerPane(plugin.id, pane, items);
|
|
451
|
+
}
|
|
448
452
|
}
|
|
449
|
-
}
|
|
450
453
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
+
if (plugin.paneTemplates) {
|
|
455
|
+
for (const template of plugin.paneTemplates) {
|
|
456
|
+
this.contributions.registerPaneTemplate(plugin.id, template, items);
|
|
457
|
+
}
|
|
454
458
|
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
if (plugin.broker) {
|
|
458
|
-
this.contributions.registerBroker(plugin.id, plugin.broker, items);
|
|
459
|
-
}
|
|
460
459
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
this.registerCapabilityForPlugin(plugin.id, capability, items);
|
|
460
|
+
if (plugin.broker) {
|
|
461
|
+
this.contributions.registerBroker(plugin.id, plugin.broker, items);
|
|
464
462
|
}
|
|
465
|
-
}
|
|
466
463
|
|
|
467
|
-
|
|
464
|
+
if (this.enableCapabilityHandlers && plugin.capabilities) {
|
|
465
|
+
for (const capability of plugin.capabilities) {
|
|
466
|
+
this.registerCapabilityForPlugin(plugin.id, capability, items);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
468
469
|
|
|
469
|
-
|
|
470
|
+
this.slots.register(plugin, this);
|
|
470
471
|
|
|
471
|
-
|
|
472
|
-
|
|
472
|
+
if (plugin.setup) {
|
|
473
|
+
await plugin.setup(this.createContext(plugin.id));
|
|
474
|
+
}
|
|
475
|
+
} catch (error) {
|
|
476
|
+
try {
|
|
477
|
+
this.removePlugin(plugin.id);
|
|
478
|
+
} catch (cleanupError) {
|
|
479
|
+
this.registryLog.error("Failed to clean up rejected plugin registration", {
|
|
480
|
+
pluginId: plugin.id,
|
|
481
|
+
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
throw error;
|
|
473
485
|
}
|
|
474
486
|
|
|
475
487
|
this.events.emit("plugin:registered", { pluginId: plugin.id });
|
|
@@ -480,13 +492,19 @@ export class PluginRegistry implements PluginRuntimeAccess {
|
|
|
480
492
|
if (!plugin) return;
|
|
481
493
|
this.registryLog.info(`Unregistering plugin: ${pluginId}`);
|
|
482
494
|
|
|
483
|
-
|
|
484
|
-
this.
|
|
485
|
-
|
|
486
|
-
this.contributions.unregister(pluginId);
|
|
495
|
+
this.removePlugin(pluginId);
|
|
496
|
+
this.events.emit("plugin:unregistered", { pluginId });
|
|
497
|
+
}
|
|
487
498
|
|
|
499
|
+
private removePlugin(pluginId: string): void {
|
|
500
|
+
const plugin = this.plugins.get(pluginId);
|
|
501
|
+
if (!plugin) return;
|
|
502
|
+
let cleanupError: unknown;
|
|
503
|
+
try { plugin.dispose?.(); } catch (error) { cleanupError = error; }
|
|
504
|
+
try { this.slots.unregister(pluginId); } catch (error) { cleanupError ??= error; }
|
|
505
|
+
try { this.contributions.unregister(pluginId); } catch (error) { cleanupError ??= error; }
|
|
488
506
|
this.plugins.delete(pluginId);
|
|
489
|
-
|
|
507
|
+
if (cleanupError) throw cleanupError;
|
|
490
508
|
}
|
|
491
509
|
|
|
492
510
|
destroy(): void {
|
package/src/remote/controller.ts
CHANGED
|
@@ -51,7 +51,6 @@ interface AppRemoteControllerOptions {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const DEFAULT_MUTATION_INCLUDE: RemoteStateInclude[] = ["app", "layout", "panes", "commandBar"];
|
|
54
|
-
const DEFAULT_BATCH_INCLUDE: RemoteStateInclude[] = ["app", "layout", "panes", "commandBar"];
|
|
55
54
|
|
|
56
55
|
export function createAppRemoteController({
|
|
57
56
|
dispatch,
|
|
@@ -68,11 +67,6 @@ export function createAppRemoteController({
|
|
|
68
67
|
uiRegistry,
|
|
69
68
|
});
|
|
70
69
|
|
|
71
|
-
const getAfterMutation = async (resource: string): Promise<unknown> => {
|
|
72
|
-
await afterMutation();
|
|
73
|
-
return getResource(resource);
|
|
74
|
-
};
|
|
75
|
-
|
|
76
70
|
const getAfterMutationSummary = async (extra?: Record<string, unknown>): Promise<unknown> => {
|
|
77
71
|
await afterMutation();
|
|
78
72
|
return mutationSummary(getState(), extra);
|
|
@@ -420,7 +414,7 @@ export function createAppRemoteController({
|
|
|
420
414
|
ok: responses.every((response) => response.ok),
|
|
421
415
|
haltedAt,
|
|
422
416
|
responses,
|
|
423
|
-
}, undefined, buildIncludedState(request.include, request.dryRun ? [] :
|
|
417
|
+
}, undefined, buildIncludedState(request.include, request.dryRun ? [] : DEFAULT_MUTATION_INCLUDE));
|
|
424
418
|
}
|
|
425
419
|
default:
|
|
426
420
|
throw new Error(`Unknown remote request type: ${(request as { type?: unknown }).type}`);
|
package/src/remote/json-patch.ts
CHANGED
|
@@ -12,6 +12,14 @@ function pointerSegments(path: string): string[] {
|
|
|
12
12
|
return path.slice(1).split("/").map(decodePointerSegment);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const UNSAFE_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
16
|
+
|
|
17
|
+
function assertSafeObjectKey(key: string): void {
|
|
18
|
+
if (UNSAFE_OBJECT_KEYS.has(key)) {
|
|
19
|
+
throw new Error(`Unsafe object key in JSON patch path: ${key}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
function cloneJson<T>(value: T): T {
|
|
16
24
|
return value == null ? value : JSON.parse(JSON.stringify(value)) as T;
|
|
17
25
|
}
|
|
@@ -30,12 +38,18 @@ function containerFor(root: unknown, path: string): { parent: unknown; key: stri
|
|
|
30
38
|
}
|
|
31
39
|
parent = parent[index];
|
|
32
40
|
} else if (parent && typeof parent === "object") {
|
|
41
|
+
assertSafeObjectKey(segment);
|
|
42
|
+
if (!Object.prototype.hasOwnProperty.call(parent, segment)) {
|
|
43
|
+
throw new Error(`Cannot descend into missing object key: ${segment}`);
|
|
44
|
+
}
|
|
33
45
|
parent = (parent as Record<string, unknown>)[segment];
|
|
34
46
|
} else {
|
|
35
47
|
throw new Error(`Cannot descend into non-object patch path: ${path}`);
|
|
36
48
|
}
|
|
37
49
|
}
|
|
38
|
-
|
|
50
|
+
const key = segments[segments.length - 1]!;
|
|
51
|
+
if (!Array.isArray(parent)) assertSafeObjectKey(key);
|
|
52
|
+
return { parent, key };
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
function applyOperation(root: unknown, operation: RemoteJsonPatchOperation): void {
|
package/src/remote/server.ts
CHANGED
|
@@ -45,21 +45,31 @@ export async function startRemoteControlServer(options: RemoteControlServerOptio
|
|
|
45
45
|
token,
|
|
46
46
|
startedAt: new Date().toISOString(),
|
|
47
47
|
};
|
|
48
|
-
|
|
48
|
+
try {
|
|
49
|
+
await writeRemoteEndpointFiles(options.dataDir, endpoint);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
await closeHttpServer(httpServer);
|
|
52
|
+
await removeRemoteEndpointFiles(options.dataDir, endpoint).catch(() => {});
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
49
55
|
|
|
50
56
|
return {
|
|
51
57
|
endpoint,
|
|
52
58
|
close: async () => {
|
|
53
|
-
await
|
|
54
|
-
httpServer.close((error) => (error ? reject(error) : resolve()));
|
|
55
|
-
}).catch((error) => {
|
|
56
|
-
if ((error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") throw error;
|
|
57
|
-
});
|
|
59
|
+
await closeHttpServer(httpServer);
|
|
58
60
|
await removeRemoteEndpointFiles(options.dataDir, endpoint);
|
|
59
61
|
},
|
|
60
62
|
};
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
async function closeHttpServer(httpServer: ReturnType<typeof createServer>): Promise<void> {
|
|
66
|
+
await new Promise<void>((resolve, reject) => {
|
|
67
|
+
httpServer.close((error) => (error ? reject(error) : resolve()));
|
|
68
|
+
}).catch((error) => {
|
|
69
|
+
if ((error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") throw error;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
async function handleHttpRequest(
|
|
64
74
|
handle: RemoteControlServerOptions["handle"],
|
|
65
75
|
token: string,
|
|
@@ -26,7 +26,7 @@ function openCommandBar(label: string, query: string, options?: { accelerator?:
|
|
|
26
26
|
return commandItem(label, { type: "open-command-bar", query }, options);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
function buildApplicationMenu(): ApplicationMenuItemConfig[] {
|
|
30
30
|
return [
|
|
31
31
|
{
|
|
32
32
|
label: "Gloomberb",
|
|
@@ -19,6 +19,7 @@ const NOTES_INDEX_FILE = "__quick-notes-index__.json";
|
|
|
19
19
|
const BROKER_INVOKE_OPERATIONS = new Set([
|
|
20
20
|
"validate",
|
|
21
21
|
"importPositions",
|
|
22
|
+
"importPortfolioSnapshot",
|
|
22
23
|
"connect",
|
|
23
24
|
"disconnect",
|
|
24
25
|
"getPersistedConfigUpdate",
|
|
@@ -96,6 +97,7 @@ function invokeBrokerOperation(
|
|
|
96
97
|
switch (operation) {
|
|
97
98
|
case "validate":
|
|
98
99
|
case "importPositions":
|
|
100
|
+
case "importPortfolioSnapshot":
|
|
99
101
|
case "connect":
|
|
100
102
|
case "disconnect":
|
|
101
103
|
case "getPersistedConfigUpdate":
|
|
@@ -39,4 +39,18 @@ describe("handleHttpFetch", () => {
|
|
|
39
39
|
expect(response.headers.location).toBe("/reader");
|
|
40
40
|
expect(response.setCookie).toEqual(["substack.sid=sid123; Path=/; HttpOnly"]);
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
test("aborts the backend fetch at the requested deadline", async () => {
|
|
44
|
+
server = Bun.serve({
|
|
45
|
+
port: 0,
|
|
46
|
+
fetch() {
|
|
47
|
+
return new Promise<Response>(() => {});
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await expect(handleHttpFetch({
|
|
52
|
+
url: server.url.toString(),
|
|
53
|
+
init: { timeoutMs: 10 },
|
|
54
|
+
})).rejects.toThrow();
|
|
55
|
+
});
|
|
42
56
|
});
|
|
@@ -34,12 +34,20 @@ export async function handleHttpFetch(payload: Record<string, unknown>) {
|
|
|
34
34
|
typeof init.body === "string" && method !== "GET" && method !== "HEAD"
|
|
35
35
|
? init.body
|
|
36
36
|
: undefined;
|
|
37
|
+
const timeoutMs =
|
|
38
|
+
typeof init.timeoutMs === "number"
|
|
39
|
+
&& Number.isFinite(init.timeoutMs)
|
|
40
|
+
&& init.timeoutMs > 0
|
|
41
|
+
&& init.timeoutMs <= 120_000
|
|
42
|
+
? init.timeoutMs
|
|
43
|
+
: undefined;
|
|
37
44
|
|
|
38
45
|
const response = await fetch(url, {
|
|
39
46
|
method,
|
|
40
47
|
headers: normalizeHttpFetchHeaders(init.headers),
|
|
41
48
|
body,
|
|
42
49
|
redirect,
|
|
50
|
+
signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined,
|
|
43
51
|
});
|
|
44
52
|
const responseHeaders: Record<string, string> = {};
|
|
45
53
|
response.headers.forEach((value, key) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
2
3
|
import { join } from "path";
|
|
3
4
|
import { createAppServices, type AppServices } from "../../../../core/app-services";
|
|
4
5
|
import type { AppSessionSnapshot } from "../../../../core/state/session-persistence";
|
|
@@ -98,7 +99,7 @@ function buildInitializationPayload(
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
async function resolveDesktopDataDir(): Promise<string> {
|
|
101
|
-
const dataDir = await getDataDir() ?? join(process.env.HOME ||
|
|
102
|
+
const dataDir = await getDataDir() ?? join(process.env.HOME || homedir(), ".gloomberb");
|
|
102
103
|
if (!existsSync(dataDir)) {
|
|
103
104
|
mkdirSync(dataDir, { recursive: true });
|
|
104
105
|
}
|
|
@@ -261,6 +261,7 @@ function createRuntime(payload: CliPaneShotPayload): PluginRuntimeAccess {
|
|
|
261
261
|
switchPanel: () => {},
|
|
262
262
|
openCommandBar: () => {},
|
|
263
263
|
showPane: () => {},
|
|
264
|
+
createPaneFromTemplate: () => {},
|
|
264
265
|
hidePane: () => {},
|
|
265
266
|
focusPane: () => {},
|
|
266
267
|
openPaneSettings: () => {},
|
|
@@ -19,7 +19,6 @@ export const CSS_TEXT_DIM = "var(--gloom-text-dim)";
|
|
|
19
19
|
export const CSS_TEXT_BRIGHT = "var(--gloom-text-bright)";
|
|
20
20
|
export const CSS_SELECTED = "var(--gloom-selected)";
|
|
21
21
|
export const CSS_SELECTED_TEXT = "var(--gloom-selected-text)";
|
|
22
|
-
export const CSS_HOVER_BG = "var(--gloom-hover-bg)";
|
|
23
22
|
|
|
24
23
|
function hasAttribute(attributes: unknown, flag: number): boolean {
|
|
25
24
|
return typeof attributes === "number" && (attributes & flag) !== 0;
|
|
@@ -90,17 +90,6 @@ export function callCellMouseHandler(handler: unknown, event: CellMouseEvent): v
|
|
|
90
90
|
(handler as (event: unknown) => void)(event);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
export function hasDirectMouseHandler(props: Record<string, unknown>): boolean {
|
|
94
|
-
return typeof props.onMouseDown === "function"
|
|
95
|
-
|| typeof props.onMouseOver === "function"
|
|
96
|
-
|| typeof props.onMouseUp === "function"
|
|
97
|
-
|| typeof props.onMouseMove === "function"
|
|
98
|
-
|| typeof props.onMouseOut === "function"
|
|
99
|
-
|| typeof props.onMouseDrag === "function"
|
|
100
|
-
|| typeof props.onMouseDragEnd === "function"
|
|
101
|
-
|| typeof props.onMouseScroll === "function";
|
|
102
|
-
}
|
|
103
|
-
|
|
104
93
|
export function cellBoundsForElement(
|
|
105
94
|
getElement: () => HTMLElement | null,
|
|
106
95
|
getProps: () => Record<string, unknown>,
|
|
@@ -11,6 +11,8 @@ interface ElectrobunHttpFetchResponse {
|
|
|
11
11
|
body: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
const CLOUD_MARKET_HTTP_TIMEOUT_MS = 10_000;
|
|
15
|
+
|
|
14
16
|
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
|
15
17
|
const normalized: Record<string, string> = {};
|
|
16
18
|
if (!headers) return normalized;
|
|
@@ -75,7 +77,11 @@ async function electrobunHttpFetch(url: string, init?: RequestInit): Promise<Res
|
|
|
75
77
|
return fetchResponse;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
async function requestBackendHttpFetch(
|
|
80
|
+
async function requestBackendHttpFetch(
|
|
81
|
+
url: string,
|
|
82
|
+
init?: RequestInit,
|
|
83
|
+
timeoutMs?: number,
|
|
84
|
+
): Promise<ElectrobunHttpFetchResponse> {
|
|
79
85
|
return backendRequest<ElectrobunHttpFetchResponse>("http.fetch", {
|
|
80
86
|
url,
|
|
81
87
|
init: {
|
|
@@ -83,6 +89,7 @@ async function requestBackendHttpFetch(url: string, init?: RequestInit): Promise
|
|
|
83
89
|
headers: normalizeHeaders(init?.headers),
|
|
84
90
|
body: await serializeBody(init?.body),
|
|
85
91
|
redirect: init?.redirect,
|
|
92
|
+
timeoutMs,
|
|
86
93
|
},
|
|
87
94
|
});
|
|
88
95
|
}
|
|
@@ -100,7 +107,13 @@ function createResponseHeaders(headers: Record<string, string>, setCookie: strin
|
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
async function electrobunCloudApiFetch(url: string, init?: RequestInit): Promise<Response> {
|
|
103
|
-
|
|
110
|
+
if (init?.signal?.aborted) {
|
|
111
|
+
throw createAbortError();
|
|
112
|
+
}
|
|
113
|
+
const timeoutMs = new URL(url).pathname.startsWith("/market/")
|
|
114
|
+
? CLOUD_MARKET_HTTP_TIMEOUT_MS
|
|
115
|
+
: undefined;
|
|
116
|
+
const response = await withAbort(requestBackendHttpFetch(url, init, timeoutMs), init?.signal);
|
|
104
117
|
|
|
105
118
|
return {
|
|
106
119
|
ok: response.status >= 200 && response.status < 300,
|
|
@@ -3,9 +3,11 @@ import type { NewsArticle, NewsQuery } from "../../../../news/types";
|
|
|
3
3
|
import type { DataProvider, QuoteSubscriptionTarget } from "../../../../types/data-provider";
|
|
4
4
|
import type { Quote, TickerFinancials } from "../../../../types/financials";
|
|
5
5
|
import { backendRequest, getElectrobunBackendInitSnapshot, onCapabilityEvent } from "../backend-rpc";
|
|
6
|
+
import { createCapabilityInvoker } from "./capability-invoker";
|
|
6
7
|
|
|
7
8
|
const ASSET_DATA_CAPABILITY_ID = "asset-data.asset-data-router";
|
|
8
9
|
const NEWS_CAPABILITY_ID = "news.core";
|
|
10
|
+
const ASSET_DATA_REQUEST_TIMEOUT_MS = 115_000;
|
|
9
11
|
|
|
10
12
|
export type RemoteAssetDataClient = DataProvider & {
|
|
11
13
|
getNews(query: NewsQuery): Promise<NewsArticle[]>;
|
|
@@ -64,23 +66,12 @@ function hasRendererOperation(operations: Set<string> | null, operationId: strin
|
|
|
64
66
|
return operations === null || operations.has(operationId);
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
function createCapabilityInvoker() {
|
|
68
|
-
const inFlightRequests = new Map<string, Promise<unknown>>();
|
|
69
|
-
return function invoke<T>(capabilityId: string, operationId: string, payload: unknown): Promise<T> {
|
|
70
|
-
const requestPayload = { capabilityId, operationId, payload };
|
|
71
|
-
const key = JSON.stringify(requestPayload);
|
|
72
|
-
const inFlight = inFlightRequests.get(key);
|
|
73
|
-
if (inFlight) return inFlight as Promise<T>;
|
|
74
|
-
const promise = backendRequest<T>("capability.invoke", requestPayload).finally(() => {
|
|
75
|
-
inFlightRequests.delete(key);
|
|
76
|
-
});
|
|
77
|
-
inFlightRequests.set(key, promise);
|
|
78
|
-
return promise;
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
69
|
export function createRemoteAssetDataClient(): RemoteAssetDataClient {
|
|
83
|
-
const invoke = createCapabilityInvoker(
|
|
70
|
+
const invoke = createCapabilityInvoker({
|
|
71
|
+
request: backendRequest,
|
|
72
|
+
shouldApplyDeadline: (capabilityId) => capabilityId === ASSET_DATA_CAPABILITY_ID,
|
|
73
|
+
timeoutMs: ASSET_DATA_REQUEST_TIMEOUT_MS,
|
|
74
|
+
});
|
|
84
75
|
const assetDataOperations = getRendererOperationIds(ASSET_DATA_CAPABILITY_ID);
|
|
85
76
|
const newsOperations = getRendererOperationIds(NEWS_CAPABILITY_ID);
|
|
86
77
|
let nextSubscriptionId = 1;
|
|
@@ -93,7 +84,7 @@ export function createRemoteAssetDataClient(): RemoteAssetDataClient {
|
|
|
93
84
|
let quoteBackendFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
94
85
|
|
|
95
86
|
const quoteTargetKey = (target: QuoteSubscriptionTarget) =>
|
|
96
|
-
`${target.symbol}:${target.exchange ?? ""}:${target.context?.brokerId ?? ""}:${target.context?.brokerInstanceId ?? ""}:${target.context?.instrument?.conId ?? target.context?.instrument?.localSymbol ?? ""}`;
|
|
87
|
+
`${target.symbol}:${target.exchange ?? ""}:${target.context?.brokerId ?? ""}:${target.context?.brokerInstanceId ?? ""}:${target.context?.instrument?.conId ?? target.context?.instrument?.localSymbol ?? target.context?.instrument?.symbol ?? ""}`;
|
|
97
88
|
|
|
98
89
|
const scheduleQuoteBackendSubscriptionFlush = () => {
|
|
99
90
|
if (quoteBackendFlushTimer) return;
|
|
@@ -156,7 +147,7 @@ export function createRemoteAssetDataClient(): RemoteAssetDataClient {
|
|
|
156
147
|
if (!hasRendererOperation(assetDataOperations, "subscribeQuotes")) return () => {};
|
|
157
148
|
const uniqueTargets = [...new Map(
|
|
158
149
|
targets.map((target) => [
|
|
159
|
-
|
|
150
|
+
quoteTargetKey(target),
|
|
160
151
|
target,
|
|
161
152
|
] as const),
|
|
162
153
|
).values()];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createCapabilityInvoker } from "./capability-invoker";
|
|
3
|
+
|
|
4
|
+
describe("createCapabilityInvoker", () => {
|
|
5
|
+
test("keeps timed-out calls deduped until the raw backend request settles", async () => {
|
|
6
|
+
let requestCount = 0;
|
|
7
|
+
const rawResolvers: Array<(value: unknown) => void> = [];
|
|
8
|
+
const invoke = createCapabilityInvoker({
|
|
9
|
+
request: <T>() => {
|
|
10
|
+
requestCount += 1;
|
|
11
|
+
return new Promise<T>((resolve) => {
|
|
12
|
+
rawResolvers.push((value) => resolve(value as T));
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
shouldApplyDeadline: (capabilityId) => capabilityId.startsWith("asset-data."),
|
|
16
|
+
timeoutMs: 10,
|
|
17
|
+
});
|
|
18
|
+
const payload = { ticker: "AAPL", exchange: "NASDAQ" };
|
|
19
|
+
|
|
20
|
+
const first = invoke("asset-data.asset-data-router", "getQuote", payload);
|
|
21
|
+
const duplicate = invoke("asset-data.asset-data-router", "getQuote", payload);
|
|
22
|
+
expect(duplicate).toBe(first);
|
|
23
|
+
expect(requestCount).toBe(1);
|
|
24
|
+
await expect(first).rejects.toThrow("Asset data request timed out after 10ms");
|
|
25
|
+
|
|
26
|
+
const retryWhileRawPending = invoke(
|
|
27
|
+
"asset-data.asset-data-router",
|
|
28
|
+
"getQuote",
|
|
29
|
+
payload,
|
|
30
|
+
);
|
|
31
|
+
expect(retryWhileRawPending).toBe(first);
|
|
32
|
+
await expect(retryWhileRawPending).rejects.toThrow(
|
|
33
|
+
"Asset data request timed out after 10ms",
|
|
34
|
+
);
|
|
35
|
+
expect(requestCount).toBe(1);
|
|
36
|
+
|
|
37
|
+
rawResolvers[0]?.({ price: 100 });
|
|
38
|
+
await Promise.resolve();
|
|
39
|
+
await Promise.resolve();
|
|
40
|
+
|
|
41
|
+
const retryAfterRawSettles = invoke(
|
|
42
|
+
"asset-data.asset-data-router",
|
|
43
|
+
"getQuote",
|
|
44
|
+
payload,
|
|
45
|
+
);
|
|
46
|
+
expect(requestCount).toBe(2);
|
|
47
|
+
rawResolvers[1]?.({ price: 101 });
|
|
48
|
+
await expect(retryAfterRawSettles).resolves.toEqual({ price: 101 });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { withDeadline } from "../../../../utils/async-deadline";
|
|
2
|
+
|
|
3
|
+
type CapabilityRequest = <T>(method: string, payload: unknown) => Promise<T>;
|
|
4
|
+
|
|
5
|
+
export function createCapabilityInvoker(options: {
|
|
6
|
+
request: CapabilityRequest;
|
|
7
|
+
shouldApplyDeadline(capabilityId: string): boolean;
|
|
8
|
+
timeoutMs: number;
|
|
9
|
+
}) {
|
|
10
|
+
const inFlightRequests = new Map<string, Promise<unknown>>();
|
|
11
|
+
|
|
12
|
+
return function invoke<T>(capabilityId: string, operationId: string, payload: unknown): Promise<T> {
|
|
13
|
+
const requestPayload = { capabilityId, operationId, payload };
|
|
14
|
+
const key = JSON.stringify(requestPayload);
|
|
15
|
+
const inFlight = inFlightRequests.get(key);
|
|
16
|
+
if (inFlight) return inFlight as Promise<T>;
|
|
17
|
+
|
|
18
|
+
const request = options.request<T>("capability.invoke", requestPayload);
|
|
19
|
+
const boundedRequest = options.shouldApplyDeadline(capabilityId)
|
|
20
|
+
? withDeadline(
|
|
21
|
+
request,
|
|
22
|
+
options.timeoutMs,
|
|
23
|
+
`Asset data request timed out after ${options.timeoutMs}ms: ${operationId}`,
|
|
24
|
+
)
|
|
25
|
+
: request;
|
|
26
|
+
inFlightRequests.set(key, boundedRequest);
|
|
27
|
+
void request.finally(() => {
|
|
28
|
+
if (inFlightRequests.get(key) === boundedRequest) {
|
|
29
|
+
inFlightRequests.delete(key);
|
|
30
|
+
}
|
|
31
|
+
}).catch(() => {
|
|
32
|
+
// The returned bounded request owns error delivery to the caller.
|
|
33
|
+
});
|
|
34
|
+
return boundedRequest;
|
|
35
|
+
};
|
|
36
|
+
}
|