@kehto/services 0.16.1 → 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;
867
- }
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
- }
901
- }
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;
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
+ });
910
984
  }
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
- }
985
+ if (existing) {
986
+ try {
987
+ existing();
988
+ } catch {
925
989
  }
926
- return;
990
+ unsubscribeHandles.delete(m.action.id);
927
991
  }
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;
1057
- }
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;
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);
1073
1145
  }
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) {
@@ -2070,8 +2167,9 @@ function sanitizePublishOptions(raw) {
2070
2167
  const out = {};
2071
2168
  const relays = stringArray(raw.relays);
2072
2169
  if (relays) out.relays = relays;
2073
- const targetAuthors = stringArray(raw.targetAuthors);
2074
- if (targetAuthors) out.targetAuthors = targetAuthors;
2170
+ if (typeof raw.toOutbox === "boolean") out.toOutbox = raw.toOutbox;
2171
+ const toInboxes = stringArray(raw.toInboxes);
2172
+ if (toInboxes) out.toInboxes = toInboxes;
2075
2173
  return Object.keys(out).length > 0 ? out : void 0;
2076
2174
  }
2077
2175
  function sanitizeTarget(raw) {
@@ -2293,9 +2391,33 @@ function allowed(ctx, urls) {
2293
2391
  }
2294
2392
  return [...out];
2295
2393
  }
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
+ }
2296
2418
  async function resolvePlan(ctx, pubkeys, direction, relayHints) {
2297
2419
  const useWrite = direction === "read";
2298
- const collected = /* @__PURE__ */ new Set();
2420
+ const discovered = /* @__PURE__ */ new Set();
2299
2421
  const missingAuthors = [];
2300
2422
  let sawNip65 = false;
2301
2423
  if (pubkeys.length > 0) {
@@ -2305,25 +2427,46 @@ async function resolvePlan(ctx, pubkeys, direction, relayHints) {
2305
2427
  const relays2 = entry ? useWrite ? entry.write : entry.read : void 0;
2306
2428
  if (relays2 && relays2.length > 0) {
2307
2429
  sawNip65 = true;
2308
- for (const url of relays2) collected.add(url);
2430
+ for (const url of relays2) discovered.add(url);
2309
2431
  } else {
2310
2432
  missingAuthors.push(pubkey);
2311
2433
  }
2312
2434
  }
2313
2435
  }
2314
- for (const url of relayHints ?? []) collected.add(url);
2315
- let relays = allowed(ctx, collected);
2436
+ const hintRelays = allowed(ctx, relayHints ?? []);
2437
+ let relays = allowed(ctx, discovered);
2316
2438
  let source;
2317
- if (relays.length === 0) {
2318
- relays = allowed(ctx, ctx.fallbackRelays);
2319
- source = "fallback";
2320
- } else {
2439
+ if (relays.length > 0) {
2321
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);
2322
2447
  }
2448
+ if (relays.length === 0) source = "fallback";
2323
2449
  const plan = { relays, source };
2324
2450
  if (missingAuthors.length > 0) plan.missingAuthors = missingAuthors;
2325
2451
  return plan;
2326
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
+ }
2327
2470
  function recordRelay(collector, id, relayUrl) {
2328
2471
  let set = collector.relayMap.get(id);
2329
2472
  if (!set) {
@@ -2332,81 +2475,166 @@ function recordRelay(collector, id, relayUrl) {
2332
2475
  }
2333
2476
  set.add(relayUrl);
2334
2477
  }
2335
- function admitEvent(ctx, collector, event) {
2336
- if (collector.seen.has(event.id)) return;
2337
- collector.verifications.push(
2338
- ctx.verify(event).then((ok) => {
2339
- if (ok && !collector.seen.has(event.id)) collector.seen.set(event.id, event);
2340
- else if (!ok) collector.relayMap.delete(event.id);
2341
- })
2342
- );
2343
- }
2344
- function buildCollectResult(collector, timedOut) {
2345
- const events = [...collector.seen.values()].map(
2346
- (event) => createRelayEventResultWithHints3(event, [...collector.relayMap.get(event.id) ?? []])
2347
- );
2348
- return { events, incomplete: timedOut };
2349
- }
2350
- function collectFromRelays(ctx, filters, relayUrls2, timeoutMs) {
2351
- return new Promise((resolve) => {
2352
- const collector = { seen: /* @__PURE__ */ new Map(), relayMap: /* @__PURE__ */ new Map(), verifications: [] };
2353
- const handles = [];
2354
- let eoseCount = 0;
2355
- let finished = false;
2356
- let timedOut = false;
2357
- function finalize() {
2358
- if (finished) return;
2359
- finished = true;
2360
- clearTimeout(timer);
2361
- for (const handle of handles) {
2362
- try {
2363
- handle.unsubscribe();
2364
- } catch {
2365
- }
2366
- }
2367
- 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;
2368
2490
  }
2369
- const timer = setTimeout(() => {
2370
- timedOut = true;
2371
- finalize();
2372
- }, timeoutMs);
2373
- for (const relayUrl of relayUrls2) {
2374
- handles.push(relayPoolSubscribe(ctx, filters, relayUrl, (item) => {
2375
- if (finished) return;
2376
- if (item === "EOSE") {
2377
- eoseCount += 1;
2378
- if (eoseCount >= relayUrls2.length) finalize();
2379
- return;
2380
- }
2381
- recordRelay(collector, item.id, relayUrl);
2382
- admitEvent(ctx, collector, item);
2383
- }));
2491
+ collector.seen.set(event.id, event);
2492
+ try {
2493
+ sink.event(collectedEventResult(collector, event));
2494
+ } catch {
2384
2495
  }
2385
- if (relayUrls2.length === 0) finalize();
2496
+ }).finally(() => {
2497
+ collector.pendingVerifications -= 1;
2498
+ onSettled();
2386
2499
  });
2387
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
+ }
2388
2511
  function relayPoolSubscribe(ctx, filters, relayUrl, cb) {
2389
2512
  return ctx.relayPool.subscribe(filters, [relayUrl], cb);
2390
2513
  }
2391
- async function queryImpl(ctx, filters, options) {
2514
+ function startQueryImpl(ctx, filters, options, sink) {
2392
2515
  if (!ctx.relayPool.isAvailable()) {
2393
- 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
+ };
2394
2521
  }
2395
2522
  const authors = deriveAuthors(filters, options?.authors);
2396
- const plan = await resolvePlan(ctx, authors, "read", options?.relays);
2397
- if (plan.relays.length === 0) {
2398
- return { events: [], incomplete: true, error: "relay list unavailable" };
2399
- }
2400
- const timeoutMs = options?.timeoutMs ?? ctx.defaultTimeoutMs;
2401
- const collected = await collectFromRelays(ctx, filters, plan.relays, timeoutMs);
2402
- const incomplete = collected.incomplete || (plan.missingAuthors?.length ?? 0) > 0;
2403
- let events = collected.events;
2404
- if (options?.limit !== void 0 && events.length > options.limit) {
2405
- 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
+ }
2406
2589
  }
2407
- const result2 = { events };
2408
- if (incomplete) result2.incomplete = true;
2409
- 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;
2410
2638
  }
2411
2639
  async function getEventImpl(ctx, eventId, options) {
2412
2640
  if (typeof eventId !== "string" || eventId.length === 0) {
@@ -2430,57 +2658,138 @@ async function getEventImpl(ctx, eventId, options) {
2430
2658
  return result2;
2431
2659
  }
2432
2660
  function closeLiveSub(sub) {
2433
- for (const handle of sub.handles) {
2661
+ for (const handle of sub.handles.values()) {
2434
2662
  try {
2435
2663
  handle.unsubscribe();
2436
2664
  } catch {
2437
2665
  }
2438
2666
  }
2439
- sub.handles.length = 0;
2667
+ sub.handles.clear();
2440
2668
  }
2441
2669
  function attachLiveRelay(ctx, sub, filters, relayUrl, sink) {
2442
- sub.handles.push(relayPoolSubscribe(ctx, filters, relayUrl, (item) => {
2443
- if (sub.closed) return;
2444
- if (item === "EOSE") return;
2445
- if (sub.seen.has(item.id)) return;
2446
- void ctx.verify(item).then((ok) => {
2447
- if (!ok || sub.closed || sub.seen.has(item.id)) return;
2448
- sub.seen.add(item.id);
2449
- sink.event(createRelayEventResultWithHints3(item, [relayUrl]));
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
+ });
2450
2681
  });
2451
- }));
2682
+ if (sub.closed) {
2683
+ try {
2684
+ handle.unsubscribe();
2685
+ } catch {
2686
+ }
2687
+ } else {
2688
+ sub.handles.set(relayUrl, handle);
2689
+ }
2690
+ return true;
2691
+ } catch {
2692
+ sub.relays.delete(relayUrl);
2693
+ return false;
2694
+ }
2452
2695
  }
2453
2696
  function startSubscription(ctx, filters, options, sink) {
2454
2697
  const authors = deriveAuthors(filters, options?.authors);
2455
- const sub = { handles: [], seen: /* @__PURE__ */ new Set(), closed: false };
2456
- void resolvePlan(ctx, authors, "read", options?.relays).then((plan) => {
2457
- if (sub.closed) return;
2458
- if (plan.relays.length === 0) {
2459
- sink.closed("relay list unavailable");
2460
- 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();
2461
2730
  }
2462
- for (const relayUrl of plan.relays) attachLiveRelay(ctx, sub, filters, relayUrl, sink);
2463
- }).catch((err2) => {
2464
- if (!sub.closed) sink.closed(err2 instanceof Error ? err2.message : "subscribe failed");
2465
- });
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
+ }
2466
2757
  return {
2467
2758
  close() {
2468
2759
  if (sub.closed) return;
2469
2760
  sub.closed = true;
2761
+ discoveryOpen = false;
2762
+ clearTimeout(discoveryTimer);
2470
2763
  closeLiveSub(sub);
2471
2764
  }
2472
2765
  };
2473
2766
  }
