@kehto/services 0.15.0 → 0.16.5

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/dist/index.js CHANGED
@@ -785,8 +785,7 @@ function createCoordinatedRelay(options) {
785
785
  };
786
786
  }
787
787
 
788
- // src/keys-service.ts
789
- var KEYS_SERVICE_VERSION = "1.2.0";
788
+ // src/keys-service-internals.ts
790
789
  var MODIFIER_ALIASES = {
791
790
  ctrl: "ctrl",
792
791
  control: "ctrl",
@@ -815,127 +814,242 @@ function parseChord(chord) {
815
814
  out.key = keyTok.length === 1 ? keyTok.toUpperCase() : keyTok;
816
815
  return out;
817
816
  }
817
+ function formatChord(spec) {
818
+ const parts = [];
819
+ if (spec.alt) parts.push("Alt");
820
+ if (spec.ctrl) parts.push("Ctrl");
821
+ if (spec.shift) parts.push("Shift");
822
+ if (spec.meta) parts.push("Meta");
823
+ parts.push(spec.key);
824
+ return parts.join("+");
825
+ }
826
+ var BROWSER_RESERVED_CTRL_KEYS = /* @__PURE__ */ new Set(["L", "N", "R", "T", "W"]);
827
+ function isReservedKeyChord(spec) {
828
+ if (spec.key === "Tab" || spec.key === "Escape") return true;
829
+ if ((spec.ctrl || spec.meta) && !spec.alt && BROWSER_RESERVED_CTRL_KEYS.has(spec.key)) return true;
830
+ if (spec.alt && !spec.ctrl && !spec.meta && (spec.key === "ArrowLeft" || spec.key === "ArrowRight")) {
831
+ return true;
832
+ }
833
+ return false;
834
+ }
835
+ function chordSpecKey(spec) {
836
+ return `${spec.ctrl}|${spec.alt}|${spec.shift}|${spec.meta}|${spec.key}`;
837
+ }
838
+ function forwardPayload(m) {
839
+ return {
840
+ key: m.key,
841
+ code: m.code,
842
+ ctrlKey: m.ctrl,
843
+ altKey: m.alt,
844
+ shiftKey: m.shift,
845
+ metaKey: m.meta
846
+ };
847
+ }
848
+ function eventKey(ev) {
849
+ const k = ev.key.length === 1 ? ev.key.toUpperCase() : ev.key;
850
+ return `${ev.ctrlKey}|${ev.altKey}|${ev.shiftKey}|${ev.metaKey}|${k}`;
851
+ }
852
+ function bindingsForWindow(windowId, registry, windowIndex) {
853
+ const actionIds = windowIndex.get(windowId);
854
+ if (!actionIds) return [];
855
+ const bindings = [];
856
+ for (const actionId of actionIds) {
857
+ const entry = registry.get(actionId);
858
+ if (!entry || entry.windowId !== windowId) continue;
859
+ bindings.push({ actionId, key: entry.chordString });
860
+ }
861
+ return bindings;
862
+ }
863
+ function pushBindings(windowId, registry, windowIndex, send) {
864
+ const payload = {
865
+ type: "keys.bindings",
866
+ bindings: bindingsForWindow(windowId, registry, windowIndex)
867
+ };
868
+ send(payload);
869
+ }
870
+ function removeActionFromWindowIndex(actionId, windowIndex) {
871
+ for (const [wid, set] of windowIndex.entries()) {
872
+ if (set.delete(actionId) && set.size === 0) windowIndex.delete(wid);
873
+ }
874
+ }
875
+
876
+ // src/keys-service.ts
877
+ var KEYS_SERVICE_VERSION = "1.3.0";
878
+ function rememberActionForWindow(windowIndex, windowId, actionId) {
879
+ if (!windowIndex.has(windowId)) windowIndex.set(windowId, /* @__PURE__ */ new Set());
880
+ windowIndex.get(windowId).add(actionId);
881
+ }
882
+ function bindActionEntry(registry, windowIndex, actionId, windowId, chord, chordString) {
883
+ registry.set(actionId, { chord, chordString, windowId });
884
+ rememberActionForWindow(windowIndex, windowId, actionId);
885
+ }
886
+ function bindActionAndMarkChanged(registry, windowIndex, changedWindowIds, actionId, windowId, chord, chordString) {
887
+ bindActionEntry(registry, windowIndex, actionId, windowId, chord, chordString);
888
+ changedWindowIds.add(windowId);
889
+ return chordString;
890
+ }
891
+ function handleForwardMessage(message, onForward) {
892
+ onForward?.(forwardPayload(message));
893
+ }
894
+ function sendUnknownKeysMethod(send, message) {
895
+ const id = message.id ?? "";
896
+ send({
897
+ type: `${message.type}.error`,
898
+ id,
899
+ error: `Unknown keys method: ${message.type}`
900
+ });
901
+ }
902
+ function createKeysMessageHandler(options) {
903
+ return (windowId, message, send) => {
904
+ switch (message.type) {
905
+ case "keys.forward":
906
+ handleForwardMessage(message, options.onForward);
907
+ return;
908
+ case "keys.registerAction":
909
+ options.onRegisterAction(windowId, message, send);
910
+ return;
911
+ case "keys.unregisterAction":
912
+ options.onUnregisterAction(windowId, message, send);
913
+ return;
914
+ default:
915
+ sendUnknownKeysMethod(send, message);
916
+ }
917
+ };
918
+ }
919
+ function sendRegisterActionResult(send, message, binding, error) {
920
+ const result2 = {
921
+ type: "keys.registerAction.result",
922
+ id: message.id,
923
+ actionId: message.action.id,
924
+ ...binding ? { binding } : {},
925
+ ...error ? { error } : {}
926
+ };
927
+ send(result2);
928
+ }
929
+ function pushChangedBindings(changedWindowIds, registry, windowIndex, sendHandles, fallbackSend) {
930
+ for (const changedWindowId of changedWindowIds) {
931
+ const ownerSend = sendHandles.get(changedWindowId) ?? fallbackSend;
932
+ pushBindings(changedWindowId, registry, windowIndex, ownerSend);
933
+ if (!windowIndex.has(changedWindowId)) sendHandles.delete(changedWindowId);
934
+ }
935
+ }
818
936
  function createKeysService(options = {}) {
819
937
  const descriptor = {
820
938
  name: "keys",
821
939
  version: KEYS_SERVICE_VERSION,
822
940
  description: options.hostBridge ? "NIP-5D keys NAP reference handler (host-bridge delegated)" : "NIP-5D keys NAP reference handler (document-level chord listener)"
823
941
  };
824
- function chordSpecKey(spec) {
825
- return `${spec.ctrl}|${spec.alt}|${spec.shift}|${spec.meta}|${spec.key}`;
826
- }
827
942
  const reservedChordKeys = /* @__PURE__ */ new Set();
828
943
  if (options.reservedChords) {
829
944
  for (const chordStr of options.reservedChords) {
830
945
  reservedChordKeys.add(chordSpecKey(parseChord(chordStr)));
831
946
  }
832
947
  }
833
- function forwardKey(m) {
834
- const k = m.key.length === 1 ? m.key.toUpperCase() : m.key;
835
- return `${m.ctrl}|${m.alt}|${m.shift}|${m.meta}|${k}`;
836
- }
837
- function forwardPayload(m) {
838
- return {
839
- key: m.key,
840
- code: m.code,
841
- ctrlKey: m.ctrl,
842
- altKey: m.alt,
843
- shiftKey: m.shift,
844
- metaKey: m.meta
845
- };
846
- }
847
- function eventKey(ev) {
848
- const k = ev.key.length === 1 ? ev.key.toUpperCase() : ev.key;
849
- return `${ev.ctrlKey}|${ev.altKey}|${ev.shiftKey}|${ev.metaKey}|${k}`;
850
- }
948
+ const isUnavailableBinding = (chord) => reservedChordKeys.has(chordSpecKey(chord)) || isReservedKeyChord(chord);
851
949
  if (options.hostBridge) {
852
950
  const bridge = options.hostBridge;
853
951
  const bridgeWindowActions = /* @__PURE__ */ new Map();
952
+ const bridgeActionRegistry = /* @__PURE__ */ new Map();
953
+ const bridgeSendHandles = /* @__PURE__ */ new Map();
854
954
  const unsubscribeHandles = /* @__PURE__ */ new Map();
855
- return {
856
- descriptor,
857
- handleMessage(windowId, message, send) {
858
- switch (message.type) {
859
- case "keys.forward": {
860
- const m = message;
861
- const reserved = reservedChordKeys.has(forwardKey(m));
862
- options.onForward?.(forwardPayload(m));
863
- if (reserved) {
864
- return;
865
- }
866
- return;
955
+ const handleBridgeRegisterAction = (windowId, m, send) => {
956
+ bridgeSendHandles.set(windowId, send);
957
+ let binding;
958
+ const changedWindowIds = /* @__PURE__ */ new Set();
959
+ if (m.action.defaultKey) {
960
+ try {
961
+ const chord = parseChord(m.action.defaultKey);
962
+ const normalizedChord = formatChord(chord);
963
+ const existingEntry = bridgeActionRegistry.get(m.action.id);
964
+ const existing = unsubscribeHandles.get(m.action.id);
965
+ let nextUnsubscribe;
966
+ if (!isUnavailableBinding(chord)) {
967
+ nextUnsubscribe = bridge.subscribe(normalizedChord, (ev) => {
968
+ const e = ev;
969
+ if ("repeat" in e && e.repeat) return;
970
+ options.onForward?.({
971
+ key: e.key,
972
+ code: e.code,
973
+ ctrlKey: e.ctrlKey,
974
+ altKey: e.altKey,
975
+ shiftKey: e.shiftKey,
976
+ metaKey: e.metaKey
977
+ });
978
+ const payload = {
979
+ type: "keys.action",
980
+ actionId: m.action.id
981
+ };
982
+ send(payload);
983
+ });
867
984
  }
868
- case "keys.registerAction": {
869
- const m = message;
870
- if (m.action.defaultKey) {
871
- try {
872
- const unsubscribe = bridge.subscribe(m.action.defaultKey, (ev) => {
873
- const e = ev;
874
- if ("repeat" in e && e.repeat) return;
875
- options.onForward?.({
876
- key: e.key,
877
- code: e.code,
878
- ctrlKey: e.ctrlKey,
879
- altKey: e.altKey,
880
- shiftKey: e.shiftKey,
881
- metaKey: e.metaKey
882
- });
883
- const payload = {
884
- type: "keys.action",
885
- actionId: m.action.id
886
- };
887
- send(payload);
888
- });
889
- unsubscribeHandles.set(m.action.id, unsubscribe);
890
- if (!bridgeWindowActions.has(windowId)) bridgeWindowActions.set(windowId, /* @__PURE__ */ new Set());
891
- bridgeWindowActions.get(windowId).add(m.action.id);
892
- } catch (err2) {
893
- const id = m.id ?? "";
894
- send({
895
- type: "keys.registerAction.error",
896
- id,
897
- error: `bridge subscribe failed: ${err2.message}`
898
- });
899
- return;
900
- }
985
+ if (existing) {
986
+ try {
987
+ existing();
988
+ } catch {
901
989
  }
902
- const result2 = {
903
- type: "keys.registerAction.result",
904
- id: m.id,
905
- actionId: m.action.id,
906
- ...m.action.defaultKey ? { binding: m.action.defaultKey } : {}
907
- };
908
- send(result2);
909
- return;
990
+ unsubscribeHandles.delete(m.action.id);
910
991
  }
911
- case "keys.unregisterAction": {
912
- const m = message;
913
- if (m.actionId) {
914
- const unsubscribe = unsubscribeHandles.get(m.actionId);
915
- if (unsubscribe) {
916
- try {
917
- unsubscribe();
918
- } catch {
919
- }
920
- unsubscribeHandles.delete(m.actionId);
921
- for (const [wid, set] of bridgeWindowActions.entries()) {
922
- if (set.delete(m.actionId) && set.size === 0) bridgeWindowActions.delete(wid);
923
- }
924
- }
925
- }
926
- return;
927
- }
928
- default: {
929
- const id = message.id ?? "";
930
- send({
931
- type: `${message.type}.error`,
932
- id,
933
- error: `Unknown keys method: ${message.type}`
934
- });
935
- return;
992
+ if (existingEntry) changedWindowIds.add(existingEntry.windowId);
993
+ removeActionFromWindowIndex(m.action.id, bridgeWindowActions);
994
+ bridgeActionRegistry.delete(m.action.id);
995
+ if (nextUnsubscribe) {
996
+ unsubscribeHandles.set(m.action.id, nextUnsubscribe);
997
+ binding = bindActionAndMarkChanged(
998
+ bridgeActionRegistry,
999
+ bridgeWindowActions,
1000
+ changedWindowIds,
1001
+ m.action.id,
1002
+ windowId,
1003
+ chord,
1004
+ normalizedChord
1005
+ );
936
1006
  }
1007
+ } catch (err2) {
1008
+ sendRegisterActionResult(
1009
+ send,
1010
+ m,
1011
+ void 0,
1012
+ `bridge subscribe failed: ${err2.message}`
1013
+ );
1014
+ return;
937
1015
  }
938
- },
1016
+ }
1017
+ sendRegisterActionResult(send, m, binding);
1018
+ pushChangedBindings(
1019
+ changedWindowIds,
1020
+ bridgeActionRegistry,
1021
+ bridgeWindowActions,
1022
+ bridgeSendHandles,
1023
+ send
1024
+ );
1025
+ };
1026
+ const handleBridgeUnregisterAction = (_windowId, m, send) => {
1027
+ if (!m.actionId) return;
1028
+ const unsubscribe = unsubscribeHandles.get(m.actionId);
1029
+ const entry = bridgeActionRegistry.get(m.actionId);
1030
+ if (!unsubscribe) return;
1031
+ try {
1032
+ unsubscribe();
1033
+ } catch {
1034
+ }
1035
+ unsubscribeHandles.delete(m.actionId);
1036
+ bridgeActionRegistry.delete(m.actionId);
1037
+ removeActionFromWindowIndex(m.actionId, bridgeWindowActions);
1038
+ if (entry) {
1039
+ const ownerSend = bridgeSendHandles.get(entry.windowId) ?? send;
1040
+ pushBindings(entry.windowId, bridgeActionRegistry, bridgeWindowActions, ownerSend);
1041
+ if (!bridgeWindowActions.has(entry.windowId)) {
1042
+ bridgeSendHandles.delete(entry.windowId);
1043
+ }
1044
+ }
1045
+ };
1046
+ return {
1047
+ descriptor,
1048
+ handleMessage: createKeysMessageHandler({
1049
+ onForward: options.onForward,
1050
+ onRegisterAction: handleBridgeRegisterAction,
1051
+ onUnregisterAction: handleBridgeUnregisterAction
1052
+ }),
939
1053
  onWindowDestroyed(windowId) {
940
1054
  const actions = bridgeWindowActions.get(windowId);
941
1055
  if (!actions) return;
@@ -948,8 +1062,10 @@ function createKeysService(options = {}) {
948
1062
  }
949
1063
  }
950
1064
  unsubscribeHandles.delete(actionId);
1065
+ bridgeActionRegistry.delete(actionId);
951
1066
  }
952
1067
  bridgeWindowActions.delete(windowId);
1068
+ bridgeSendHandles.delete(windowId);
953
1069
  },
954
1070
  destroy() {
955
1071
  for (const unsubscribe of unsubscribeHandles.values()) {
@@ -959,7 +1075,9 @@ function createKeysService(options = {}) {
959
1075
  }
960
1076
  }
961
1077
  unsubscribeHandles.clear();
1078
+ bridgeActionRegistry.clear();
962
1079
  bridgeWindowActions.clear();
1080
+ bridgeSendHandles.clear();
963
1081
  }
964
1082
  };
965
1083
  }
@@ -1003,8 +1121,7 @@ function createKeysService(options = {}) {
1003
1121
  if (send) {
1004
1122
  const payload = {
1005
1123
  type: "keys.action",
1006
- actionId,
1007
- chord: entry.chord
1124
+ actionId
1008
1125
  };
1009
1126
  send(payload);
1010
1127
  }
@@ -1012,76 +1129,56 @@ function createKeysService(options = {}) {
1012
1129
  }
1013
1130
  };
1014
1131
  target.addEventListener("keydown", listener);
1015
- return {
1016
- descriptor,
1017
- handleMessage(windowId, message, send) {
1018
- switch (message.type) {
1019
- case "keys.forward": {
1020
- const m = message;
1021
- const reserved = reservedChordKeys.has(forwardKey(m));
1022
- options.onForward?.(forwardPayload(m));
1023
- if (reserved) return;
1024
- return;
1025
- }
1026
- case "keys.registerAction": {
1027
- const m = message;
1028
- sendHandles.set(windowId, send);
1029
- if (m.action.defaultKey) {
1030
- try {
1031
- const chord = parseChord(m.action.defaultKey);
1032
- actionRegistry.set(m.action.id, {
1033
- chord,
1034
- chordString: m.action.defaultKey,
1035
- windowId
1036
- });
1037
- if (!windowActions.has(windowId)) windowActions.set(windowId, /* @__PURE__ */ new Set());
1038
- windowActions.get(windowId).add(m.action.id);
1039
- } catch (err2) {
1040
- const id = m.id ?? "";
1041
- send({
1042
- type: "keys.registerAction.error",
1043
- id,
1044
- error: `invalid chord: ${err2.message}`
1045
- });
1046
- return;
1047
- }
1048
- }
1049
- const result2 = {
1050
- type: "keys.registerAction.result",
1051
- id: m.id,
1052
- actionId: m.action.id,
1053
- ...m.action.defaultKey ? { binding: m.action.defaultKey } : {}
1054
- };
1055
- send(result2);
1056
- return;
1132
+ const handleDocumentRegisterAction = (windowId, m, send) => {
1133
+ sendHandles.set(windowId, send);
1134
+ let binding;
1135
+ const changedWindowIds = /* @__PURE__ */ new Set();
1136
+ if (m.action.defaultKey) {
1137
+ try {
1138
+ const chord = parseChord(m.action.defaultKey);
1139
+ const normalizedChord = formatChord(chord);
1140
+ const existing = actionRegistry.get(m.action.id);
1141
+ if (existing) {
1142
+ changedWindowIds.add(existing.windowId);
1143
+ removeActionFromWindowIndex(m.action.id, windowActions);
1144
+ actionRegistry.delete(m.action.id);
1057
1145
  }
1058
- case "keys.unregisterAction": {
1059
- const m = message;
1060
- if (m.actionId && actionRegistry.has(m.actionId)) {
1061
- const entry = actionRegistry.get(m.actionId);
1062
- actionRegistry.delete(m.actionId);
1063
- const set = windowActions.get(entry.windowId);
1064
- if (set) {
1065
- set.delete(m.actionId);
1066
- if (set.size === 0) {
1067
- windowActions.delete(entry.windowId);
1068
- sendHandles.delete(entry.windowId);
1069
- }
1070
- }
1071
- }
1072
- return;
1073
- }
1074
- default: {
1075
- const id = message.id ?? "";
1076
- send({
1077
- type: `${message.type}.error`,
1078
- id,
1079
- error: `Unknown keys method: ${message.type}`
1080
- });
1081
- return;
1146
+ if (!isUnavailableBinding(chord)) {
1147
+ binding = bindActionAndMarkChanged(actionRegistry, windowActions, changedWindowIds, m.action.id, windowId, chord, normalizedChord);
1082
1148
  }
1149
+ } catch (err2) {
1150
+ sendRegisterActionResult(
1151
+ send,
1152
+ m,
1153
+ void 0,
1154
+ `invalid chord: ${err2.message}`
1155
+ );
1156
+ return;
1083
1157
  }
1084
- },
1158
+ }
1159
+ sendRegisterActionResult(send, m, binding);
1160
+ pushChangedBindings(changedWindowIds, actionRegistry, windowActions, sendHandles, send);
1161
+ };
1162
+ const handleDocumentUnregisterAction = (_windowId, m, send) => {
1163
+ if (!m.actionId || !actionRegistry.has(m.actionId)) return;
1164
+ const entry = actionRegistry.get(m.actionId);
1165
+ actionRegistry.delete(m.actionId);
1166
+ const set = windowActions.get(entry.windowId);
1167
+ if (set) {
1168
+ set.delete(m.actionId);
1169
+ if (set.size === 0) windowActions.delete(entry.windowId);
1170
+ }
1171
+ const ownerSend = sendHandles.get(entry.windowId) ?? send;
1172
+ pushBindings(entry.windowId, actionRegistry, windowActions, ownerSend);
1173
+ if (!windowActions.has(entry.windowId)) sendHandles.delete(entry.windowId);
1174
+ };
1175
+ return {
1176
+ descriptor,
1177
+ handleMessage: createKeysMessageHandler({
1178
+ onForward: options.onForward,
1179
+ onRegisterAction: handleDocumentRegisterAction,
1180
+ onUnregisterAction: handleDocumentUnregisterAction
1181
+ }),
1085
1182
  onWindowDestroyed(windowId) {
1086
1183
  const actions = windowActions.get(windowId);
1087
1184
  if (actions) {
@@ -2030,6 +2127,60 @@ var OUTBOX_DESCRIPTOR = {
2030
2127
  version: OUTBOX_SERVICE_VERSION,
2031
2128
  description: "NAP-OUTBOX outbox-aware relay routing \u2014 getEvent/query/subscribe/publish/resolveRelays"
2032
2129
  };
2130
+ function isRecord(value) {
2131
+ return typeof value === "object" && value !== null;
2132
+ }
2133
+ function stringArray(value) {
2134
+ if (!Array.isArray(value)) return void 0;
2135
+ const strings = value.filter((item) => typeof item === "string");
2136
+ return strings.length > 0 ? strings : void 0;
2137
+ }
2138
+ function uint(value) {
2139
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
2140
+ }
2141
+ function sanitizeEventOptions(raw) {
2142
+ if (!isRecord(raw)) return void 0;
2143
+ const out = {};
2144
+ if (typeof raw.author === "string") out.author = raw.author;
2145
+ const relays = stringArray(raw.relays);
2146
+ if (relays) out.relays = relays;
2147
+ const timeoutMs = uint(raw.timeoutMs);
2148
+ if (timeoutMs !== void 0) out.timeoutMs = timeoutMs;
2149
+ return Object.keys(out).length > 0 ? out : void 0;
2150
+ }
2151
+ function sanitizeQueryOptions(raw) {
2152
+ if (!isRecord(raw)) return void 0;
2153
+ const out = {};
2154
+ const authors = stringArray(raw.authors);
2155
+ if (authors) out.authors = authors;
2156
+ const relays = stringArray(raw.relays);
2157
+ if (relays) out.relays = relays;
2158
+ const limit = uint(raw.limit);
2159
+ if (limit !== void 0) out.limit = limit;
2160
+ const timeoutMs = uint(raw.timeoutMs);
2161
+ if (timeoutMs !== void 0) out.timeoutMs = timeoutMs;
2162
+ return Object.keys(out).length > 0 ? out : void 0;
2163
+ }
2164
+ var sanitizeSubscribeOptions = sanitizeQueryOptions;
2165
+ function sanitizePublishOptions(raw) {
2166
+ if (!isRecord(raw)) return void 0;
2167
+ const out = {};
2168
+ const relays = stringArray(raw.relays);
2169
+ if (relays) out.relays = relays;
2170
+ if (typeof raw.toOutbox === "boolean") out.toOutbox = raw.toOutbox;
2171
+ const toInboxes = stringArray(raw.toInboxes);
2172
+ if (toInboxes) out.toInboxes = toInboxes;
2173
+ return Object.keys(out).length > 0 ? out : void 0;
2174
+ }
2175
+ function sanitizeTarget(raw) {
2176
+ if (!isRecord(raw)) return void 0;
2177
+ const out = {};
2178
+ const authors = stringArray(raw.authors);
2179
+ if (authors) out.authors = authors;
2180
+ if (typeof raw.pubkey === "string") out.pubkey = raw.pubkey;
2181
+ if (raw.direction === "read" || raw.direction === "write") out.direction = raw.direction;
2182
+ return out;
2183
+ }
2033
2184
  function normalizeFilters(raw) {
2034
2185
  if (Array.isArray(raw)) {
2035
2186
  const filters = raw.filter((f) => typeof f === "object" && f !== null);
@@ -2046,7 +2197,6 @@ function buildEventQuery(eventId, eventOptions) {
2046
2197
  queryOptions.authors = [eventOptions.author];
2047
2198
  }
2048
2199
  if (Array.isArray(eventOptions?.relays)) queryOptions.relays = eventOptions.relays;
2049
- if (eventOptions?.strategy !== void 0) queryOptions.strategy = eventOptions.strategy;
2050
2200
  if (eventOptions?.timeoutMs !== void 0) queryOptions.timeoutMs = eventOptions.timeoutMs;
2051
2201
  return { filter, queryOptions };
2052
2202
  }
@@ -2084,7 +2234,7 @@ function handleGetEvent(router, msg, send) {
2084
2234
  return;
2085
2235
  }
2086
2236
  const getEvent = router.getEvent ?? ((eventId, eventOptions) => fallbackGetEvent(router, eventId, eventOptions));
2087
- void getEvent(m.eventId, m.options).then((result2) => sendEventResult(id, m.eventId, result2, send)).catch(
2237
+ void getEvent(m.eventId, sanitizeEventOptions(m.options)).then((result2) => sendEventResult(id, m.eventId, result2, send)).catch(
2088
2238
  (err2) => send({ type: "outbox.getEvent.result", id, error: toErrorMessage(err2) })
2089
2239
  );
2090
2240
  }
@@ -2102,7 +2252,7 @@ function createOutboxService(options) {
2102
2252
  send({ type: "outbox.query.result", id, events: [], error: "invalid filter" });
2103
2253
  return;
2104
2254
  }
2105
- void router.query(filters, m.options).then(
2255
+ void router.query(filters, sanitizeQueryOptions(m.options)).then(
2106
2256
  (result2) => send({
2107
2257
  type: "outbox.query.result",
2108
2258
  id,
@@ -2133,7 +2283,7 @@ function createOutboxService(options) {
2133
2283
  send({ type: "outbox.closed", subId, ...reason === void 0 ? {} : { reason } });
2134
2284
  }
2135
2285
  };
2136
- subscriptions.set(subKey, router.subscribe(filters, m.options, sink));
2286
+ subscriptions.set(subKey, router.subscribe(filters, sanitizeSubscribeOptions(m.options), sink));
2137
2287
  }
2138
2288
  function handleClose(windowId, msg, send) {
2139
2289
  const m = msg;
@@ -2151,7 +2301,7 @@ function createOutboxService(options) {
2151
2301
  send({ type: "outbox.publish.result", id, ok: false, error: "invalid filter" });
2152
2302
  return;
2153
2303
  }
2154
- void router.publish(m.event, m.options).then(
2304
+ void router.publish(m.event, sanitizePublishOptions(m.options)).then(
2155
2305
  (result2) => send({
2156
2306
  type: "outbox.publish.result",
2157
2307
  id,
@@ -2168,11 +2318,12 @@ function createOutboxService(options) {
2168
2318
  function handleResolveRelays(msg, send) {
2169
2319
  const m = msg;
2170
2320
  const id = m.id ?? "";
2171
- if (!m.target || typeof m.target !== "object") {
2321
+ const target = sanitizeTarget(m.target);
2322
+ if (!target) {
2172
2323
  send({ type: "outbox.resolveRelays.result", id, error: "invalid filter" });
2173
2324
  return;
2174
2325
  }
2175
- void router.resolveRelays(m.target).then((plan) => send({ type: "outbox.resolveRelays.result", id, plan })).catch(
2326
+ void router.resolveRelays(target).then((plan) => send({ type: "outbox.resolveRelays.result", id, plan })).catch(
2176
2327
  (err2) => send({ type: "outbox.resolveRelays.result", id, error: toErrorMessage(err2) })
2177
2328
  );
2178
2329
  }
@@ -2233,11 +2384,6 @@ function deriveAuthors(filters, optionAuthors) {
2233
2384
  for (const author of optionAuthors ?? []) authors.add(author);
2234
2385
  return [...authors];
2235
2386
  }
2236
- function wantsWriteRelays(direction, strategy) {
2237
- if (strategy === "outbox") return true;
2238
- if (strategy === "inbox") return false;
2239
- return direction === "read";
2240
- }
2241
2387
  function allowed(ctx, urls) {
2242
2388
  const out = /* @__PURE__ */ new Set();
2243
2389
  for (const url of urls) {
@@ -2245,9 +2391,33 @@ function allowed(ctx, urls) {
2245
2391
  }
2246
2392
  return [...out];
2247
2393
  }
2248
- async function resolvePlan(ctx, pubkeys, direction, strategy, relayHints) {
2249
- const useWrite = wantsWriteRelays(direction, strategy);
2250
- const collected = /* @__PURE__ */ new Set();
2394
+ async function resolveRequiredRelays(ctx, pubkeys, direction) {
2395
+ const useWrite = direction === "read";
2396
+ const relays = /* @__PURE__ */ new Set();
2397
+ const missingAuthors = [];
2398
+ const deniedAuthors = [];
2399
+ const uniquePubkeys = [...new Set(pubkeys)];
2400
+ if (uniquePubkeys.length === 0) return { relays: [], missingAuthors, deniedAuthors };
2401
+ const lists = await ctx.loadRelayLists(uniquePubkeys);
2402
+ for (const pubkey of uniquePubkeys) {
2403
+ const entry = lists.get(pubkey);
2404
+ const candidateRelays = entry ? useWrite ? entry.write : entry.read : void 0;
2405
+ if (!candidateRelays || candidateRelays.length === 0) {
2406
+ missingAuthors.push(pubkey);
2407
+ continue;
2408
+ }
2409
+ const allowedRelays = allowed(ctx, candidateRelays);
2410
+ if (allowedRelays.length === 0) {
2411
+ deniedAuthors.push(pubkey);
2412
+ continue;
2413
+ }
2414
+ for (const url of allowedRelays) relays.add(url);
2415
+ }
2416
+ return { relays: [...relays], missingAuthors, deniedAuthors };
2417
+ }
2418
+ async function resolvePlan(ctx, pubkeys, direction, relayHints) {
2419
+ const useWrite = direction === "read";
2420
+ const discovered = /* @__PURE__ */ new Set();
2251
2421
  const missingAuthors = [];
2252
2422
  let sawNip65 = false;
2253
2423
  if (pubkeys.length > 0) {
@@ -2257,25 +2427,46 @@ async function resolvePlan(ctx, pubkeys, direction, strategy, relayHints) {
2257
2427
  const relays2 = entry ? useWrite ? entry.write : entry.read : void 0;
2258
2428
  if (relays2 && relays2.length > 0) {
2259
2429
  sawNip65 = true;
2260
- for (const url of relays2) collected.add(url);
2430
+ for (const url of relays2) discovered.add(url);
2261
2431
  } else {
2262
2432
  missingAuthors.push(pubkey);
2263
2433
  }
2264
2434
  }
2265
2435
  }
2266
- for (const url of relayHints ?? []) collected.add(url);
2267
- let relays = allowed(ctx, collected);
2436
+ const hintRelays = allowed(ctx, relayHints ?? []);
2437
+ let relays = allowed(ctx, discovered);
2268
2438
  let source;
2269
- if (relays.length === 0) {
2270
- relays = allowed(ctx, ctx.fallbackRelays);
2271
- source = "fallback";
2272
- } else {
2439
+ if (relays.length > 0) {
2273
2440
  source = sawNip65 ? "nip65" : "policy";
2441
+ } else {
2442
+ relays = allowed(ctx, ctx.fallbackRelays);
2443
+ source = relays.length > 0 ? "fallback" : "policy";
2444
+ }
2445
+ for (const url of hintRelays) {
2446
+ if (!relays.includes(url)) relays.push(url);
2274
2447
  }
2448
+ if (relays.length === 0) source = "fallback";
2275
2449
  const plan = { relays, source };
2276
2450
  if (missingAuthors.length > 0) plan.missingAuthors = missingAuthors;
2277
2451
  return plan;
2278
2452
  }
2453
+ function discoveredReadRelays(ctx, authors, lists) {
2454
+ const relays = /* @__PURE__ */ new Set();
2455
+ const missingAuthors = [];
2456
+ for (const author of new Set(authors)) {
2457
+ const writeRelays = lists.get(author)?.write;
2458
+ if (!writeRelays || writeRelays.length === 0) {
2459
+ missingAuthors.push(author);
2460
+ continue;
2461
+ }
2462
+ for (const url of allowed(ctx, writeRelays)) relays.add(url);
2463
+ }
2464
+ return { relays: [...relays], missingAuthors };
2465
+ }
2466
+ function immediateReadRelays(ctx, relayHints) {
2467
+ const hints = allowed(ctx, relayHints ?? []);
2468
+ return hints.length > 0 ? hints : allowed(ctx, ctx.fallbackRelays);
2469
+ }
2279
2470
  function recordRelay(collector, id, relayUrl) {
2280
2471
  let set = collector.relayMap.get(id);
2281
2472
  if (!set) {
@@ -2284,82 +2475,166 @@ function recordRelay(collector, id, relayUrl) {
2284
2475
  }
2285
2476
  set.add(relayUrl);
2286
2477
  }
2287
- function admitEvent(ctx, collector, event) {
2288
- if (collector.seen.has(event.id)) return;
2289
- collector.verifications.push(
2290
- ctx.verify(event).then((ok) => {
2291
- if (ok && !collector.seen.has(event.id)) collector.seen.set(event.id, event);
2292
- else if (!ok) collector.relayMap.delete(event.id);
2293
- })
2294
- );
2295
- }
2296
- function buildCollectResult(collector, timedOut) {
2297
- const events = [...collector.seen.values()].map(
2298
- (event) => createRelayEventResultWithHints3(event, [...collector.relayMap.get(event.id) ?? []])
2299
- );
2300
- return { events, incomplete: timedOut };
2301
- }
2302
- function collectFromRelays(ctx, filters, relayUrls2, timeoutMs) {
2303
- return new Promise((resolve) => {
2304
- const collector = { seen: /* @__PURE__ */ new Map(), relayMap: /* @__PURE__ */ new Map(), verifications: [] };
2305
- const handles = [];
2306
- let eoseCount = 0;
2307
- let finished = false;
2308
- let timedOut = false;
2309
- function finalize() {
2310
- if (finished) return;
2311
- finished = true;
2312
- clearTimeout(timer);
2313
- for (const handle of handles) {
2314
- try {
2315
- handle.unsubscribe();
2316
- } catch {
2317
- }
2318
- }
2319
- void Promise.all(collector.verifications).then(() => resolve(buildCollectResult(collector, timedOut)));
2478
+ function collectedEventResult(collector, event) {
2479
+ return createRelayEventResultWithHints3(event, [...collector.relayMap.get(event.id) ?? []]);
2480
+ }
2481
+ function admitEvent(ctx, collector, event, sink, isOpen, onSettled) {
2482
+ if (collector.admitted.has(event.id)) return;
2483
+ collector.admitted.add(event.id);
2484
+ collector.pendingVerifications += 1;
2485
+ void ctx.verify(event).then((ok) => {
2486
+ if (!isOpen()) return;
2487
+ if (!ok) {
2488
+ collector.relayMap.delete(event.id);
2489
+ return;
2320
2490
  }
2321
- const timer = setTimeout(() => {
2322
- timedOut = true;
2323
- finalize();
2324
- }, timeoutMs);
2325
- for (const relayUrl of relayUrls2) {
2326
- handles.push(relayPoolSubscribe(ctx, filters, relayUrl, (item) => {
2327
- if (finished) return;
2328
- if (item === "EOSE") {
2329
- eoseCount += 1;
2330
- if (eoseCount >= relayUrls2.length) finalize();
2331
- return;
2332
- }
2333
- recordRelay(collector, item.id, relayUrl);
2334
- admitEvent(ctx, collector, item);
2335
- }));
2491
+ collector.seen.set(event.id, event);
2492
+ try {
2493
+ sink.event(collectedEventResult(collector, event));
2494
+ } catch {
2336
2495
  }
2337
- if (relayUrls2.length === 0) finalize();
2496
+ }).finally(() => {
2497
+ collector.pendingVerifications -= 1;
2498
+ onSettled();
2338
2499
  });
2339
2500
  }
2501
+ function buildQueryResult(collector, options, incomplete, subscribedAny) {
2502
+ let events = [...collector.seen.values()].map((event) => collectedEventResult(collector, event));
2503
+ if (options?.limit !== void 0 && events.length > options.limit) {
2504
+ events = [...events].sort((a, b) => b.event.created_at - a.event.created_at).slice(0, options.limit);
2505
+ }
2506
+ const result2 = { events };
2507
+ if (incomplete || !subscribedAny) result2.incomplete = true;
2508
+ if (!subscribedAny) result2.error = "relay list unavailable";
2509
+ return result2;
2510
+ }
2340
2511
  function relayPoolSubscribe(ctx, filters, relayUrl, cb) {
2341
2512
  return ctx.relayPool.subscribe(filters, [relayUrl], cb);
2342
2513
  }
2343
- async function queryImpl(ctx, filters, options) {
2514
+ function startQueryImpl(ctx, filters, options, sink) {
2344
2515
  if (!ctx.relayPool.isAvailable()) {
2345
- return { events: [], incomplete: true, error: "relay list unavailable" };
2516
+ return {
2517
+ result: Promise.resolve({ events: [], incomplete: true, error: "relay list unavailable" }),
2518
+ close() {
2519
+ }
2520
+ };
2346
2521
  }
2347
- const strategy = options?.strategy ?? "auto";
2348
2522
  const authors = deriveAuthors(filters, options?.authors);
2349
- const plan = await resolvePlan(ctx, authors, "read", strategy, options?.relays);
2350
- if (plan.relays.length === 0) {
2351
- return { events: [], incomplete: true, error: "relay list unavailable" };
2352
- }
2353
- const timeoutMs = options?.timeoutMs ?? ctx.defaultTimeoutMs;
2354
- const collected = await collectFromRelays(ctx, filters, plan.relays, timeoutMs);
2355
- const incomplete = collected.incomplete || (plan.missingAuthors?.length ?? 0) > 0;
2356
- let events = collected.events;
2357
- if (options?.limit !== void 0 && events.length > options.limit) {
2358
- events = [...events].sort((a, b) => b.event.created_at - a.event.created_at).slice(0, options.limit);
2523
+ const collector = {
2524
+ seen: /* @__PURE__ */ new Map(),
2525
+ admitted: /* @__PURE__ */ new Set(),
2526
+ relayMap: /* @__PURE__ */ new Map(),
2527
+ pendingVerifications: 0
2528
+ };
2529
+ const handles = /* @__PURE__ */ new Map();
2530
+ const activeRelays = /* @__PURE__ */ new Set();
2531
+ const eoseRelays = /* @__PURE__ */ new Set();
2532
+ let discoveryPending = authors.length > 0;
2533
+ let incomplete = false;
2534
+ let subscribedAny = false;
2535
+ let finished = false;
2536
+ let resolveResult;
2537
+ const result2 = new Promise((resolve) => {
2538
+ resolveResult = resolve;
2539
+ });
2540
+ let timer;
2541
+ function closeHandles() {
2542
+ for (const handle of handles.values()) {
2543
+ try {
2544
+ handle.unsubscribe();
2545
+ } catch {
2546
+ }
2547
+ }
2548
+ handles.clear();
2549
+ }
2550
+ function finalize(forceIncomplete = false) {
2551
+ if (finished) return;
2552
+ finished = true;
2553
+ incomplete ||= forceIncomplete;
2554
+ clearTimeout(timer);
2555
+ closeHandles();
2556
+ resolveResult(buildQueryResult(collector, options, incomplete, subscribedAny));
2557
+ }
2558
+ function maybeFinalize() {
2559
+ if (finished || discoveryPending || collector.pendingVerifications > 0) return;
2560
+ if (activeRelays.size === 0 || eoseRelays.size >= activeRelays.size) finalize();
2561
+ }
2562
+ function attachRelay(relayUrl) {
2563
+ if (finished || activeRelays.has(relayUrl)) return;
2564
+ activeRelays.add(relayUrl);
2565
+ try {
2566
+ const handle = relayPoolSubscribe(ctx, filters, relayUrl, (item) => {
2567
+ if (finished) return;
2568
+ if (item === "EOSE") {
2569
+ eoseRelays.add(relayUrl);
2570
+ maybeFinalize();
2571
+ return;
2572
+ }
2573
+ recordRelay(collector, item.id, relayUrl);
2574
+ admitEvent(ctx, collector, item, sink, () => !finished, maybeFinalize);
2575
+ });
2576
+ subscribedAny = true;
2577
+ if (finished) {
2578
+ try {
2579
+ handle.unsubscribe();
2580
+ } catch {
2581
+ }
2582
+ } else {
2583
+ handles.set(relayUrl, handle);
2584
+ }
2585
+ } catch {
2586
+ activeRelays.delete(relayUrl);
2587
+ incomplete = true;
2588
+ }
2359
2589
  }
2360
- const result2 = { events };
2361
- if (incomplete) result2.incomplete = true;
2362
- return result2;
2590
+ function attachMany(relayUrls2) {
2591
+ for (const relayUrl of relayUrls2) attachRelay(relayUrl);
2592
+ }
2593
+ timer = setTimeout(() => finalize(true), options?.timeoutMs ?? ctx.defaultTimeoutMs);
2594
+ if (authors.length === 0) {
2595
+ discoveryPending = false;
2596
+ attachMany(allowed(ctx, [...ctx.fallbackRelays, ...options?.relays ?? []]));
2597
+ maybeFinalize();
2598
+ } else {
2599
+ let loaded;
2600
+ try {
2601
+ loaded = ctx.loadRelayLists(authors);
2602
+ } catch {
2603
+ discoveryPending = false;
2604
+ incomplete = true;
2605
+ attachMany(immediateReadRelays(ctx, options?.relays));
2606
+ maybeFinalize();
2607
+ return { result: result2, close: () => finalize(true) };
2608
+ }
2609
+ if (loaded instanceof Map) {
2610
+ const discovery = discoveredReadRelays(ctx, authors, loaded);
2611
+ discoveryPending = false;
2612
+ incomplete ||= discovery.missingAuthors.length > 0;
2613
+ const initial = allowed(ctx, [...options?.relays ?? [], ...discovery.relays]);
2614
+ attachMany(initial.length > 0 ? initial : allowed(ctx, ctx.fallbackRelays));
2615
+ maybeFinalize();
2616
+ } else {
2617
+ attachMany(immediateReadRelays(ctx, options?.relays));
2618
+ void Promise.resolve(loaded).then((lists) => {
2619
+ if (finished) return;
2620
+ const discovery = discoveredReadRelays(ctx, authors, lists);
2621
+ discoveryPending = false;
2622
+ incomplete ||= discovery.missingAuthors.length > 0;
2623
+ attachMany(discovery.relays);
2624
+ maybeFinalize();
2625
+ }).catch(() => {
2626
+ if (finished) return;
2627
+ discoveryPending = false;
2628
+ incomplete = true;
2629
+ maybeFinalize();
2630
+ });
2631
+ }
2632
+ }
2633
+ return { result: result2, close: () => finalize(true) };
2634
+ }
2635
+ function queryImpl(ctx, filters, options) {
2636
+ return startQueryImpl(ctx, filters, options, { event: () => {
2637
+ } }).result;
2363
2638
  }
2364
2639
  async function getEventImpl(ctx, eventId, options) {
2365
2640
  if (typeof eventId !== "string" || eventId.length === 0) {
@@ -2372,7 +2647,6 @@ async function getEventImpl(ctx, eventId, options) {
2372
2647
  queryOptions.authors = [options.author];
2373
2648
  }
2374
2649
  if (Array.isArray(options?.relays)) queryOptions.relays = options.relays;
2375
- if (options?.strategy !== void 0) queryOptions.strategy = options.strategy;
2376
2650
  if (options?.timeoutMs !== void 0) queryOptions.timeoutMs = options.timeoutMs;
2377
2651
  const queryResult = await queryImpl(ctx, [filter], queryOptions);
2378
2652
  const eventResult = queryResult.events.find((candidate) => candidate.event.id === eventId);
@@ -2384,72 +2658,138 @@ async function getEventImpl(ctx, eventId, options) {
2384
2658
  return result2;
2385
2659
  }
2386
2660
  function closeLiveSub(sub) {
2387
- for (const handle of sub.handles) {
2661
+ for (const handle of sub.handles.values()) {
2388
2662
  try {
2389
2663
  handle.unsubscribe();
2390
2664
  } catch {
2391
2665
  }
2392
2666
  }
2393
- sub.handles.length = 0;
2394
- }
2395
- function attachLiveRelay(ctx, sub, filters, relayUrl, live, sink) {
2396
- sub.handles.push(relayPoolSubscribe(ctx, filters, relayUrl, (item) => {
2397
- if (sub.closed) return;
2398
- if (item === "EOSE") {
2399
- sub.eoseCount += 1;
2400
- if (!sub.eoseSent && sub.eoseCount >= sub.relayCount) {
2401
- sub.eoseSent = true;
2402
- if (!live) {
2403
- sub.closed = true;
2404
- closeLiveSub(sub);
2405
- sink.closed();
2406
- }
2667
+ sub.handles.clear();
2668
+ }
2669
+ function attachLiveRelay(ctx, sub, filters, relayUrl, sink) {
2670
+ if (sub.closed || sub.relays.has(relayUrl)) return false;
2671
+ sub.relays.add(relayUrl);
2672
+ try {
2673
+ const handle = relayPoolSubscribe(ctx, filters, relayUrl, (item) => {
2674
+ if (sub.closed || item === "EOSE" || sub.admitted.has(item.id)) return;
2675
+ sub.admitted.add(item.id);
2676
+ void ctx.verify(item).then((ok) => {
2677
+ if (!ok || sub.closed || sub.seen.has(item.id)) return;
2678
+ sub.seen.add(item.id);
2679
+ sink.event(createRelayEventResultWithHints3(item, [relayUrl]));
2680
+ });
2681
+ });
2682
+ if (sub.closed) {
2683
+ try {
2684
+ handle.unsubscribe();
2685
+ } catch {
2407
2686
  }
2408
- return;
2687
+ } else {
2688
+ sub.handles.set(relayUrl, handle);
2409
2689
  }
2410
- if (sub.seen.has(item.id)) return;
2411
- void ctx.verify(item).then((ok) => {
2412
- if (!ok || sub.closed || sub.seen.has(item.id)) return;
2413
- sub.seen.add(item.id);
2414
- sink.event(createRelayEventResultWithHints3(item, [relayUrl]));
2415
- });
2416
- }));
2690
+ return true;
2691
+ } catch {
2692
+ sub.relays.delete(relayUrl);
2693
+ return false;
2694
+ }
2417
2695
  }
2418
2696
  function startSubscription(ctx, filters, options, sink) {
2419
- const live = options?.live ?? true;
2420
- const strategy = options?.strategy ?? "auto";
2421
2697
  const authors = deriveAuthors(filters, options?.authors);
2422
- const sub = { handles: [], seen: /* @__PURE__ */ new Set(), closed: false, eoseCount: 0, relayCount: 0, eoseSent: false };
2423
- void resolvePlan(ctx, authors, "read", strategy, options?.relays).then((plan) => {
2424
- if (sub.closed) return;
2425
- if (plan.relays.length === 0) {
2426
- sink.closed("relay list unavailable");
2427
- return;
2698
+ const sub = {
2699
+ handles: /* @__PURE__ */ new Map(),
2700
+ relays: /* @__PURE__ */ new Set(),
2701
+ admitted: /* @__PURE__ */ new Set(),
2702
+ seen: /* @__PURE__ */ new Set(),
2703
+ closed: false
2704
+ };
2705
+ let discoveryOpen = authors.length > 0;
2706
+ let discoveryTimer;
2707
+ function attachMany(relayUrls2) {
2708
+ for (const relayUrl of relayUrls2) attachLiveRelay(ctx, sub, filters, relayUrl, sink);
2709
+ }
2710
+ function closeUnavailable() {
2711
+ if (sub.closed || sub.handles.size > 0) return;
2712
+ sub.closed = true;
2713
+ sink.closed("relay list unavailable");
2714
+ }
2715
+ if (!ctx.relayPool.isAvailable()) {
2716
+ closeUnavailable();
2717
+ } else if (authors.length === 0) {
2718
+ discoveryOpen = false;
2719
+ attachMany(immediateReadRelays(ctx, options?.relays));
2720
+ closeUnavailable();
2721
+ } else {
2722
+ let loaded;
2723
+ try {
2724
+ loaded = ctx.loadRelayLists(authors);
2725
+ } catch {
2726
+ discoveryOpen = false;
2727
+ attachMany(immediateReadRelays(ctx, options?.relays));
2728
+ closeUnavailable();
2729
+ loaded = /* @__PURE__ */ new Map();
2428
2730
  }
2429
- sub.relayCount = plan.relays.length;
2430
- for (const relayUrl of plan.relays) attachLiveRelay(ctx, sub, filters, relayUrl, live, sink);
2431
- }).catch((err2) => {
2432
- if (!sub.closed) sink.closed(err2 instanceof Error ? err2.message : "subscribe failed");
2433
- });
2731
+ if (discoveryOpen && loaded instanceof Map) {
2732
+ const discovery = discoveredReadRelays(ctx, authors, loaded);
2733
+ discoveryOpen = false;
2734
+ const initial = allowed(ctx, [...options?.relays ?? [], ...discovery.relays]);
2735
+ attachMany(initial.length > 0 ? initial : allowed(ctx, ctx.fallbackRelays));
2736
+ closeUnavailable();
2737
+ } else if (discoveryOpen) {
2738
+ attachMany(immediateReadRelays(ctx, options?.relays));
2739
+ discoveryTimer = setTimeout(() => {
2740
+ discoveryOpen = false;
2741
+ closeUnavailable();
2742
+ }, options?.timeoutMs ?? ctx.defaultTimeoutMs);
2743
+ void Promise.resolve(loaded).then((lists) => {
2744
+ if (!discoveryOpen || sub.closed) return;
2745
+ discoveryOpen = false;
2746
+ clearTimeout(discoveryTimer);
2747
+ attachMany(discoveredReadRelays(ctx, authors, lists).relays);
2748
+ closeUnavailable();
2749
+ }).catch(() => {
2750
+ if (!discoveryOpen || sub.closed) return;
2751
+ discoveryOpen = false;
2752
+ clearTimeout(discoveryTimer);
2753
+ closeUnavailable();
2754
+ });
2755
+ }
2756
+ }
2434
2757
  return {
2435
2758
  close() {
2436
2759
  if (sub.closed) return;
2437
2760
  sub.closed = true;
2761
+ discoveryOpen = false;
2762
+ clearTimeout(discoveryTimer);
2438
2763
  closeLiveSub(sub);
2439
2764
  }
2440
2765
  };
2441
2766
  }
2442
2767
  async function resolvePublishTargets(ctx, signed, options) {
2443
- const strategy = options?.strategy ?? "auto";
2444
2768
  const targets = /* @__PURE__ */ new Set();
2445
- const authorPlan = await resolvePlan(ctx, [signed.pubkey], "read", strategy === "inbox" ? "auto" : "outbox");
2446
- for (const url of authorPlan.relays) targets.add(url);
2447
- if (options?.targetAuthors && options.targetAuthors.length > 0) {
2448
- const inboxPlan = await resolvePlan(ctx, options.targetAuthors, "write", "inbox");
2449
- for (const url of inboxPlan.relays) targets.add(url);
2769
+ const requiredTargets = /* @__PURE__ */ new Set();
2770
+ if (options?.toOutbox !== false) {
2771
+ const outboxPlan = await resolveRequiredRelays(ctx, [signed.pubkey], "read");
2772
+ if (outboxPlan.missingAuthors.length > 0) return { relayUrls: [], requiredRelayUrls: [], error: "relay list unavailable" };
2773
+ if (outboxPlan.deniedAuthors.length > 0) return { relayUrls: [], requiredRelayUrls: [], error: "policy denied" };
2774
+ for (const url of outboxPlan.relays) {
2775
+ targets.add(url);
2776
+ requiredTargets.add(url);
2777
+ }
2778
+ }
2779
+ if (options?.toInboxes && options.toInboxes.length > 0) {
2780
+ const inboxPlan = await resolveRequiredRelays(ctx, options.toInboxes, "write");
2781
+ if (inboxPlan.missingAuthors.length > 0) return { relayUrls: [], requiredRelayUrls: [], error: "relay list unavailable" };
2782
+ if (inboxPlan.deniedAuthors.length > 0) return { relayUrls: [], requiredRelayUrls: [], error: "policy denied" };
2783
+ for (const url of inboxPlan.relays) {
2784
+ targets.add(url);
2785
+ requiredTargets.add(url);
2786
+ }
2450
2787
  }
2451
2788
  for (const url of options?.relays ?? []) targets.add(url);
2452
- return allowed(ctx, targets);
2789
+ return {
2790
+ relayUrls: allowed(ctx, targets),
2791
+ requiredRelayUrls: allowed(ctx, requiredTargets)
2792
+ };
2453
2793
  }
2454
2794
  async function publishImpl(ctx, template, options) {
2455
2795
  if (!ctx.signEvent) return { ok: false, error: "publish denied" };
@@ -2460,7 +2800,11 @@ async function publishImpl(ctx, template, options) {
2460
2800
  } catch (err2) {
2461
2801
  return { ok: false, error: err2 instanceof Error ? err2.message : "sign failed" };
2462
2802
  }
2463
- const relayUrls2 = await resolvePublishTargets(ctx, signed, options);
2803
+ const publishTargets = await resolvePublishTargets(ctx, signed, options);
2804
+ if (publishTargets.error) {
2805
+ return { ok: false, event: signed, eventId: signed.id, error: publishTargets.error };
2806
+ }
2807
+ const relayUrls2 = publishTargets.relayUrls;
2464
2808
  if (relayUrls2.length === 0) return { ok: false, event: signed, eventId: signed.id, error: "relay list unavailable" };
2465
2809
  let relays;
2466
2810
  try {
@@ -2468,7 +2812,7 @@ async function publishImpl(ctx, template, options) {
2468
2812
  } catch (err2) {
2469
2813
  return { ok: false, event: signed, eventId: signed.id, error: err2 instanceof Error ? err2.message : "publish failed" };
2470
2814
  }
2471
- const ok = Object.values(relays).some(Boolean);
2815
+ const ok = publishTargets.requiredRelayUrls.length > 0 ? publishTargets.requiredRelayUrls.every((url) => relays[url] === true) : Object.values(relays).some(Boolean);
2472
2816
  const result2 = { ok, event: signed, eventId: signed.id, relays };
2473
2817
  if (!ok) result2.error = "publish denied";
2474
2818
  return result2;
@@ -2503,11 +2847,12 @@ function createRelayPoolOutboxRouter(options) {
2503
2847
  return {
2504
2848
  getEvent: (eventId, eventOptions) => getEventImpl(ctx, eventId, eventOptions),
2505
2849
  query: (filters, queryOptions) => queryImpl(ctx, filters, queryOptions),
2850
+ queryStream: (filters, queryOptions, sink) => startQueryImpl(ctx, filters, queryOptions, sink),
2506
2851
  subscribe: (filters, subscribeOptions, sink) => startSubscription(ctx, filters, subscribeOptions, sink),
2507
2852
  publish: (template, publishOptions) => publishImpl(ctx, template, publishOptions),
2508
2853
  resolveRelays: (target) => {
2509
2854
  const pubkeys = target.authors ?? (target.pubkey ? [target.pubkey] : []);
2510
- return resolvePlan(ctx, pubkeys, target.direction ?? "read", target.strategy ?? "auto");
2855
+ return resolvePlan(ctx, pubkeys, target.direction ?? "read");
2511
2856
  }
2512
2857
  };
2513
2858
  }
@@ -2664,6 +3009,7 @@ function createHttpUploader(options) {
2664
3009
  const digest = options.digestSha256 ?? defaultDigestSha256;
2665
3010
  const nowS = options.now ?? (() => Math.floor(Date.now() / 1e3));
2666
3011
  const defaultRail = options.defaultRail ?? firstConfiguredRail(rails);
3012
+ const inFlight = /* @__PURE__ */ new Map();
2667
3013
  async function upload(request, ctx) {
2668
3014
  const rail = request.rail ?? defaultRail;
2669
3015
  const config = rail === "nip96" ? rails.nip96 : rail === "blossom" ? rails.blossom : void 0;
@@ -2674,18 +3020,32 @@ function createHttpUploader(options) {
2674
3020
  if (!server) {
2675
3021
  return failed(ctx.uploadId, rail, "no server configured");
2676
3022
  }
2677
- const bytes = await toBytes(request.data);
2678
- const sha256 = await digest(bytes);
3023
+ const controller = new AbortController();
3024
+ inFlight.set(ctx.uploadId, controller);
3025
+ let sha256;
2679
3026
  try {
2680
- return rail === "nip96" ? await uploadNip96({ request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS }) : await uploadBlossom({ request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS });
3027
+ const bytes = await toBytes(request.data);
3028
+ sha256 = await digest(bytes);
3029
+ if (controller.signal.aborted) {
3030
+ return cancelled(ctx.uploadId, rail, sha256);
3031
+ }
3032
+ return rail === "nip96" ? await uploadNip96({ request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS, controller }) : await uploadBlossom({ request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS, controller });
2681
3033
  } catch (err2) {
3034
+ if (controller.signal.aborted) {
3035
+ return cancelled(ctx.uploadId, rail, sha256);
3036
+ }
2682
3037
  return failed(ctx.uploadId, rail, toErrorMessage3(err2), sha256);
3038
+ } finally {
3039
+ inFlight.delete(ctx.uploadId);
2683
3040
  }
2684
3041
  }
2685
- return { upload };
3042
+ function cancel(uploadId) {
3043
+ inFlight.get(uploadId)?.abort();
3044
+ }
3045
+ return { upload, cancel };
2686
3046
  }
2687
3047
  async function uploadNip96(args) {
2688
- const { request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS } = args;
3048
+ const { request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS, controller } = args;
2689
3049
  const auth = await signEvent({
2690
3050
  kind: KIND_NIP98,
2691
3051
  created_at: nowS(),
@@ -2701,10 +3061,12 @@ async function uploadNip96(args) {
2701
3061
  if (request.caption !== void 0) form.append("caption", request.caption);
2702
3062
  if (request.mimeType !== void 0) form.append("content_type", request.mimeType);
2703
3063
  if (request.noTransform) form.append("no_transform", "true");
3064
+ emitUploading(ctx, "nip96", bytes.byteLength, nowS);
2704
3065
  const res = await fetchFn(server, {
2705
3066
  method: "POST",
2706
3067
  headers: { Authorization: nostrAuthHeader(auth) },
2707
- body: form
3068
+ body: form,
3069
+ signal: controller.signal
2708
3070
  });
2709
3071
  if (!res.ok) {
2710
3072
  return failed(ctx.uploadId, "nip96", `server rejected (HTTP ${res.status})`, sha256);
@@ -2742,7 +3104,7 @@ function fromNip94Tags(uploadId, rail, tags, fallbackSize, fallbackSha) {
2742
3104
  return result2;
2743
3105
  }
2744
3106
  async function uploadBlossom(args) {
2745
- const { request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS } = args;
3107
+ const { request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS, controller } = args;
2746
3108
  const auth = await signEvent({
2747
3109
  kind: KIND_BLOSSOM_AUTH,
2748
3110
  created_at: nowS(),
@@ -2756,33 +3118,65 @@ async function uploadBlossom(args) {
2756
3118
  const endpoint = `${trimTrailingSlash(server)}/upload`;
2757
3119
  const headers = { Authorization: nostrAuthHeader(auth) };
2758
3120
  if (request.mimeType) headers["Content-Type"] = request.mimeType;
3121
+ emitUploading(ctx, "blossom", bytes.byteLength, nowS);
2759
3122
  const res = await fetchFn(endpoint, {
2760
3123
  method: "PUT",
2761
3124
  headers,
2762
- body: bytesToArrayBuffer(bytes)
3125
+ body: bytesToArrayBuffer(bytes),
3126
+ signal: controller.signal
2763
3127
  });
2764
3128
  if (!res.ok) {
2765
3129
  return failed(ctx.uploadId, "blossom", `server rejected (HTTP ${res.status})`, sha256);
2766
3130
  }
2767
3131
  const blob = await res.json();
2768
- if (!blob.url) {
3132
+ if (typeof blob.url !== "string" || blob.url.length === 0) {
2769
3133
  return failed(ctx.uploadId, "blossom", "server returned no url", sha256);
2770
3134
  }
3135
+ if (typeof blob.sha256 !== "string" || blob.sha256.length === 0) {
3136
+ return failed(ctx.uploadId, "blossom", "server returned no sha256", sha256);
3137
+ }
3138
+ if (blob.sha256 !== sha256) {
3139
+ return failed(ctx.uploadId, "blossom", "server returned mismatched sha256", sha256);
3140
+ }
3141
+ if (!Number.isSafeInteger(blob.size) || (blob.size ?? -1) < 0) {
3142
+ return failed(ctx.uploadId, "blossom", "server returned invalid size", sha256);
3143
+ }
3144
+ if (blob.size !== bytes.byteLength) {
3145
+ return failed(ctx.uploadId, "blossom", "server returned mismatched size", sha256);
3146
+ }
3147
+ const mimeType = blob.type ?? request.mimeType;
3148
+ const nip94 = [["url", blob.url]];
3149
+ if (mimeType) nip94.push(["m", mimeType]);
3150
+ nip94.push(["x", sha256], ["size", String(bytes.byteLength)]);
2771
3151
  const result2 = {
2772
3152
  ok: true,
2773
3153
  uploadId: ctx.uploadId,
2774
3154
  status: "complete",
2775
3155
  rail: "blossom",
2776
3156
  url: blob.url,
2777
- sha256: blob.sha256 ?? sha256,
2778
- size: blob.size ?? bytes.byteLength
3157
+ sha256,
3158
+ size: bytes.byteLength,
3159
+ nip94
2779
3160
  };
2780
- if (blob.type) result2.mimeType = blob.type;
3161
+ if (mimeType) result2.mimeType = mimeType;
2781
3162
  return result2;
2782
3163
  }
2783
3164
  function failed(uploadId, rail, error, sha256) {
2784
3165
  return { ok: false, uploadId, status: "failed", rail, error, ...sha256 ? { sha256 } : {} };
2785
3166
  }
3167
+ function cancelled(uploadId, rail, sha256) {
3168
+ return { ok: false, uploadId, status: "cancelled", rail, error: "user cancelled", ...sha256 ? { sha256 } : {} };
3169
+ }
3170
+ function emitUploading(ctx, rail, bytesTotal, nowS) {
3171
+ ctx.onStatus({
3172
+ ok: true,
3173
+ uploadId: ctx.uploadId,
3174
+ status: "uploading",
3175
+ rail,
3176
+ bytesTotal,
3177
+ updatedAt: nowS() * 1e3
3178
+ });
3179
+ }
2786
3180
  function firstConfiguredRail(rails) {
2787
3181
  if (rails.nip96?.servers?.length) return "nip96";
2788
3182
  if (rails.blossom?.servers?.length) return "blossom";