From 6868f3971961ddea2be45a9b1cc336022de810e4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Nov 2023 12:34:19 -0600 Subject: [PATCH 01/13] NIP-46: request target proof-of-work difficulty when signing events --- src/middleware/auth98.ts | 2 +- src/sign.ts | 23 +++++++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/middleware/auth98.ts b/src/middleware/auth98.ts index 8eb73b5..0520010 100644 --- a/src/middleware/auth98.ts +++ b/src/middleware/auth98.ts @@ -91,7 +91,7 @@ function withProof( async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) { const req = localRequest(c); const reqEvent = await buildAuthEventTemplate(req, opts); - const resEvent = await signEvent(reqEvent, c); + const resEvent = await signEvent(reqEvent, c, opts); const result = await validateAuthEvent(req, resEvent, opts); if (result.success) { diff --git a/src/sign.ts b/src/sign.ts index 14c192f..b5bbd33 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -8,13 +8,22 @@ import { Sub } from '@/subs.ts'; import { eventMatchesTemplate, Time } from '@/utils.ts'; import { createAdminEvent } from '@/utils/web.ts'; +interface SignEventOpts { + /** Target proof-of-work difficulty for the signed event. */ + pow?: number; +} + /** * Sign Nostr event using the app context. * * - If a secret key is provided, it will be used to sign the event. * - If `X-Nostr-Sign` is passed, it will use NIP-46 to sign the event. */ -async function signEvent(event: EventTemplate, c: AppContext): Promise> { +async function signEvent( + event: EventTemplate, + c: AppContext, + opts: SignEventOpts = {}, +): Promise> { const seckey = c.get('seckey'); const header = c.req.headers.get('x-nostr-sign'); @@ -23,7 +32,7 @@ async function signEvent(event: EventTemplate, c: } if (header) { - return await signNostrConnect(event, c); + return await signNostrConnect(event, c, opts); } throw new HTTPException(400, { @@ -32,7 +41,11 @@ async function signEvent(event: EventTemplate, c: } /** Sign event with NIP-46, waiting in the background for the signed event. */ -async function signNostrConnect(event: EventTemplate, c: AppContext): Promise> { +async function signNostrConnect( + event: EventTemplate, + c: AppContext, + opts: SignEventOpts = {}, +): Promise> { const pubkey = c.get('pubkey'); if (!pubkey) { @@ -48,7 +61,9 @@ async function signNostrConnect(event: EventTemplate< JSON.stringify({ id: messageId, method: 'sign_event', - params: [event], + params: [event, { + pow: opts.pow, + }], }), ), tags: [['p', pubkey]], From bedc8fdf91e68af5a11344793f36b68f93a00f13 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Nov 2023 12:35:58 -0600 Subject: [PATCH 02/13] Upgrade nostr-tools to v1.17.0 --- src/deps.ts | 3 ++- src/utils/nip98.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/deps.ts b/src/deps.ts index 9c460f6..ee8277b 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -25,8 +25,9 @@ export { nip19, nip21, type UnsignedEvent, + type VerifiedEvent, verifySignature, -} from 'npm:nostr-tools@^1.14.0'; +} from 'npm:nostr-tools@^1.17.0'; export { findReplyTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; export { parseFormData } from 'npm:formdata-helper@^0.3.0'; // @deno-types="npm:@types/lodash@4.14.194" diff --git a/src/utils/nip98.ts b/src/utils/nip98.ts index facc9af..bd7e673 100644 --- a/src/utils/nip98.ts +++ b/src/utils/nip98.ts @@ -1,4 +1,4 @@ -import { type Event, type EventTemplate, nip13 } from '@/deps.ts'; +import { type Event, type EventTemplate, nip13, type VerifiedEvent } from '@/deps.ts'; import { decode64Schema, jsonSchema } from '@/schema.ts'; import { signedEventSchema } from '@/schemas/nostr.ts'; import { eventAge, findTag, nostrNow, sha256 } from '@/utils.ts'; @@ -32,7 +32,7 @@ function validateAuthEvent(req: Request, event: Event, opts: ParseAuthRequestOpt const { maxAge = Time.minutes(1), validatePayload = true, pow = 0 } = opts; const schema = signedEventSchema - .refine((event): event is Event<27235> => event.kind === 27235, 'Event must be kind 27235') + .refine((event): event is VerifiedEvent<27235> => event.kind === 27235, 'Event must be kind 27235') .refine((event) => eventAge(event) < maxAge, 'Event expired') .refine((event) => tagValue(event, 'method') === req.method, 'Event method does not match HTTP request method') .refine((event) => tagValue(event, 'u') === req.url, 'Event URL does not match request URL') From c1bf326981f0d3ff39305e3793a1f7238e798ab0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Nov 2023 12:39:20 -0600 Subject: [PATCH 03/13] c.req.headers.get --> c.req.header, hono deprecation --- src/controllers/api/streaming.ts | 4 ++-- src/controllers/nostr/relay.ts | 2 +- src/middleware/auth19.ts | 2 +- src/sign.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index 6a9e535..d7aa677 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -28,8 +28,8 @@ const streamSchema = z.enum([ type Stream = z.infer; const streamingController: AppController = (c) => { - const upgrade = c.req.headers.get('upgrade'); - const token = c.req.headers.get('sec-websocket-protocol'); + const upgrade = c.req.header('upgrade'); + const token = c.req.header('sec-websocket-protocol'); const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream')); if (upgrade?.toLowerCase() !== 'websocket') { diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index 3a5a9aa..9f5cd59 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -117,7 +117,7 @@ function prepareFilters(filters: ClientREQ[2][]): Filter[] { } const relayController: AppController = (c) => { - const upgrade = c.req.headers.get('upgrade'); + const upgrade = c.req.header('upgrade'); if (upgrade?.toLowerCase() !== 'websocket') { return c.text('Please use a Nostr client to connect.', 400); diff --git a/src/middleware/auth19.ts b/src/middleware/auth19.ts index fec79ad..19344fb 100644 --- a/src/middleware/auth19.ts +++ b/src/middleware/auth19.ts @@ -6,7 +6,7 @@ const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); /** NIP-19 auth middleware. */ const auth19: AppMiddleware = async (c, next) => { - const authHeader = c.req.headers.get('authorization'); + const authHeader = c.req.header('authorization'); const match = authHeader?.match(BEARER_REGEX); if (match) { diff --git a/src/sign.ts b/src/sign.ts index b5bbd33..0662668 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -25,7 +25,7 @@ async function signEvent( opts: SignEventOpts = {}, ): Promise> { const seckey = c.get('seckey'); - const header = c.req.headers.get('x-nostr-sign'); + const header = c.req.header('x-nostr-sign'); if (seckey) { return finishEvent(event, seckey); From 5b030c99c508481d62b89cba44e084d786d72cc3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Nov 2023 12:42:18 -0600 Subject: [PATCH 04/13] Upgrade Hono to v3.10.1 --- src/deps.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/deps.ts b/src/deps.ts index ee8277b..cdf3e3f 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -6,8 +6,8 @@ export { Hono, HTTPException, type MiddlewareHandler, -} from 'https://deno.land/x/hono@v3.7.5/mod.ts'; -export { cors, logger, serveStatic } from 'https://deno.land/x/hono@v3.7.5/middleware.ts'; +} from 'https://deno.land/x/hono@v3.10.1/mod.ts'; +export { cors, logger, serveStatic } from 'https://deno.land/x/hono@v3.10.1/middleware.ts'; export { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts'; export { Author, RelayPool } from 'https://dev.jspm.io/nostr-relaypool@0.6.28'; export { From 3cdfbac4a159c7919d1446475f6b44cadd20de50 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Nov 2023 19:25:16 -0600 Subject: [PATCH 05/13] Switch to sentry-deno --- src/deps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/deps.ts b/src/deps.ts index cdf3e3f..e3d32ef 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -74,7 +74,7 @@ export { S3Client } from 'https://deno.land/x/s3_lite_client@0.6.1/mod.ts'; export { default as IpfsHash } from 'npm:ipfs-only-hash@^4.0.0'; export { default as uuid62 } from 'npm:uuid62@^1.0.2'; export { Machina } from 'https://gitlab.com/soapbox-pub/nostr-machina/-/raw/08a157d39f2741c9a3a4364cb97db36e71d8c03a/mod.ts'; -export * as Sentry from 'npm:@sentry/node@^7.73.0'; +export * as Sentry from 'https://deno.land/x/sentry@7.78.0/index.js'; export { sentry as sentryMiddleware } from 'npm:@hono/sentry@^1.0.0'; export type * as TypeFest from 'npm:type-fest@^4.3.0'; From f4e334b5ffcc619a02db8c0eea49dd101400b377 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Nov 2023 17:57:47 -0600 Subject: [PATCH 06/13] Require POW on signup --- src/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index 4cf6afe..776de8d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -115,7 +115,7 @@ app.post('/oauth/revoke', emptyObjectController); app.post('/oauth/authorize', oauthAuthorizeController); app.get('/oauth/authorize', oauthController); -app.post('/api/v1/accounts', requireProof(), createAccountController); +app.post('/api/v1/accounts', requireProof({ pow: 20 }), createAccountController); app.get('/api/v1/accounts/verify_credentials', requirePubkey, verifyCredentialsController); app.patch( '/api/v1/accounts/update_credentials', From e55ddbd8e644cbfd4509be766ca0ac07741077b0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Nov 2023 19:57:03 -0600 Subject: [PATCH 07/13] eventMatchesTemplate: drop `nonce` tags before comparison --- src/utils.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/utils.ts b/src/utils.ts index 11ef0ea..2b258fc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -107,8 +107,22 @@ function dedupeEvents(events: Event[]): Event[] { return [...new Map(events.map((event) => [event.id, event])).values()]; } +/** Return a copy of the event with the given tags removed. */ +function stripTags(event: E, tags: string[] = []): E { + if (!tags.length) return event; + return { + ...event, + tags: event.tags.filter(([name]) => !tags.includes(name)), + }; +} + /** Ensure the template and event match on their shared keys. */ function eventMatchesTemplate(event: Event, template: EventTemplate): boolean { + const whitelist = ['nonce']; + + event = stripTags(event, whitelist); + template = stripTags(template, whitelist); + return getEventHash(event) === getEventHash({ pubkey: event.pubkey, ...template }); } From 595fb2cfc614da79c2b3e5bd51239e7b318a1fc0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Nov 2023 21:20:14 -0600 Subject: [PATCH 08/13] eventMatchesTemplate: let the event timestamp be greater than the template --- src/utils.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index 2b258fc..1f10cd3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -123,7 +123,15 @@ function eventMatchesTemplate(event: Event, template: EventTemplate): boolean { event = stripTags(event, whitelist); template = stripTags(template, whitelist); - return getEventHash(event) === getEventHash({ pubkey: event.pubkey, ...template }); + if (template.created_at > event.created_at) { + return false; + } + + return getEventHash(event) === getEventHash({ + pubkey: event.pubkey, + ...template, + created_at: event.created_at, + }); } /** Test whether the value is a Nostr ID. */ From 3a85e3f8bfe15ca5a6b66a61c8e1dafa0de774bb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 Nov 2023 18:44:23 -0600 Subject: [PATCH 09/13] Add fetchWorker for fetching off the main thread --- .tool-versions | 2 +- src/deps.ts | 1 + src/utils/nip05.ts | 3 ++- src/utils/unfurl.ts | 3 ++- src/workers/fetch.test.ts | 14 ++++++++++++++ src/workers/fetch.ts | 19 +++++++++++++++++++ src/workers/fetch.worker.ts | 17 +++++++++++++++++ 7 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 src/workers/fetch.test.ts create mode 100644 src/workers/fetch.ts create mode 100644 src/workers/fetch.worker.ts diff --git a/.tool-versions b/.tool-versions index bc89cc4..04e52ae 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -deno 1.37.1 +deno 1.38.3 diff --git a/src/deps.ts b/src/deps.ts index e3d32ef..a02ad20 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -76,5 +76,6 @@ export { default as uuid62 } from 'npm:uuid62@^1.0.2'; export { Machina } from 'https://gitlab.com/soapbox-pub/nostr-machina/-/raw/08a157d39f2741c9a3a4364cb97db36e71d8c03a/mod.ts'; export * as Sentry from 'https://deno.land/x/sentry@7.78.0/index.js'; export { sentry as sentryMiddleware } from 'npm:@hono/sentry@^1.0.0'; +export * as Comlink from 'npm:comlink@^4.4.1'; export type * as TypeFest from 'npm:type-fest@^4.3.0'; diff --git a/src/utils/nip05.ts b/src/utils/nip05.ts index 2205ded..b655175 100644 --- a/src/utils/nip05.ts +++ b/src/utils/nip05.ts @@ -1,5 +1,6 @@ import { TTLCache, z } from '@/deps.ts'; import { Time } from '@/utils/time.ts'; +import { fetchWorker } from '@/workers/fetch.ts'; const nip05Cache = new TTLCache>({ ttl: Time.hours(1), max: 5000 }); @@ -19,7 +20,7 @@ async function lookup(value: string, opts: LookupOpts = {}): Promise fetch(url, { signal }), + fetch: (url) => fetchWorker(url, { signal }), }); return { diff --git a/src/workers/fetch.test.ts b/src/workers/fetch.test.ts new file mode 100644 index 0000000..d5054fe --- /dev/null +++ b/src/workers/fetch.test.ts @@ -0,0 +1,14 @@ +import { assert } from '@/deps-test.ts'; + +import { fetchWorker } from './fetch.ts'; + +Deno.test('fetchWorker', async () => { + await sleep(2000); + const response = await fetchWorker('https://example.com'); + const text = await response.text(); + assert(text.includes('Example Domain')); +}); + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/workers/fetch.ts b/src/workers/fetch.ts new file mode 100644 index 0000000..61a4a4a --- /dev/null +++ b/src/workers/fetch.ts @@ -0,0 +1,19 @@ +import { Comlink } from '@/deps.ts'; + +import type { FetchWorker } from './fetch.worker.ts'; + +const _worker = Comlink.wrap( + new Worker( + new URL('./fetch.worker.ts', import.meta.url), + { type: 'module' }, + ), +); + +const fetchWorker: typeof fetch = async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + + const args = await _worker.fetch(url); + return new Response(...args); +}; + +export { fetchWorker }; diff --git a/src/workers/fetch.worker.ts b/src/workers/fetch.worker.ts new file mode 100644 index 0000000..3c86255 --- /dev/null +++ b/src/workers/fetch.worker.ts @@ -0,0 +1,17 @@ +import { Comlink } from '@/deps.ts'; + +export const FetchWorker = { + async fetch(url: string): Promise<[BodyInit, ResponseInit]> { + const response = await fetch(url); + return [ + await response.text(), + { + status: response.status, + statusText: response.statusText, + headers: Array.from(response.headers.entries()), + }, + ]; + }, +}; + +Comlink.expose(FetchWorker); From da3efaa5bcb1cdecf81df938ad8d516f324e0c44 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 Nov 2023 20:55:43 -0600 Subject: [PATCH 10/13] fetchWorker: support RequestInit --- src/deps-test.ts | 2 +- src/workers/fetch.test.ts | 21 +++++++++++-- src/workers/fetch.ts | 8 +++-- src/workers/fetch.worker.ts | 10 +++++-- src/workers/handlers/abortsignal.ts | 46 +++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 src/workers/handlers/abortsignal.ts diff --git a/src/deps-test.ts b/src/deps-test.ts index 1448854..3e6da88 100644 --- a/src/deps-test.ts +++ b/src/deps-test.ts @@ -1 +1 @@ -export { assert, assertEquals, assertThrows } from 'https://deno.land/std@0.198.0/assert/mod.ts'; +export { assert, assertEquals, assertRejects, assertThrows } from 'https://deno.land/std@0.198.0/assert/mod.ts'; diff --git a/src/workers/fetch.test.ts b/src/workers/fetch.test.ts index d5054fe..21fa1de 100644 --- a/src/workers/fetch.test.ts +++ b/src/workers/fetch.test.ts @@ -1,14 +1,31 @@ -import { assert } from '@/deps-test.ts'; +import { assert, assertRejects } from '@/deps-test.ts'; import { fetchWorker } from './fetch.ts'; +await sleep(2000); + Deno.test('fetchWorker', async () => { - await sleep(2000); const response = await fetchWorker('https://example.com'); const text = await response.text(); assert(text.includes('Example Domain')); }); +Deno.test({ + name: 'fetchWorker with AbortSignal', + async fn() { + const controller = new AbortController(); + const signal = controller.signal; + + setTimeout(() => controller.abort(), 100); + assertRejects(() => fetchWorker('http://httpbin.org/delay/10', { signal })); + + await new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }); + }); + }, + sanitizeResources: false, +}); + function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/src/workers/fetch.ts b/src/workers/fetch.ts index 61a4a4a..87b622a 100644 --- a/src/workers/fetch.ts +++ b/src/workers/fetch.ts @@ -1,5 +1,7 @@ import { Comlink } from '@/deps.ts'; +import './handlers/abortsignal.ts'; + import type { FetchWorker } from './fetch.worker.ts'; const _worker = Comlink.wrap( @@ -9,10 +11,10 @@ const _worker = Comlink.wrap( ), ); -const fetchWorker: typeof fetch = async (input) => { +const fetchWorker: typeof fetch = async (input, init) => { + const { signal, ...rest } = init || {}; const url = input instanceof Request ? input.url : input.toString(); - - const args = await _worker.fetch(url); + const args = await _worker.fetch(url, rest, signal); return new Response(...args); }; diff --git a/src/workers/fetch.worker.ts b/src/workers/fetch.worker.ts index 3c86255..e36be4a 100644 --- a/src/workers/fetch.worker.ts +++ b/src/workers/fetch.worker.ts @@ -1,8 +1,14 @@ import { Comlink } from '@/deps.ts'; +import './handlers/abortsignal.ts'; + export const FetchWorker = { - async fetch(url: string): Promise<[BodyInit, ResponseInit]> { - const response = await fetch(url); + async fetch( + url: string, + init: Omit, + signal: AbortSignal | null | undefined, + ): Promise<[BodyInit, ResponseInit]> { + const response = await fetch(url, { ...init, signal }); return [ await response.text(), { diff --git a/src/workers/handlers/abortsignal.ts b/src/workers/handlers/abortsignal.ts new file mode 100644 index 0000000..c4c6a3e --- /dev/null +++ b/src/workers/handlers/abortsignal.ts @@ -0,0 +1,46 @@ +import { Comlink } from '@/deps.ts'; + +const signalFinalizers = new FinalizationRegistry((port: MessagePort) => { + port.postMessage(null); + port.close(); +}); + +Comlink.transferHandlers.set('abortsignal', { + canHandle(value) { + return value instanceof AbortSignal || value?.constructor?.name === 'AbortSignal'; + }, + serialize(signal) { + if (signal.aborted) { + return [{ aborted: true }]; + } + + const { port1, port2 } = new MessageChannel(); + signal.addEventListener( + 'abort', + () => port1.postMessage({ reason: signal.reason }), + { once: true }, + ); + + signalFinalizers?.register(signal, port1); + + return [{ aborted: false, port: port2 }, [port2]]; + }, + deserialize({ aborted, port }) { + if (aborted || !port) { + return AbortSignal.abort(); + } + + const ctrl = new AbortController(); + + port.addEventListener('message', (ev) => { + if (ev.data && 'reason' in ev.data) { + ctrl.abort(ev.data.reason); + } + port.close(); + }, { once: true }); + + port.start(); + + return ctrl.signal; + }, +} as Comlink.TransferHandler); From 99964c4d0e72fe8a93b6cb765c590345d1be32db Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 Nov 2023 13:01:48 -0600 Subject: [PATCH 11/13] fetchWorker: support POST'ing (and FormData) --- src/uploaders/ipfs.ts | 5 ++-- src/workers/fetch.test.ts | 12 +++++--- src/workers/fetch.ts | 60 +++++++++++++++++++++++++++++++++++---- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/uploaders/ipfs.ts b/src/uploaders/ipfs.ts index e6a33cd..7438205 100644 --- a/src/uploaders/ipfs.ts +++ b/src/uploaders/ipfs.ts @@ -1,5 +1,6 @@ import { Conf } from '@/config.ts'; import { z } from '@/deps.ts'; +import { fetchWorker } from '@/workers/fetch.ts'; import type { Uploader } from './types.ts'; @@ -22,7 +23,7 @@ const ipfsUploader: Uploader = { const formData = new FormData(); formData.append('file', file); - const response = await fetch(url, { + const response = await fetchWorker(url, { method: 'POST', body: formData, }); @@ -41,7 +42,7 @@ const ipfsUploader: Uploader = { url.search = query.toString(); - await fetch(url, { + await fetchWorker(url, { method: 'POST', }); }, diff --git a/src/workers/fetch.test.ts b/src/workers/fetch.test.ts index 21fa1de..c985ee6 100644 --- a/src/workers/fetch.test.ts +++ b/src/workers/fetch.test.ts @@ -4,10 +4,14 @@ import { fetchWorker } from './fetch.ts'; await sleep(2000); -Deno.test('fetchWorker', async () => { - const response = await fetchWorker('https://example.com'); - const text = await response.text(); - assert(text.includes('Example Domain')); +Deno.test({ + name: 'fetchWorker', + async fn() { + const response = await fetchWorker('https://example.com'); + const text = await response.text(); + assert(text.includes('Example Domain')); + }, + sanitizeResources: false, }); Deno.test({ diff --git a/src/workers/fetch.ts b/src/workers/fetch.ts index 87b622a..3246e78 100644 --- a/src/workers/fetch.ts +++ b/src/workers/fetch.ts @@ -11,11 +11,61 @@ const _worker = Comlink.wrap( ), ); -const fetchWorker: typeof fetch = async (input, init) => { - const { signal, ...rest } = init || {}; - const url = input instanceof Request ? input.url : input.toString(); - const args = await _worker.fetch(url, rest, signal); - return new Response(...args); +/** + * Fetch implementation with a Web Worker. + * Calling this performs the fetch in a separate CPU thread so it doesn't block the main thread. + */ +const fetchWorker: typeof fetch = async (...args) => { + const [url, init] = serializeFetchArgs(args); + const { body, signal, ...rest } = init; + const result = await _worker.fetch(url, { ...rest, body: await prepareBodyForWorker(body) }, signal); + return new Response(...result); }; +/** Take arguments to `fetch`, and turn them into something we can send over Comlink. */ +function serializeFetchArgs(args: Parameters): [string, RequestInit] { + const request = normalizeRequest(args); + const init = requestToInit(request); + return [request.url, init]; +} + +/** Get a `Request` object from arguments to `fetch`. */ +function normalizeRequest(args: Parameters): Request { + return new Request(...args); +} + +/** Get the body as a type we can transfer over Web Workers. */ +async function prepareBodyForWorker( + body: BodyInit | undefined | null, +): Promise { + if (!body || typeof body === 'string' || body instanceof ArrayBuffer || body instanceof Blob) { + return body; + } else { + const response = new Response(body); + return await response.arrayBuffer(); + } +} + +/** + * Convert a `Request` object into its serialized `RequestInit` format. + * `RequestInit` is a subset of `Request`, just lacking helper methods like `json()`, + * making it easier to serialize (exceptions: `body` and `signal`). + */ +function requestToInit(request: Request): RequestInit { + return { + method: request.method, + headers: [...request.headers.entries()], + body: request.body, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + mode: request.mode, + credentials: request.credentials, + cache: request.cache, + redirect: request.redirect, + integrity: request.integrity, + keepalive: request.keepalive, + signal: request.signal, + }; +} + export { fetchWorker }; From 86749cc285813d863d64a40c2fba286f40d37b26 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 Nov 2023 13:03:55 -0600 Subject: [PATCH 12/13] fetchWorker: return response as ArrayBuffer --- src/workers/fetch.worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workers/fetch.worker.ts b/src/workers/fetch.worker.ts index e36be4a..fb380e0 100644 --- a/src/workers/fetch.worker.ts +++ b/src/workers/fetch.worker.ts @@ -10,7 +10,7 @@ export const FetchWorker = { ): Promise<[BodyInit, ResponseInit]> { const response = await fetch(url, { ...init, signal }); return [ - await response.text(), + await response.arrayBuffer(), { status: response.status, statusText: response.statusText, From 1232c5a8380bd40fb0ceb3b5ae97f69124fc4036 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 Nov 2023 13:04:22 -0600 Subject: [PATCH 13/13] fetchWorker: Array.from --> [...] --- src/workers/fetch.worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workers/fetch.worker.ts b/src/workers/fetch.worker.ts index fb380e0..2988a2e 100644 --- a/src/workers/fetch.worker.ts +++ b/src/workers/fetch.worker.ts @@ -14,7 +14,7 @@ export const FetchWorker = { { status: response.status, statusText: response.statusText, - headers: Array.from(response.headers.entries()), + headers: [...response.headers.entries()], }, ]; },