2474
2767
  async function resolvePublishTargets(ctx, signed, options) {
2475
2768
  const targets = /* @__PURE__ */ new Set();
2476
- const authorPlan = await resolvePlan(ctx, [signed.pubkey], "read");
2477
- for (const url of authorPlan.relays) targets.add(url);
2478
- if (options?.targetAuthors && options.targetAuthors.length > 0) {
2479
- const inboxPlan = await resolvePlan(ctx, options.targetAuthors, "write");
2480
- 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
+ }
2481
2787
  }
2482
2788
  for (const url of options?.relays ?? []) targets.add(url);
2483
- return allowed(ctx, targets);
2789
+ return {
2790
+ relayUrls: allowed(ctx, targets),
2791
+ requiredRelayUrls: allowed(ctx, requiredTargets)
2792
+ };
2484
2793
  }
2485
2794
  async function publishImpl(ctx, template, options) {
2486
2795
  if (!ctx.signEvent) return { ok: false, error: "publish denied" };
@@ -2491,7 +2800,11 @@ async function publishImpl(ctx, template, options) {
2491
2800
  } catch (err2) {
2492
2801
  return { ok: false, error: err2 instanceof Error ? err2.message : "sign failed" };
2493
2802
  }
2494
- 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;
2495
2808
  if (relayUrls2.length === 0) return { ok: false, event: signed, eventId: signed.id, error: "relay list unavailable" };
2496
2809
  let relays;
2497
2810
  try {
@@ -2499,7 +2812,7 @@ async function publishImpl(ctx, template, options) {
2499
2812
  } catch (err2) {
2500
2813
  return { ok: false, event: signed, eventId: signed.id, error: err2 instanceof Error ? err2.message : "publish failed" };
2501
2814
  }
2502
- 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);
2503
2816
  const result2 = { ok, event: signed, eventId: signed.id, relays };
2504
2817
  if (!ok) result2.error = "publish denied";
2505
2818
  return result2;
@@ -2534,6 +2847,7 @@ function createRelayPoolOutboxRouter(options) {
2534
2847
  return {
2535
2848
  getEvent: (eventId, eventOptions) => getEventImpl(ctx, eventId, eventOptions),
2536
2849
  query: (filters, queryOptions) => queryImpl(ctx, filters, queryOptions),
2850
+ queryStream: (filters, queryOptions, sink) => startQueryImpl(ctx, filters, queryOptions, sink),
2537
2851
  subscribe: (filters, subscribeOptions, sink) => startSubscription(ctx, filters, subscribeOptions, sink),
2538
2852
  publish: (template, publishOptions) => publishImpl(ctx, template, publishOptions),
2539
2853
  resolveRelays: (target) => {
@@ -2695,6 +3009,7 @@ function createHttpUploader(options) {
2695
3009
  const digest = options.digestSha256 ?? defaultDigestSha256;
2696
3010
  const nowS = options.now ?? (() => Math.floor(Date.now() / 1e3));
2697
3011
  const defaultRail = options.defaultRail ?? firstConfiguredRail(rails);
3012
+ const inFlight = /* @__PURE__ */ new Map();
2698
3013
  async function upload(request, ctx) {
2699
3014
  const rail = request.rail ?? defaultRail;
2700
3015
  const config = rail === "nip96" ? rails.nip96 : rail === "blossom" ? rails.blossom : void 0;
@@ -2705,18 +3020,32 @@ function createHttpUploader(options) {
2705
3020
  if (!server) {
2706
3021
  return failed(ctx.uploadId, rail, "no server configured");
2707
3022
  }
2708
- const bytes = await toBytes(request.data);
2709
- const sha256 = await digest(bytes);
3023
+ const controller = new AbortController();
3024
+ inFlight.set(ctx.uploadId, controller);
3025
+ let sha256;
2710
3026
  try {
2711
- 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 });
2712
3033
  } catch (err2) {
3034
+ if (controller.signal.aborted) {
3035
+ return cancelled(ctx.uploadId, rail, sha256);
3036
+ }
2713
3037
  return failed(ctx.uploadId, rail, toErrorMessage3(err2), sha256);
3038
+ } finally {
3039
+ inFlight.delete(ctx.uploadId);
2714
3040
  }
2715
3041
  }
2716
- return { upload };
3042
+ function cancel(uploadId) {
3043
+ inFlight.get(uploadId)?.abort();
3044
+ }
3045
+ return { upload, cancel };
2717
3046
  }
2718
3047
  async function uploadNip96(args) {
2719
- const { request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS } = args;
3048
+ const { request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS, controller } = args;
2720
3049
  const auth = await signEvent({
2721
3050
  kind: KIND_NIP98,
2722
3051
  created_at: nowS(),
@@ -2732,10 +3061,12 @@ async function uploadNip96(args) {
2732
3061
  if (request.caption !== void 0) form.append("caption", request.caption);
2733
3062
  if (request.mimeType !== void 0) form.append("content_type", request.mimeType);
2734
3063
  if (request.noTransform) form.append("no_transform", "true");
3064
+ emitUploading(ctx, "nip96", bytes.byteLength, nowS);
2735
3065
  const res = await fetchFn(server, {
2736
3066
  method: "POST",
2737
3067
  headers: { Authorization: nostrAuthHeader(auth) },
2738
- body: form
3068
+ body: form,
3069
+ signal: controller.signal
2739
3070
  });
2740
3071
  if (!res.ok) {
2741
3072
  return failed(ctx.uploadId, "nip96", `server rejected (HTTP ${res.status})`, sha256);
@@ -2773,7 +3104,7 @@ function fromNip94Tags(uploadId, rail, tags, fallbackSize, fallbackSha) {
2773
3104
  return result2;
2774
3105
  }
2775
3106
  async function uploadBlossom(args) {
2776
- const { request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS } = args;
3107
+ const { request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS, controller } = args;
2777
3108
  const auth = await signEvent({
2778
3109
  kind: KIND_BLOSSOM_AUTH,
2779
3110
  created_at: nowS(),
@@ -2787,33 +3118,65 @@ async function uploadBlossom(args) {
2787
3118
  const endpoint = `${trimTrailingSlash(server)}/upload`;
2788
3119
  const headers = { Authorization: nostrAuthHeader(auth) };
2789
3120
  if (request.mimeType) headers["Content-Type"] = request.mimeType;
3121
+ emitUploading(ctx, "blossom", bytes.byteLength, nowS);
2790
3122
  const res = await fetchFn(endpoint, {
2791
3123
  method: "PUT",
2792
3124
  headers,
2793
- body: bytesToArrayBuffer(bytes)
3125
+ body: bytesToArrayBuffer(bytes),
3126
+ signal: controller.signal
2794
3127
  });
2795
3128
  if (!res.ok) {
2796
3129
  return failed(ctx.uploadId, "blossom", `server rejected (HTTP ${res.status})`, sha256);
2797
3130
  }
2798
3131
  const blob = await res.json();
2799
- if (!blob.url) {
3132
+ if (typeof blob.url !== "string" || blob.url.length === 0) {
2800
3133
  return failed(ctx.uploadId, "blossom", "server returned no url", sha256);
2801
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)]);
2802
3151
  const result2 = {
2803
3152
  ok: true,
2804
3153
  uploadId: ctx.uploadId,
2805
3154
  status: "complete",
2806
3155
  rail: "blossom",
2807
3156
  url: blob.url,
2808
- sha256: blob.sha256 ?? sha256,
2809
- size: blob.size ?? bytes.byteLength
3157
+ sha256,
3158
+ size: bytes.byteLength,
3159
+ nip94
2810
3160
  };
2811
- if (blob.type) result2.mimeType = blob.type;
3161
+ if (mimeType) result2.mimeType = mimeType;
2812
3162
  return result2;
2813
3163
  }
2814
3164
  function failed(uploadId, rail, error, sha256) {
2815
3165
  return { ok: false, uploadId, status: "failed", rail, error, ...sha256 ? { sha256 } : {} };
2816
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
+ }
2817
3180
  function firstConfiguredRail(rails) {
2818
3181
  if (rails.nip96?.servers?.length) return "nip96";
2819
3182
  if (rails.blossom?.servers?.length) return "blossom";