@kehto/services 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1796,4 +1796,236 @@ interface RelayPoolOutboxRouterOptions {
1796
1796
  */
1797
1797
  declare function createRelayPoolOutboxRouter(options: RelayPoolOutboxRouterOptions): OutboxRouter;
1798
1798
 
1799
- export { type AudioServiceOptions, type AudioSource, type CacheServiceOptions, type ConfigSchemaValidation, type ConfigService, type ConfigServiceOptions, type CoordinatedRelayOptions, type GiftWrapDecryptResult, type HostCacheBridge, type HostDecryptBridge, type HostKeyEvent, type HostKeysBridge, type HostMediaBridge, type IdentityDecryptErrorCode, type IdentityDecryptErrorMessage, type IdentityDecryptMessage, type IdentityDecryptResultMessage, type IdentityServiceOptions, type KeysServiceOptions, type MediaMetadataLike, type MediaPlaybackOwner, type MediaServiceOptions, type MediaSessionCreateOptions, type MediaSessionTarget, type MediaSourceRef, type Notification, type NotificationServiceOptions, type NotifyServiceOptions, type OutboxPublishOptions, type OutboxPublishResult, type OutboxQueryOptions, type OutboxRelayPlan, type OutboxRelayPool, type OutboxResult, type OutboxRouter, type OutboxRouterSubscription, type OutboxServiceOptions, type OutboxStrategy, type OutboxSubscribeOptions, type OutboxSubscriptionSink, type OutboxTarget, type RelayListEntry, type RelayPoolOutboxRouterOptions, type RelayPoolServiceOptions, type ResourceService, type ResourceServiceOptions, type Rumor, type ThemeService, type ThemeServiceOptions, type VerifyEvent, createAudioService, createBrowserMediaBridge, createCacheService, createConfigService, createCoordinatedRelay, createIdentityService, createKeysService, createMediaService, createNotificationService, createNotifyService, createOutboxService, createRelayPoolOutboxRouter, createRelayPoolService, createResourceService, createThemeService };
1799
+ /**
1800
+ * upload-service.ts — NAP-UPLOAD (shell-mediated file/blob upload) reference service.
1801
+ *
1802
+ * Shell-side handler for the NAP-UPLOAD wire protocol. It is a pure envelope
1803
+ * router: it validates `upload.*` envelopes, delegates the actual byte transfer
1804
+ * (server selection, rail authorization signing, the HTTP upload) to an injected
1805
+ * {@link Uploader}, and posts the correlated result / status messages back to the
1806
+ * napplet.
1807
+ *
1808
+ * The uploader is injected (options-as-bridge) so this service has no transport
1809
+ * or Nostr dependency and is fully unit-testable. NAP-UPLOAD is deliberately
1810
+ * abstract over the backend — the runtime decides *how* it uploads (NIP-96,
1811
+ * Blossom, …). A concrete HTTP-backed uploader ships alongside as
1812
+ * {@link createHttpUploader}.
1813
+ *
1814
+ * ──────────────────────────── Responsibilities ────────────────────────────
1815
+ * Inbound: upload.upload, upload.status
1816
+ * Outbound: upload.upload.result, upload.status.result, upload.status.changed
1817
+ *
1818
+ * The service owns the `uploadId` (generated per request, scoped to the
1819
+ * requesting napplet), tracks the latest {@link UploadStatus} per upload for
1820
+ * `upload.status` queries, and cleans up on window teardown. The shell owns
1821
+ * consent, policy, server selection, signing, and the HTTP upload — all behind
1822
+ * the {@link Uploader}.
1823
+ *
1824
+ * @example
1825
+ * ```ts
1826
+ * import { createUploadService, createHttpUploader } from '@kehto/services';
1827
+ *
1828
+ * const uploader = createHttpUploader({ rails: { nip96: { servers } }, signEvent });
1829
+ * runtime.registerService('upload', createUploadService({ uploader }));
1830
+ * ```
1831
+ *
1832
+ * @packageDocumentation
1833
+ */
1834
+
1835
+ /**
1836
+ * Storage rail. `nip96` (NIP-96 HTTP file storage) and `blossom` (Blossom blob
1837
+ * storage) are the first concrete backends; the open string keeps the API
1838
+ * stable as shells add rails (torrents, usenet, …).
1839
+ */
1840
+ type UploadRail = 'nip96' | 'blossom' | (string & {});
1841
+ /** Lifecycle state of an upload. */
1842
+ type UploadState = 'pending' | 'uploading' | 'complete' | 'failed' | 'cancelled';
1843
+ /** Pixel dimensions of an uploaded image/video. */
1844
+ interface UploadDimensions {
1845
+ width: number;
1846
+ height: number;
1847
+ }
1848
+ /**
1849
+ * A napplet's upload request. `data` crosses the postMessage boundary by
1850
+ * structured clone — shells never require base64 encoding.
1851
+ */
1852
+ interface UploadRequest {
1853
+ /** Storage rail; omit to let the shell pick a configured default. */
1854
+ rail?: UploadRail;
1855
+ /** The bytes to upload. */
1856
+ data: ArrayBuffer | Blob;
1857
+ /** MIME type; inferred from `data` when omitted. */
1858
+ mimeType?: string;
1859
+ /** Suggested filename. */
1860
+ filename?: string;
1861
+ /** Alt text / description for the file event. */
1862
+ caption?: string;
1863
+ /** Request the server not re-encode the file (NIP-96 `no_transform`). */
1864
+ noTransform?: boolean;
1865
+ /** Rail-specific or shell-specific extra metadata. */
1866
+ metadata?: Record<string, unknown>;
1867
+ }
1868
+ /** A single Nostr tag (NIP-94 / imeta entries are arrays of strings). */
1869
+ type NostrTag = string[];
1870
+ /** The result of an upload. */
1871
+ interface UploadResult {
1872
+ /** Whether the upload succeeded (or is progressing) vs failed/cancelled. */
1873
+ ok: boolean;
1874
+ /** Shell-generated id, scoped to the requesting napplet. */
1875
+ uploadId: string;
1876
+ /** Current lifecycle state. */
1877
+ status: UploadState;
1878
+ /** The rail the shell used. */
1879
+ rail: UploadRail;
1880
+ /** Primary download URL. */
1881
+ url?: string;
1882
+ /** Mirrors / alternative server URLs. */
1883
+ fallbackUrls?: string[];
1884
+ /** Hash of the stored blob (NIP-94 `x`). */
1885
+ sha256?: string;
1886
+ /** Hash before server transforms (NIP-94 `ox`). */
1887
+ originalSha256?: string;
1888
+ /** Size in bytes. */
1889
+ size?: number;
1890
+ /** Stored MIME type. */
1891
+ mimeType?: string;
1892
+ /** Image/video dimensions when known. */
1893
+ dimensions?: UploadDimensions;
1894
+ /** Blurhash placeholder when known. */
1895
+ blurhash?: string;
1896
+ /** Ready-to-attach NIP-94 / imeta tags. */
1897
+ nip94?: NostrTag[];
1898
+ /** Error reason when the upload failed or was cancelled. */
1899
+ error?: string;
1900
+ }
1901
+ /** A status snapshot for an upload, including progress counters. */
1902
+ interface UploadStatus extends UploadResult {
1903
+ /** Bytes sent so far (while uploading). */
1904
+ bytesSent?: number;
1905
+ /** Total bytes to send. */
1906
+ bytesTotal?: number;
1907
+ /** Unix ms timestamp of this status. */
1908
+ updatedAt: number;
1909
+ }
1910
+ /**
1911
+ * Context handed to an {@link Uploader} for a single upload. Carries the
1912
+ * service-owned `uploadId` and a sink for streaming progress / state changes.
1913
+ */
1914
+ interface UploaderContext {
1915
+ /** The service-generated upload id (authoritative; scoped to the napplet). */
1916
+ uploadId: string;
1917
+ /** The napplet window that requested the upload. */
1918
+ windowId: string;
1919
+ /**
1920
+ * Push a status update (progress, or a transition to complete/failed). The
1921
+ * service stamps `uploadId` and `updatedAt` before forwarding to the napplet
1922
+ * as `upload.status.changed`, and records it as the latest tracked status.
1923
+ */
1924
+ onStatus(status: UploadStatus): void;
1925
+ }
1926
+ /**
1927
+ * Abstract upload backend. Implementors own server selection, rail
1928
+ * authorization signing (NIP-98 for NIP-96, kind 24242 for Blossom), the HTTP
1929
+ * upload, and integrity-hash reporting. The service translates wire envelopes
1930
+ * into these calls and back. A concrete reference implementation ships as
1931
+ * {@link createHttpUploader}.
1932
+ */
1933
+ interface Uploader {
1934
+ /** Upload `request.data`, streaming progress through `ctx.onStatus`. */
1935
+ upload(request: UploadRequest, ctx: UploaderContext): Promise<UploadResult>;
1936
+ /** Optional: resolve the latest status for an upload the service is not tracking. */
1937
+ status?(uploadId: string): Promise<UploadStatus | undefined>;
1938
+ /** Optional: abort an in-flight upload (called on window teardown). */
1939
+ cancel?(uploadId: string): void;
1940
+ }
1941
+ /** Options for {@link createUploadService}. */
1942
+ interface UploadServiceOptions {
1943
+ /** The upload backend the shell uses. Required. */
1944
+ uploader: Uploader;
1945
+ /** Generate an upload id; defaults to `crypto.randomUUID()`. */
1946
+ generateId?: () => string;
1947
+ /** Current time in unix ms; defaults to `Date.now()`. */
1948
+ now?: () => number;
1949
+ }
1950
+ /**
1951
+ * Create the NAP-UPLOAD service handler.
1952
+ *
1953
+ * @param options - Must provide an {@link Uploader}.
1954
+ * @returns A `ServiceHandler` ready for `runtime.registerService('upload', handler)`.
1955
+ * @throws If `options.uploader` is missing.
1956
+ */
1957
+ declare function createUploadService(options: UploadServiceOptions): ServiceHandler;
1958
+
1959
+ /**
1960
+ * http-uploader.ts — NAP-UPLOAD concrete HTTP-backed {@link Uploader}.
1961
+ *
1962
+ * The reference upload backend for {@link createUploadService}. Implements two
1963
+ * storage rails over HTTP:
1964
+ *
1965
+ * - **NIP-96** — signs a NIP-98 (kind 27235) HTTP-auth event, POSTs the file as
1966
+ * `multipart/form-data`, and maps the returned NIP-94 event tags into an
1967
+ * {@link UploadResult}.
1968
+ * - **Blossom** — signs a kind 24242 authorization event, PUTs the raw bytes to
1969
+ * `<server>/upload`, and maps the returned blob descriptor.
1970
+ *
1971
+ * Signing (`signEvent`) and transport (`fetch`) are injected so the uploader
1972
+ * carries no Nostr or network dependency and is fully unit-testable. The shell
1973
+ * holds the signing key and never exposes it to napplets — the uploader only
1974
+ * receives a signing callback. Server URLs are shell configuration, not napplet
1975
+ * input: a napplet may *hint* a rail, but never a server.
1976
+ *
1977
+ * The configured server URL is used directly as the upload endpoint (the
1978
+ * NIP-96 `api_url` / Blossom base). Hosts that need `.well-known` discovery can
1979
+ * resolve it before constructing the uploader.
1980
+ *
1981
+ * @example
1982
+ * ```ts
1983
+ * const uploader = createHttpUploader({
1984
+ * rails: { nip96: { servers: ['https://nostr.build/api/v2/nip96/upload'] } },
1985
+ * signEvent: (tmpl) => signer.signEvent(tmpl),
1986
+ * });
1987
+ * runtime.registerService('upload', createUploadService({ uploader }));
1988
+ * ```
1989
+ *
1990
+ * @packageDocumentation
1991
+ */
1992
+
1993
+ /** Per-rail server configuration. The first server is the primary endpoint. */
1994
+ interface RailServerConfig {
1995
+ /** Ordered server endpoint URLs; index 0 is primary. */
1996
+ servers: string[];
1997
+ }
1998
+ /** Storage rails this uploader can serve. */
1999
+ interface HttpUploaderRails {
2000
+ /** NIP-96 HTTP file storage. */
2001
+ nip96?: RailServerConfig;
2002
+ /** Blossom blob storage. */
2003
+ blossom?: RailServerConfig;
2004
+ }
2005
+ /** Signs an event template on the user's behalf (shell holds the key). */
2006
+ type SignEvent = (template: EventTemplate) => Promise<NostrEvent>;
2007
+ /** Options for {@link createHttpUploader}. */
2008
+ interface HttpUploaderOptions {
2009
+ /** Configured rails + their servers. */
2010
+ rails: HttpUploaderRails;
2011
+ /** Rail to use when a request omits one; defaults to the first configured rail. */
2012
+ defaultRail?: UploadRail;
2013
+ /** Signs NIP-98 / Blossom auth events. Required. */
2014
+ signEvent: SignEvent;
2015
+ /** Fetch implementation; defaults to the global `fetch`. */
2016
+ fetch?: typeof fetch;
2017
+ /** Hex SHA-256 of the payload bytes; defaults to Web Crypto. */
2018
+ digestSha256?: (bytes: Uint8Array) => Promise<string>;
2019
+ /** Unix *seconds* clock for event timestamps; defaults to `Date.now()/1000`. */
2020
+ now?: () => number;
2021
+ }
2022
+ /**
2023
+ * Create the reference HTTP {@link Uploader} (NIP-96 + Blossom rails).
2024
+ *
2025
+ * @param options - Rails, server config, and the injected `signEvent`.
2026
+ * @returns An {@link Uploader} for `createUploadService({ uploader })`.
2027
+ * @throws If `options.signEvent` is missing.
2028
+ */
2029
+ declare function createHttpUploader(options: HttpUploaderOptions): Uploader;
2030
+
2031
+ export { type AudioServiceOptions, type AudioSource, type CacheServiceOptions, type ConfigSchemaValidation, type ConfigService, type ConfigServiceOptions, type CoordinatedRelayOptions, type GiftWrapDecryptResult, type HostCacheBridge, type HostDecryptBridge, type HostKeyEvent, type HostKeysBridge, type HostMediaBridge, type HttpUploaderOptions, type HttpUploaderRails, type IdentityDecryptErrorCode, type IdentityDecryptErrorMessage, type IdentityDecryptMessage, type IdentityDecryptResultMessage, type IdentityServiceOptions, type KeysServiceOptions, type MediaMetadataLike, type MediaPlaybackOwner, type MediaServiceOptions, type MediaSessionCreateOptions, type MediaSessionTarget, type MediaSourceRef, type NostrTag, type Notification, type NotificationServiceOptions, type NotifyServiceOptions, type OutboxPublishOptions, type OutboxPublishResult, type OutboxQueryOptions, type OutboxRelayPlan, type OutboxRelayPool, type OutboxResult, type OutboxRouter, type OutboxRouterSubscription, type OutboxServiceOptions, type OutboxStrategy, type OutboxSubscribeOptions, type OutboxSubscriptionSink, type OutboxTarget, type RailServerConfig, type RelayListEntry, type RelayPoolOutboxRouterOptions, type RelayPoolServiceOptions, type ResourceService, type ResourceServiceOptions, type Rumor, type SignEvent, type ThemeService, type ThemeServiceOptions, type UploadDimensions, type UploadRail, type UploadRequest, type UploadResult, type UploadServiceOptions, type UploadState, type UploadStatus, type Uploader, type UploaderContext, type VerifyEvent, createAudioService, createBrowserMediaBridge, createCacheService, createConfigService, createCoordinatedRelay, createHttpUploader, createIdentityService, createKeysService, createMediaService, createNotificationService, createNotifyService, createOutboxService, createRelayPoolOutboxRouter, createRelayPoolService, createResourceService, createThemeService, createUploadService };
package/dist/index.js CHANGED
@@ -2278,6 +2278,278 @@ function normalizePublishResult(res, relayUrls) {
2278
2278
  return out;
2279
2279
  }
2280
2280
 
2281
+ // src/upload-service.ts
2282
+ var UPLOAD_SERVICE_VERSION = "1.0.0";
2283
+ var UPLOAD_DESCRIPTOR = {
2284
+ name: "upload",
2285
+ version: UPLOAD_SERVICE_VERSION,
2286
+ description: "NAP-UPLOAD shell-mediated file/blob upload \u2014 upload/status with progress pushes"
2287
+ };
2288
+ function createUploadService(options) {
2289
+ if (!options || typeof options.uploader !== "object" || options.uploader === null) {
2290
+ throw new Error("createUploadService: options.uploader is required");
2291
+ }
2292
+ const { uploader } = options;
2293
+ const generateId2 = options.generateId ?? (() => crypto.randomUUID());
2294
+ const now = options.now ?? (() => Date.now());
2295
+ const entries = /* @__PURE__ */ new Map();
2296
+ function handleUpload(windowId, msg, send) {
2297
+ const m = msg;
2298
+ const id = m.id ?? "";
2299
+ const request = m.request;
2300
+ if (!request || typeof request !== "object" || request.data == null) {
2301
+ send({ type: "upload.upload.result", id, error: "invalid request" });
2302
+ return;
2303
+ }
2304
+ const uploadId = generateId2();
2305
+ const key = `${windowId}:${uploadId}`;
2306
+ entries.set(key, { uploadId });
2307
+ const ctx = {
2308
+ uploadId,
2309
+ windowId,
2310
+ onStatus: (status) => {
2311
+ const stamped = { ...status, uploadId, updatedAt: status.updatedAt || now() };
2312
+ const entry = entries.get(key);
2313
+ if (entry) entry.status = stamped;
2314
+ send({ type: "upload.status.changed", status: stamped });
2315
+ }
2316
+ };
2317
+ void uploader.upload(request, ctx).then((result) => {
2318
+ const stamped = { ...result, uploadId };
2319
+ const entry = entries.get(key);
2320
+ if (entry) entry.status = { ...stamped, updatedAt: now() };
2321
+ send({ type: "upload.upload.result", id, result: stamped });
2322
+ }).catch((err) => {
2323
+ entries.delete(key);
2324
+ send({ type: "upload.upload.result", id, error: toErrorMessage2(err) });
2325
+ });
2326
+ }
2327
+ function handleStatus(windowId, msg, send) {
2328
+ const m = msg;
2329
+ const id = m.id ?? "";
2330
+ const uploadId = m.uploadId;
2331
+ if (typeof uploadId !== "string" || uploadId.length === 0) {
2332
+ send({ type: "upload.status.result", id, error: "invalid uploadId" });
2333
+ return;
2334
+ }
2335
+ const tracked = entries.get(`${windowId}:${uploadId}`)?.status;
2336
+ if (tracked) {
2337
+ send({ type: "upload.status.result", id, status: tracked });
2338
+ return;
2339
+ }
2340
+ if (uploader.status) {
2341
+ void uploader.status(uploadId).then(
2342
+ (status) => send(
2343
+ status ? { type: "upload.status.result", id, status } : { type: "upload.status.result", id, error: "unknown upload" }
2344
+ )
2345
+ ).catch(
2346
+ (err) => send({ type: "upload.status.result", id, error: toErrorMessage2(err) })
2347
+ );
2348
+ return;
2349
+ }
2350
+ send({ type: "upload.status.result", id, error: "unknown upload" });
2351
+ }
2352
+ return {
2353
+ descriptor: UPLOAD_DESCRIPTOR,
2354
+ handleMessage(windowId, message, send) {
2355
+ switch (message.type) {
2356
+ case "upload.upload":
2357
+ handleUpload(windowId, message, send);
2358
+ return;
2359
+ case "upload.status":
2360
+ handleStatus(windowId, message, send);
2361
+ return;
2362
+ default:
2363
+ return;
2364
+ }
2365
+ },
2366
+ onWindowDestroyed(windowId) {
2367
+ const prefix = `${windowId}:`;
2368
+ for (const [key, entry] of entries) {
2369
+ if (key.startsWith(prefix)) {
2370
+ uploader.cancel?.(entry.uploadId);
2371
+ entries.delete(key);
2372
+ }
2373
+ }
2374
+ }
2375
+ };
2376
+ }
2377
+ function toErrorMessage2(err) {
2378
+ if (err instanceof Error) return err.message;
2379
+ if (typeof err === "string") return err;
2380
+ return "upload request failed";
2381
+ }
2382
+
2383
+ // src/http-uploader.ts
2384
+ var KIND_NIP98 = 27235;
2385
+ var KIND_BLOSSOM_AUTH = 24242;
2386
+ var BLOSSOM_AUTH_TTL_S = 3600;
2387
+ function createHttpUploader(options) {
2388
+ if (!options || typeof options.signEvent !== "function") {
2389
+ throw new Error("createHttpUploader: options.signEvent is required");
2390
+ }
2391
+ const rails = options.rails ?? {};
2392
+ const signEvent = options.signEvent;
2393
+ const fetchFn = options.fetch ?? fetch;
2394
+ const digest = options.digestSha256 ?? defaultDigestSha256;
2395
+ const nowS = options.now ?? (() => Math.floor(Date.now() / 1e3));
2396
+ const defaultRail = options.defaultRail ?? firstConfiguredRail(rails);
2397
+ async function upload(request, ctx) {
2398
+ const rail = request.rail ?? defaultRail;
2399
+ const config = rail === "nip96" ? rails.nip96 : rail === "blossom" ? rails.blossom : void 0;
2400
+ if (rail !== "nip96" && rail !== "blossom") {
2401
+ return failed(ctx.uploadId, rail ?? "unknown", "unsupported rail");
2402
+ }
2403
+ const server = config?.servers?.[0];
2404
+ if (!server) {
2405
+ return failed(ctx.uploadId, rail, "no server configured");
2406
+ }
2407
+ const bytes = await toBytes(request.data);
2408
+ const sha256 = await digest(bytes);
2409
+ try {
2410
+ return rail === "nip96" ? await uploadNip96({ request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS }) : await uploadBlossom({ request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS });
2411
+ } catch (err) {
2412
+ return failed(ctx.uploadId, rail, toErrorMessage3(err), sha256);
2413
+ }
2414
+ }
2415
+ return { upload };
2416
+ }
2417
+ async function uploadNip96(args) {
2418
+ const { request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS } = args;
2419
+ const auth = await signEvent({
2420
+ kind: KIND_NIP98,
2421
+ created_at: nowS(),
2422
+ content: "",
2423
+ tags: [
2424
+ ["u", server],
2425
+ ["method", "POST"],
2426
+ ["payload", sha256]
2427
+ ]
2428
+ });
2429
+ const form = new FormData();
2430
+ form.append("file", new Blob([bytesToArrayBuffer(bytes)], { type: request.mimeType }), request.filename ?? "file");
2431
+ if (request.caption !== void 0) form.append("caption", request.caption);
2432
+ if (request.mimeType !== void 0) form.append("content_type", request.mimeType);
2433
+ if (request.noTransform) form.append("no_transform", "true");
2434
+ const res = await fetchFn(server, {
2435
+ method: "POST",
2436
+ headers: { Authorization: nostrAuthHeader(auth) },
2437
+ body: form
2438
+ });
2439
+ if (!res.ok) {
2440
+ return failed(ctx.uploadId, "nip96", `server rejected (HTTP ${res.status})`, sha256);
2441
+ }
2442
+ const body = await res.json();
2443
+ if (body.status === "error") {
2444
+ return failed(ctx.uploadId, "nip96", body.message ?? "upload failed", sha256);
2445
+ }
2446
+ const tags = body.nip94_event?.tags ?? [];
2447
+ return fromNip94Tags(ctx.uploadId, "nip96", tags, bytes.byteLength, sha256);
2448
+ }
2449
+ function fromNip94Tags(uploadId, rail, tags, fallbackSize, fallbackSha) {
2450
+ const get = (name) => tags.find((t) => t[0] === name)?.[1];
2451
+ const url = get("url");
2452
+ const result = {
2453
+ ok: Boolean(url),
2454
+ uploadId,
2455
+ status: url ? "complete" : "failed",
2456
+ rail,
2457
+ sha256: get("x") ?? fallbackSha,
2458
+ nip94: tags
2459
+ };
2460
+ if (url) result.url = url;
2461
+ const ox = get("ox");
2462
+ if (ox) result.originalSha256 = ox;
2463
+ const size = get("size");
2464
+ result.size = size ? Number(size) : fallbackSize;
2465
+ const m = get("m");
2466
+ if (m) result.mimeType = m;
2467
+ const dim = parseDimensions(get("dim"));
2468
+ if (dim) result.dimensions = dim;
2469
+ const blurhash = get("blurhash");
2470
+ if (blurhash) result.blurhash = blurhash;
2471
+ if (!url) result.error = "server returned no url";
2472
+ return result;
2473
+ }
2474
+ async function uploadBlossom(args) {
2475
+ const { request, ctx, server, bytes, sha256, signEvent, fetchFn, nowS } = args;
2476
+ const auth = await signEvent({
2477
+ kind: KIND_BLOSSOM_AUTH,
2478
+ created_at: nowS(),
2479
+ content: `Upload ${request.filename ?? "file"}`,
2480
+ tags: [
2481
+ ["t", "upload"],
2482
+ ["x", sha256],
2483
+ ["expiration", String(nowS() + BLOSSOM_AUTH_TTL_S)]
2484
+ ]
2485
+ });
2486
+ const endpoint = `${trimTrailingSlash(server)}/upload`;
2487
+ const headers = { Authorization: nostrAuthHeader(auth) };
2488
+ if (request.mimeType) headers["Content-Type"] = request.mimeType;
2489
+ const res = await fetchFn(endpoint, {
2490
+ method: "PUT",
2491
+ headers,
2492
+ body: bytesToArrayBuffer(bytes)
2493
+ });
2494
+ if (!res.ok) {
2495
+ return failed(ctx.uploadId, "blossom", `server rejected (HTTP ${res.status})`, sha256);
2496
+ }
2497
+ const blob = await res.json();
2498
+ if (!blob.url) {
2499
+ return failed(ctx.uploadId, "blossom", "server returned no url", sha256);
2500
+ }
2501
+ const result = {
2502
+ ok: true,
2503
+ uploadId: ctx.uploadId,
2504
+ status: "complete",
2505
+ rail: "blossom",
2506
+ url: blob.url,
2507
+ sha256: blob.sha256 ?? sha256,
2508
+ size: blob.size ?? bytes.byteLength
2509
+ };
2510
+ if (blob.type) result.mimeType = blob.type;
2511
+ return result;
2512
+ }
2513
+ function failed(uploadId, rail, error, sha256) {
2514
+ return { ok: false, uploadId, status: "failed", rail, error, ...sha256 ? { sha256 } : {} };
2515
+ }
2516
+ function firstConfiguredRail(rails) {
2517
+ if (rails.nip96?.servers?.length) return "nip96";
2518
+ if (rails.blossom?.servers?.length) return "blossom";
2519
+ return void 0;
2520
+ }
2521
+ function nostrAuthHeader(event) {
2522
+ return `Nostr ${base64Utf8(JSON.stringify(event))}`;
2523
+ }
2524
+ function base64Utf8(s) {
2525
+ return btoa(String.fromCharCode(...new TextEncoder().encode(s)));
2526
+ }
2527
+ function parseDimensions(dim) {
2528
+ if (!dim) return void 0;
2529
+ const m = /^(\d+)x(\d+)$/.exec(dim);
2530
+ if (!m) return void 0;
2531
+ return { width: Number(m[1]), height: Number(m[2]) };
2532
+ }
2533
+ async function toBytes(data) {
2534
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
2535
+ return new Uint8Array(await data.arrayBuffer());
2536
+ }
2537
+ function bytesToArrayBuffer(bytes) {
2538
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
2539
+ }
2540
+ function trimTrailingSlash(url) {
2541
+ return url.endsWith("/") ? url.slice(0, -1) : url;
2542
+ }
2543
+ async function defaultDigestSha256(bytes) {
2544
+ const buf = await crypto.subtle.digest("SHA-256", bytesToArrayBuffer(bytes));
2545
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
2546
+ }
2547
+ function toErrorMessage3(err) {
2548
+ if (err instanceof Error) return err.message;
2549
+ if (typeof err === "string") return err;
2550
+ return "upload failed";
2551
+ }
2552
+
2281
2553
  // src/cvm-service.ts
2282
2554
  var CVM_SERVICE_VERSION = "1.0.0";
2283
2555
  var CVM_DESCRIPTOR = {
@@ -2320,7 +2592,7 @@ function createCvmService(options) {
2320
2592
  const m = msg;
2321
2593
  const id = m.id ?? "";
2322
2594
  void transport.discover(m.query).then((servers) => send({ type: "cvm.discover.result", id, servers })).catch(
2323
- (err) => send({ type: "cvm.discover.result", id, servers: [], error: toErrorMessage2(err) })
2595
+ (err) => send({ type: "cvm.discover.result", id, servers: [], error: toErrorMessage4(err) })
2324
2596
  );
2325
2597
  }
2326
2598
  function handleRequest(windowId, msg, send) {
@@ -2336,7 +2608,7 @@ function createCvmService(options) {
2336
2608
  }
2337
2609
  openSession(windowId, m.server, send);
2338
2610
  void transport.request(m.server, m.message, m.options).then((message) => send({ type: "cvm.request.result", id, message })).catch(
2339
- (err) => send({ type: "cvm.request.result", id, error: toErrorMessage2(err) })
2611
+ (err) => send({ type: "cvm.request.result", id, error: toErrorMessage4(err) })
2340
2612
  );
2341
2613
  }
2342
2614
  function handleClose(windowId, msg, send) {
@@ -2347,7 +2619,7 @@ function createCvmService(options) {
2347
2619
  return;
2348
2620
  }
2349
2621
  closeSession(windowId, m.server.pubkey);
2350
- void transport.close(m.server).then(() => send({ type: "cvm.close.result", id })).catch((err) => send({ type: "cvm.close.result", id, error: toErrorMessage2(err) }));
2622
+ void transport.close(m.server).then(() => send({ type: "cvm.close.result", id })).catch((err) => send({ type: "cvm.close.result", id, error: toErrorMessage4(err) }));
2351
2623
  }
2352
2624
  return {
2353
2625
  descriptor: CVM_DESCRIPTOR,
@@ -2378,7 +2650,7 @@ function createCvmService(options) {
2378
2650
  }
2379
2651
  };
2380
2652
  }
2381
- function toErrorMessage2(err) {
2653
+ function toErrorMessage4(err) {
2382
2654
  if (err instanceof Error) return err.message;
2383
2655
  if (typeof err === "string") return err;
2384
2656
  return "cvm request failed";
@@ -2390,6 +2662,7 @@ export {
2390
2662
  createConfigService,
2391
2663
  createCoordinatedRelay,
2392
2664
  createCvmService,
2665
+ createHttpUploader,
2393
2666
  createIdentityService,
2394
2667
  createKeysService,
2395
2668
  createMediaService,
@@ -2399,6 +2672,7 @@ export {
2399
2672
  createRelayPoolOutboxRouter,
2400
2673
  createRelayPoolService,
2401
2674
  createResourceService,
2402
- createThemeService
2675
+ createThemeService,
2676
+ createUploadService
2403
2677
  };
2404
2678
  //# sourceMappingURL=index.js.map