Loading CHANGES.md +14 −3 Original line number Diff line number Diff line Loading @@ -91,6 +91,16 @@ the versioning. - Added `@fedify/nestjs` package. - Added `FedifyModule` for integrating Fedify into NestJS applications. - APIs making HTTP requests became able to optionally take `AbortSignal`. [[#51], [#315] by Hyunchae Kim] - Added `DocumentLoaderOptions` interface. - The `DocumentLoader` type became able to optionally take the second parameter. - Added `LookupObjectOptions.signal` option. - Added `LookupWebFingerOptions.signal` option. - Added `DoubleKnockOptions.signal` option. - Added `SqliteKvStore`, implementing `KvStore` using SQLite with the `@fedify/sqlite` package. Compatible with Bun, Deno, and Node.js. [[#274], [#318] by An Subin] Loading Loading @@ -146,6 +156,7 @@ the versioning. [#263]: https://github.com/fedify-dev/fedify/issues/263 [#267]: https://github.com/fedify-dev/fedify/issues/267 [#269]: https://github.com/fedify-dev/fedify/issues/269 [#274]: https://github.com/fedify-dev/fedify/issues/274 [#278]: https://github.com/fedify-dev/fedify/pull/278 [#281]: https://github.com/fedify-dev/fedify/pull/281 [#282]: https://github.com/fedify-dev/fedify/pull/282 Loading @@ -154,10 +165,10 @@ the versioning. [#298]: https://github.com/fedify-dev/fedify/pull/298 [#304]: https://github.com/fedify-dev/fedify/issues/304 [#309]: https://github.com/fedify-dev/fedify/pull/309 [#274]: https://github.com/fedify-dev/fedify/issues/274 [#318]: https://github.com/fedify-dev/fedify/pull/318 [#310]: https://github.com/fedify-dev/fedify/issues/310 [#311]: https://github.com/fedify-dev/fedify/issues/311 [#315]: https://github.com/fedify-dev/fedify/pull/315 [#318]: https://github.com/fedify-dev/fedify/pull/318 [#321]: https://github.com/fedify-dev/fedify/pull/321 [#328]: https://github.com/fedify-dev/fedify/pull/328 [#331]: https://github.com/fedify-dev/fedify/pull/331 Loading @@ -165,6 +176,7 @@ the versioning. [#341]: https://github.com/fedify-dev/fedify/pull/341 [#342]: https://github.com/fedify-dev/fedify/pull/342 [#348]: https://github.com/fedify-dev/fedify/pull/348 [#51]: https://github.com/fedify-dev/fedify/issues/51 [Kitty]: https://sw.kovidgoyal.net/kitty/ [WezTerm]: https://wezterm.org/ [Konsole]: https://konsole.kde.org/ Loading @@ -173,7 +185,6 @@ the versioning. [st]: https://st.suckless.org/ [iTerm]: https://iterm2.com/ Version 1.7.7 ------------- Loading packages/fedify/src/runtime/authdocloader.test.ts +66 −0 Original line number Diff line number Diff line Loading @@ -57,3 +57,69 @@ test("getAuthenticatedDocumentLoader()", async (t) => { assertRejects(() => loader("http://localhost"), UrlError); }); }); test("getAuthenticatedDocumentLoader() cancellation", { sanitizeResources: false, sanitizeOps: false, }, async (t) => { fetchMock.spyGlobal(); await t.step("document loader cancellation", async () => { fetchMock.get( "https://example.com/slow-object", () => new Promise((resolve) => { setTimeout(() => { resolve({ status: 200, headers: { "Content-Type": "application/activity+json" }, body: { "@context": "https://www.w3.org/ns/activitystreams", type: "Note", content: "Slow response", }, }); }, 1000); }), ); const loader = getAuthenticatedDocumentLoader({ keyId: new URL("https://example.com/key2"), privateKey: rsaPrivateKey2, }); const controller = new AbortController(); const promise = loader("https://example.com/slow-object", { signal: controller.signal, }); controller.abort(); await assertRejects( () => promise, Error, ); await assertRejects( () => loader("https://example.com/object", { signal: controller.signal }), Error, ); }); await t.step("immediate cancellation", async () => { const loader = getAuthenticatedDocumentLoader({ keyId: new URL("https://example.com/key2"), privateKey: rsaPrivateKey2, }); const controller = new AbortController(); controller.abort(); await assertRejects( () => loader("https://example.com/object", { signal: controller.signal }), Error, ); }); fetchMock.hardReset(); }); packages/fedify/src/runtime/authdocloader.ts +11 −2 Original line number Diff line number Diff line Loading @@ -9,6 +9,7 @@ import { createRequest, type DocumentLoader, type DocumentLoaderFactoryOptions, type DocumentLoaderOptions, getRemoteDocument, logRequest, type RemoteDocument, Loading Loading @@ -58,7 +59,10 @@ export function getAuthenticatedDocumentLoader( GetAuthenticatedDocumentLoaderOptions = {}, ): DocumentLoader { validateCryptoKey(identity.privateKey); async function load(url: string): Promise<RemoteDocument> { async function load( url: string, options?: DocumentLoaderOptions, ): Promise<RemoteDocument> { if (!allowPrivateAddress) { try { await validatePublicUrl(url); Loading @@ -73,7 +77,12 @@ export function getAuthenticatedDocumentLoader( const response = await doubleKnock( originalRequest, identity, { specDeterminer, log: logRequest, tracerProvider }, { specDeterminer, log: logRequest, tracerProvider, signal: options?.signal, }, ); return getRemoteDocument(url, response, load); } Loading packages/fedify/src/runtime/docloader.ts +47 −10 Original line number Diff line number Diff line Loading @@ -29,12 +29,28 @@ export interface RemoteDocument { documentUrl: string; } /** * Options for {@link DocumentLoader}. * @since 1.8.0 */ export interface DocumentLoaderOptions { /** * An `AbortSignal` for cancellation. * @since 1.8.0 */ signal?: AbortSignal; } /** * A JSON-LD document loader that fetches documents from the Web. * @param url The URL of the document to load. * @param options The options for the document loader. * @returns The loaded remote document. */ export type DocumentLoader = (url: string) => Promise<RemoteDocument>; export type DocumentLoader = ( url: string, options?: DocumentLoaderOptions, ) => Promise<RemoteDocument>; /** * A factory function that creates a {@link DocumentLoader} with options. Loading Loading @@ -163,7 +179,10 @@ export function logRequest(request: Request) { export async function getRemoteDocument( url: string, response: Response, fetch: (url: string) => Promise<RemoteDocument>, fetch: ( url: string, options?: DocumentLoaderOptions, ) => Promise<RemoteDocument>, ): Promise<RemoteDocument> { const documentUrl = response.url === "" ? url : response.url; const docUrl = new URL(documentUrl); Loading Loading @@ -311,7 +330,11 @@ export function getDocumentLoader( { allowPrivateAddress, skipPreloadedContexts, userAgent }: GetDocumentLoaderOptions = {}, ): DocumentLoader { async function load(url: string): Promise<RemoteDocument> { async function load( url: string, options?: DocumentLoaderOptions, ): Promise<RemoteDocument> { options?.signal?.throwIfAborted(); if (!skipPreloadedContexts && url in preloadedContexts) { logger.debug("Using preloaded context: {url}.", { url }); return { Loading @@ -337,13 +360,14 @@ export function getDocumentLoader( // to work around it we specify `redirect: "manual"` here too: // https://github.com/oven-sh/bun/issues/10754 redirect: "manual", signal: options?.signal, }); // Follow redirects manually to get the final URL: if ( response.status >= 300 && response.status < 400 && response.headers.has("Location") ) { return load(response.headers.get("Location")!); return load(response.headers.get("Location")!, options); } return getRemoteDocument(url, response, load); } Loading Loading @@ -375,15 +399,25 @@ const _fetchDocumentLoader_allowPrivateAddress = getDocumentLoader({ */ export function fetchDocumentLoader( url: string, allowPrivateAddress: boolean = false, allowPrivateAddress?: boolean, ): Promise<RemoteDocument>; export function fetchDocumentLoader( url: string, options?: DocumentLoaderOptions, ): Promise<RemoteDocument>; export function fetchDocumentLoader( url: string, arg: boolean | DocumentLoaderOptions = false, ): Promise<RemoteDocument> { const allowPrivateAddress = typeof arg === "boolean" ? arg : false; logger.warn( "fetchDocumentLoader() function is deprecated. " + "Use getDocumentLoader() function instead.", ); return (allowPrivateAddress const loader = allowPrivateAddress ? _fetchDocumentLoader_allowPrivateAddress : _fetchDocumentLoader)(url); : _fetchDocumentLoader; return loader(url); } /** Loading Loading @@ -455,9 +489,12 @@ export function kvCache( return null; } return async (url: string): Promise<RemoteDocument> => { return async ( url: string, options?: DocumentLoaderOptions, ): Promise<RemoteDocument> => { const match = matchRule(url); if (match == null) return await loader(url); if (match == null) return await loader(url, options); const key: KvKey = [...keyPrefix, url]; let cache: RemoteDocument | undefined = undefined; try { Loading @@ -471,7 +508,7 @@ export function kvCache( } } if (cache == null) { const remoteDoc = await loader(url); const remoteDoc = await loader(url, options); try { await kv.set(key, remoteDoc, { ttl: match }); } catch (error) { Loading packages/fedify/src/sig/http.test.ts +70 −0 Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ import { assertEquals, assertExists, assertFalse, assertRejects, assertStringIncludes, assertThrows, } from "@std/assert"; Loading Loading @@ -1975,3 +1976,72 @@ test("doubleKnock() regression test for redirect handling bug", async () => { fetchMock.hardReset(); }); test("signRequest() and verifyRequest() cancellation", { sanitizeResources: false, sanitizeOps: false, }, async (t) => { fetchMock.spyGlobal(); await t.step("doubleKnock cancellation", async () => { fetchMock.post( "https://example.com/slow-endpoint", () => new Promise((resolve) => { setTimeout(() => { resolve(new Response("", { status: 202 })); }, 1000); }), ); const request = new Request("https://example.com/slow-endpoint", { method: "POST", body: "Test message", headers: { "Content-Type": "text/plain", }, }); const controller = new AbortController(); const promise = doubleKnock( request, { keyId: rsaPublicKey2.id!, privateKey: rsaPrivateKey2, }, { signal: controller.signal }, ); controller.abort(); await assertRejects( () => promise, Error, ); }); await t.step("doubleKnock immediate cancellation", async () => { const request = new Request("https://example.com/", { method: "POST", body: "Hello, world!", headers: { "Content-Type": "text/plain; charset=utf-8", Accept: "text/plain", }, }); const controller = new AbortController(); controller.abort(); await assertRejects( () => doubleKnock(request, { keyId: rsaPublicKey2.id!, privateKey: rsaPrivateKey2, }, { signal: controller.signal }), Error, ); }); fetchMock.hardReset(); }); Loading
CHANGES.md +14 −3 Original line number Diff line number Diff line Loading @@ -91,6 +91,16 @@ the versioning. - Added `@fedify/nestjs` package. - Added `FedifyModule` for integrating Fedify into NestJS applications. - APIs making HTTP requests became able to optionally take `AbortSignal`. [[#51], [#315] by Hyunchae Kim] - Added `DocumentLoaderOptions` interface. - The `DocumentLoader` type became able to optionally take the second parameter. - Added `LookupObjectOptions.signal` option. - Added `LookupWebFingerOptions.signal` option. - Added `DoubleKnockOptions.signal` option. - Added `SqliteKvStore`, implementing `KvStore` using SQLite with the `@fedify/sqlite` package. Compatible with Bun, Deno, and Node.js. [[#274], [#318] by An Subin] Loading Loading @@ -146,6 +156,7 @@ the versioning. [#263]: https://github.com/fedify-dev/fedify/issues/263 [#267]: https://github.com/fedify-dev/fedify/issues/267 [#269]: https://github.com/fedify-dev/fedify/issues/269 [#274]: https://github.com/fedify-dev/fedify/issues/274 [#278]: https://github.com/fedify-dev/fedify/pull/278 [#281]: https://github.com/fedify-dev/fedify/pull/281 [#282]: https://github.com/fedify-dev/fedify/pull/282 Loading @@ -154,10 +165,10 @@ the versioning. [#298]: https://github.com/fedify-dev/fedify/pull/298 [#304]: https://github.com/fedify-dev/fedify/issues/304 [#309]: https://github.com/fedify-dev/fedify/pull/309 [#274]: https://github.com/fedify-dev/fedify/issues/274 [#318]: https://github.com/fedify-dev/fedify/pull/318 [#310]: https://github.com/fedify-dev/fedify/issues/310 [#311]: https://github.com/fedify-dev/fedify/issues/311 [#315]: https://github.com/fedify-dev/fedify/pull/315 [#318]: https://github.com/fedify-dev/fedify/pull/318 [#321]: https://github.com/fedify-dev/fedify/pull/321 [#328]: https://github.com/fedify-dev/fedify/pull/328 [#331]: https://github.com/fedify-dev/fedify/pull/331 Loading @@ -165,6 +176,7 @@ the versioning. [#341]: https://github.com/fedify-dev/fedify/pull/341 [#342]: https://github.com/fedify-dev/fedify/pull/342 [#348]: https://github.com/fedify-dev/fedify/pull/348 [#51]: https://github.com/fedify-dev/fedify/issues/51 [Kitty]: https://sw.kovidgoyal.net/kitty/ [WezTerm]: https://wezterm.org/ [Konsole]: https://konsole.kde.org/ Loading @@ -173,7 +185,6 @@ the versioning. [st]: https://st.suckless.org/ [iTerm]: https://iterm2.com/ Version 1.7.7 ------------- Loading
packages/fedify/src/runtime/authdocloader.test.ts +66 −0 Original line number Diff line number Diff line Loading @@ -57,3 +57,69 @@ test("getAuthenticatedDocumentLoader()", async (t) => { assertRejects(() => loader("http://localhost"), UrlError); }); }); test("getAuthenticatedDocumentLoader() cancellation", { sanitizeResources: false, sanitizeOps: false, }, async (t) => { fetchMock.spyGlobal(); await t.step("document loader cancellation", async () => { fetchMock.get( "https://example.com/slow-object", () => new Promise((resolve) => { setTimeout(() => { resolve({ status: 200, headers: { "Content-Type": "application/activity+json" }, body: { "@context": "https://www.w3.org/ns/activitystreams", type: "Note", content: "Slow response", }, }); }, 1000); }), ); const loader = getAuthenticatedDocumentLoader({ keyId: new URL("https://example.com/key2"), privateKey: rsaPrivateKey2, }); const controller = new AbortController(); const promise = loader("https://example.com/slow-object", { signal: controller.signal, }); controller.abort(); await assertRejects( () => promise, Error, ); await assertRejects( () => loader("https://example.com/object", { signal: controller.signal }), Error, ); }); await t.step("immediate cancellation", async () => { const loader = getAuthenticatedDocumentLoader({ keyId: new URL("https://example.com/key2"), privateKey: rsaPrivateKey2, }); const controller = new AbortController(); controller.abort(); await assertRejects( () => loader("https://example.com/object", { signal: controller.signal }), Error, ); }); fetchMock.hardReset(); });
packages/fedify/src/runtime/authdocloader.ts +11 −2 Original line number Diff line number Diff line Loading @@ -9,6 +9,7 @@ import { createRequest, type DocumentLoader, type DocumentLoaderFactoryOptions, type DocumentLoaderOptions, getRemoteDocument, logRequest, type RemoteDocument, Loading Loading @@ -58,7 +59,10 @@ export function getAuthenticatedDocumentLoader( GetAuthenticatedDocumentLoaderOptions = {}, ): DocumentLoader { validateCryptoKey(identity.privateKey); async function load(url: string): Promise<RemoteDocument> { async function load( url: string, options?: DocumentLoaderOptions, ): Promise<RemoteDocument> { if (!allowPrivateAddress) { try { await validatePublicUrl(url); Loading @@ -73,7 +77,12 @@ export function getAuthenticatedDocumentLoader( const response = await doubleKnock( originalRequest, identity, { specDeterminer, log: logRequest, tracerProvider }, { specDeterminer, log: logRequest, tracerProvider, signal: options?.signal, }, ); return getRemoteDocument(url, response, load); } Loading
packages/fedify/src/runtime/docloader.ts +47 −10 Original line number Diff line number Diff line Loading @@ -29,12 +29,28 @@ export interface RemoteDocument { documentUrl: string; } /** * Options for {@link DocumentLoader}. * @since 1.8.0 */ export interface DocumentLoaderOptions { /** * An `AbortSignal` for cancellation. * @since 1.8.0 */ signal?: AbortSignal; } /** * A JSON-LD document loader that fetches documents from the Web. * @param url The URL of the document to load. * @param options The options for the document loader. * @returns The loaded remote document. */ export type DocumentLoader = (url: string) => Promise<RemoteDocument>; export type DocumentLoader = ( url: string, options?: DocumentLoaderOptions, ) => Promise<RemoteDocument>; /** * A factory function that creates a {@link DocumentLoader} with options. Loading Loading @@ -163,7 +179,10 @@ export function logRequest(request: Request) { export async function getRemoteDocument( url: string, response: Response, fetch: (url: string) => Promise<RemoteDocument>, fetch: ( url: string, options?: DocumentLoaderOptions, ) => Promise<RemoteDocument>, ): Promise<RemoteDocument> { const documentUrl = response.url === "" ? url : response.url; const docUrl = new URL(documentUrl); Loading Loading @@ -311,7 +330,11 @@ export function getDocumentLoader( { allowPrivateAddress, skipPreloadedContexts, userAgent }: GetDocumentLoaderOptions = {}, ): DocumentLoader { async function load(url: string): Promise<RemoteDocument> { async function load( url: string, options?: DocumentLoaderOptions, ): Promise<RemoteDocument> { options?.signal?.throwIfAborted(); if (!skipPreloadedContexts && url in preloadedContexts) { logger.debug("Using preloaded context: {url}.", { url }); return { Loading @@ -337,13 +360,14 @@ export function getDocumentLoader( // to work around it we specify `redirect: "manual"` here too: // https://github.com/oven-sh/bun/issues/10754 redirect: "manual", signal: options?.signal, }); // Follow redirects manually to get the final URL: if ( response.status >= 300 && response.status < 400 && response.headers.has("Location") ) { return load(response.headers.get("Location")!); return load(response.headers.get("Location")!, options); } return getRemoteDocument(url, response, load); } Loading Loading @@ -375,15 +399,25 @@ const _fetchDocumentLoader_allowPrivateAddress = getDocumentLoader({ */ export function fetchDocumentLoader( url: string, allowPrivateAddress: boolean = false, allowPrivateAddress?: boolean, ): Promise<RemoteDocument>; export function fetchDocumentLoader( url: string, options?: DocumentLoaderOptions, ): Promise<RemoteDocument>; export function fetchDocumentLoader( url: string, arg: boolean | DocumentLoaderOptions = false, ): Promise<RemoteDocument> { const allowPrivateAddress = typeof arg === "boolean" ? arg : false; logger.warn( "fetchDocumentLoader() function is deprecated. " + "Use getDocumentLoader() function instead.", ); return (allowPrivateAddress const loader = allowPrivateAddress ? _fetchDocumentLoader_allowPrivateAddress : _fetchDocumentLoader)(url); : _fetchDocumentLoader; return loader(url); } /** Loading Loading @@ -455,9 +489,12 @@ export function kvCache( return null; } return async (url: string): Promise<RemoteDocument> => { return async ( url: string, options?: DocumentLoaderOptions, ): Promise<RemoteDocument> => { const match = matchRule(url); if (match == null) return await loader(url); if (match == null) return await loader(url, options); const key: KvKey = [...keyPrefix, url]; let cache: RemoteDocument | undefined = undefined; try { Loading @@ -471,7 +508,7 @@ export function kvCache( } } if (cache == null) { const remoteDoc = await loader(url); const remoteDoc = await loader(url, options); try { await kv.set(key, remoteDoc, { ttl: match }); } catch (error) { Loading
packages/fedify/src/sig/http.test.ts +70 −0 Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ import { assertEquals, assertExists, assertFalse, assertRejects, assertStringIncludes, assertThrows, } from "@std/assert"; Loading Loading @@ -1975,3 +1976,72 @@ test("doubleKnock() regression test for redirect handling bug", async () => { fetchMock.hardReset(); }); test("signRequest() and verifyRequest() cancellation", { sanitizeResources: false, sanitizeOps: false, }, async (t) => { fetchMock.spyGlobal(); await t.step("doubleKnock cancellation", async () => { fetchMock.post( "https://example.com/slow-endpoint", () => new Promise((resolve) => { setTimeout(() => { resolve(new Response("", { status: 202 })); }, 1000); }), ); const request = new Request("https://example.com/slow-endpoint", { method: "POST", body: "Test message", headers: { "Content-Type": "text/plain", }, }); const controller = new AbortController(); const promise = doubleKnock( request, { keyId: rsaPublicKey2.id!, privateKey: rsaPrivateKey2, }, { signal: controller.signal }, ); controller.abort(); await assertRejects( () => promise, Error, ); }); await t.step("doubleKnock immediate cancellation", async () => { const request = new Request("https://example.com/", { method: "POST", body: "Hello, world!", headers: { "Content-Type": "text/plain; charset=utf-8", Accept: "text/plain", }, }); const controller = new AbortController(); controller.abort(); await assertRejects( () => doubleKnock(request, { keyId: rsaPublicKey2.id!, privateKey: rsaPrivateKey2, }, { signal: controller.signal }), Error, ); }); fetchMock.hardReset(); });