@fedify/nuxt 2.2.0-dev.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/mod.js ADDED
@@ -0,0 +1,68 @@
1
+ import { addServerHandler, addServerPlugin, addServerTemplate, createResolver, defineNuxtModule, resolveAlias } from "@nuxt/kit";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ //#region src/module.ts
4
+ function resolveModulePath(modulePath, aliases, rootDir) {
5
+ const resolved = resolveAlias(modulePath, aliases);
6
+ if (isAbsolute(resolved)) return resolved;
7
+ if (resolved.startsWith("./") || resolved.startsWith("../")) return resolve(rootDir, resolved);
8
+ return resolved;
9
+ }
10
+ function buildContextFactoryResolver(contextDataFactoryModule) {
11
+ if (contextDataFactoryModule == null) return "const contextDataFactory = undefined;";
12
+ return `
13
+ const contextDataFactory =
14
+ contextFactoryModule.default ??
15
+ contextFactoryModule.contextDataFactory;
16
+ if (contextDataFactory == null) {
17
+ throw new TypeError(
18
+ \'@fedify/nuxt: contextDataFactoryModule must export a function as "default" or named "contextDataFactory", but neither was found.\'
19
+ );
20
+ }
21
+ if (typeof contextDataFactory !== 'function') {
22
+ throw new TypeError(
23
+ \'@fedify/nuxt: contextDataFactoryModule must export a function as "default" or named "contextDataFactory".\'
24
+ );
25
+ }`;
26
+ }
27
+ /**
28
+ * Nuxt module to integrate Fedify with Nuxt/Nitro request handling.
29
+ */
30
+ const fedifyNuxtModule = defineNuxtModule({
31
+ meta: {
32
+ name: "@fedify/nuxt",
33
+ configKey: "fedify"
34
+ },
35
+ defaults: {
36
+ federationModule: "#server/federation",
37
+ contextDataFactoryModule: void 0
38
+ },
39
+ setup(options, nuxt) {
40
+ const resolver = createResolver(import.meta.url);
41
+ const rootDir = nuxt.options.rootDir;
42
+ const federationModule = resolveModulePath(options.federationModule, nuxt.options.alias, rootDir);
43
+ const contextDataFactoryModule = options.contextDataFactoryModule == null ? void 0 : resolveModulePath(options.contextDataFactoryModule, nuxt.options.alias, rootDir);
44
+ const middlewareFilename = "fedify-nuxt-options.mjs";
45
+ addServerTemplate({
46
+ filename: middlewareFilename,
47
+ getContents: () => {
48
+ const imports = [`import * as federationModule from ${JSON.stringify(federationModule)};`, `import { createFedifyMiddleware } from ${JSON.stringify(resolver.resolve("../src/runtime/server/middleware.ts"))};`];
49
+ if (contextDataFactoryModule != null) imports.push(`import * as contextFactoryModule from ${JSON.stringify(contextDataFactoryModule)};`);
50
+ return [
51
+ ...imports,
52
+ "const federation = federationModule.default ?? federationModule.federation;",
53
+ buildContextFactoryResolver(contextDataFactoryModule ?? null),
54
+ "export default createFedifyMiddleware(federation, contextDataFactory);",
55
+ ""
56
+ ].join("\n");
57
+ }
58
+ });
59
+ addServerHandler({
60
+ route: "",
61
+ middleware: true,
62
+ handler: middlewareFilename
63
+ });
64
+ addServerPlugin(resolver.resolve("../src/runtime/server/plugin.ts"));
65
+ }
66
+ });
67
+ //#endregion
68
+ export { fedifyNuxtModule as default };
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@fedify/nuxt",
3
+ "version": "2.2.0-dev.0",
4
+ "description": "Integrate Fedify with Nuxt",
5
+ "keywords": [
6
+ "Fedify",
7
+ "ActivityPub",
8
+ "Fediverse",
9
+ "Nuxt"
10
+ ],
11
+ "author": {
12
+ "name": "ChanHaeng Lee",
13
+ "email": "2chanhaeng@gmail.com",
14
+ "url": "https://chomu.dev/"
15
+ },
16
+ "homepage": "https://fedify.dev/",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/fedify-dev/fedify.git",
20
+ "directory": "packages/nuxt"
21
+ },
22
+ "license": "MIT",
23
+ "bugs": {
24
+ "url": "https://github.com/fedify-dev/fedify/issues"
25
+ },
26
+ "funding": [
27
+ "https://opencollective.com/fedify",
28
+ "https://github.com/sponsors/dahlia"
29
+ ],
30
+ "type": "module",
31
+ "main": "./dist/mod.cjs",
32
+ "module": "./dist/mod.js",
33
+ "types": "./dist/mod.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "require": {
37
+ "types": "./dist/mod.d.cts",
38
+ "default": "./dist/mod.cjs"
39
+ },
40
+ "import": {
41
+ "types": "./dist/mod.d.ts",
42
+ "default": "./dist/mod.js"
43
+ }
44
+ },
45
+ "./package.json": "./package.json"
46
+ },
47
+ "files": [
48
+ "dist/",
49
+ "src/runtime/",
50
+ "package.json"
51
+ ],
52
+ "peerDependencies": {
53
+ "@nuxt/kit": "^4.4.0",
54
+ "h3": "^1.15.0",
55
+ "nuxt": "^4.4.2",
56
+ "@fedify/fedify": "^2.2.0"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^22.17.0",
60
+ "@nuxt/schema": "^4.4.0",
61
+ "tsdown": "^0.21.6",
62
+ "typescript": "^5.9.2",
63
+ "@fedify/fixture": "^2.0.0"
64
+ },
65
+ "scripts": {
66
+ "build:self": "tsdown",
67
+ "build": "pnpm --filter @fedify/nuxt... run build:self",
68
+ "prepublish": "pnpm build",
69
+ "test": "node --experimental-transform-types --test"
70
+ }
71
+ }
@@ -0,0 +1,4 @@
1
+ export const NOT_ACCEPTABLE_BODY = "Not acceptable";
2
+
3
+ export const DEFERRED_NOT_ACCEPTABLE_CONTEXT_KEY =
4
+ "__fedify_deferred_not_acceptable__";
@@ -0,0 +1,50 @@
1
+ import { test } from "@fedify/fixture";
2
+ import { equal, ok, strictEqual } from "node:assert/strict";
3
+ import { fetchWithFedify, resolveDeferredNotAcceptable } from "./logic.ts";
4
+
5
+ test(
6
+ "genuine handler 406 must be classified as handled",
7
+ async () => {
8
+ const handlerBody = JSON.stringify({ error: "custom reason" });
9
+ const handlerResponse = new Response(handlerBody, {
10
+ status: 406,
11
+ headers: { "Content-Type": "application/json" },
12
+ });
13
+
14
+ const result = await fetchWithFedify(
15
+ () => Promise.resolve(handlerResponse),
16
+ new Request("https://example.com/inbox"),
17
+ undefined,
18
+ );
19
+
20
+ equal(result.kind, "handled");
21
+ if (result.kind === "handled") {
22
+ strictEqual(result.response, handlerResponse);
23
+ }
24
+ },
25
+ );
26
+
27
+ test(
28
+ "framework intentional 404 on shared route must not become 406",
29
+ () => {
30
+ // When a route handler ran (routeHandled=true), preserve the
31
+ // framework's 404 instead of rewriting it to 406.
32
+ const result = resolveDeferredNotAcceptable(true, 404, true);
33
+ equal(
34
+ result,
35
+ undefined,
36
+ "should preserve framework 404 when route was handled",
37
+ );
38
+ },
39
+ );
40
+
41
+ test(
42
+ "deferred 406 fires when no route handler matched",
43
+ () => {
44
+ // When no route handler matched (routeHandled=false), the 404
45
+ // is a genuine route miss and should be converted to 406.
46
+ const result = resolveDeferredNotAcceptable(true, 404, false);
47
+ ok(result, "should return 406 when no route handled the request");
48
+ equal(result!.status, 406);
49
+ },
50
+ );
@@ -0,0 +1,60 @@
1
+ import type { FederationFetchOptions } from "@fedify/fedify/federation";
2
+ import { NOT_ACCEPTABLE_BODY } from "./lib.ts";
3
+
4
+ export { DEFERRED_NOT_ACCEPTABLE_CONTEXT_KEY } from "./lib.ts";
5
+
6
+ const DUMMY_NOT_FOUND_RESPONSE = new Response("", { status: 404 });
7
+
8
+ const createNotAcceptableResponse = () =>
9
+ new Response(NOT_ACCEPTABLE_BODY, {
10
+ status: 406,
11
+ headers: {
12
+ "Content-Type": "text/plain",
13
+ Vary: "Accept",
14
+ },
15
+ });
16
+
17
+ export type FetchResult =
18
+ | { kind: "handled"; response: Response }
19
+ | { kind: "not-found" }
20
+ | { kind: "not-acceptable" };
21
+
22
+ export async function fetchWithFedify<TContextData = unknown>(
23
+ fetcher: (
24
+ request: Request,
25
+ options: FederationFetchOptions<TContextData>,
26
+ ) => Promise<Response>,
27
+ request: Request,
28
+ contextData: TContextData,
29
+ ): Promise<FetchResult> {
30
+ let notAcceptableResponse: Response | null = null;
31
+
32
+ const response = await fetcher(request, {
33
+ contextData,
34
+ onNotFound: () => DUMMY_NOT_FOUND_RESPONSE,
35
+ onNotAcceptable: () => {
36
+ notAcceptableResponse = createNotAcceptableResponse();
37
+ return notAcceptableResponse;
38
+ },
39
+ });
40
+
41
+ if (response === DUMMY_NOT_FOUND_RESPONSE) {
42
+ return { kind: "not-found" };
43
+ }
44
+
45
+ if (notAcceptableResponse != null && response === notAcceptableResponse) {
46
+ return { kind: "not-acceptable" };
47
+ }
48
+
49
+ return { kind: "handled", response };
50
+ }
51
+
52
+ export function resolveDeferredNotAcceptable(
53
+ isDeferred: boolean,
54
+ frameworkStatus: number,
55
+ routeHandled?: boolean,
56
+ ): Response | undefined {
57
+ if (!isDeferred || frameworkStatus !== 404) return undefined;
58
+ if (routeHandled) return undefined;
59
+ return createNotAcceptableResponse();
60
+ }
@@ -0,0 +1,44 @@
1
+ import type { Federation } from "@fedify/fedify/federation";
2
+ import { defineEventHandler, type H3Event, toWebRequest } from "h3";
3
+ import { DEFERRED_NOT_ACCEPTABLE_CONTEXT_KEY } from "./lib.ts";
4
+ import { fetchWithFedify } from "./logic.ts";
5
+
6
+ function assertFederation(
7
+ federation: unknown,
8
+ ): asserts federation is Federation<unknown> {
9
+ const candidate = federation as { fetch?: unknown } | null | undefined;
10
+ if (candidate == null || typeof candidate.fetch !== "function") {
11
+ throw new TypeError(
12
+ "@fedify/nuxt: Federation instance is missing. " +
13
+ "Export default Federation (or named 'federation') from the configured module.",
14
+ );
15
+ }
16
+ }
17
+
18
+ export function createFedifyMiddleware(
19
+ federation: unknown,
20
+ contextDataFactory?: (event: H3Event, request: Request) => unknown,
21
+ ) {
22
+ assertFederation(federation);
23
+
24
+ return defineEventHandler(async (event) => {
25
+ const request = toWebRequest(event);
26
+ const contextData = typeof contextDataFactory === "function"
27
+ ? await contextDataFactory(event, request)
28
+ : undefined;
29
+
30
+ const result = await fetchWithFedify(
31
+ federation.fetch.bind(federation),
32
+ request,
33
+ contextData,
34
+ );
35
+
36
+ if (result.kind === "not-found") return;
37
+ if (result.kind === "not-acceptable") {
38
+ event.context[DEFERRED_NOT_ACCEPTABLE_CONTEXT_KEY] = true;
39
+ return;
40
+ }
41
+
42
+ return result.response;
43
+ });
44
+ }
@@ -0,0 +1,118 @@
1
+ import { test } from "@fedify/fixture";
2
+ import type { H3Event } from "h3";
3
+ import { equal, ok } from "node:assert/strict";
4
+ import {
5
+ DEFERRED_NOT_ACCEPTABLE_CONTEXT_KEY,
6
+ NOT_ACCEPTABLE_BODY,
7
+ } from "./lib.ts";
8
+ import fedifyPlugin from "./plugin.ts";
9
+
10
+ interface MockResponse {
11
+ statusCode: number;
12
+ statusMessage?: string;
13
+ setHeader(name: string, value: string): void;
14
+ getHeader(name: string): string | undefined;
15
+ }
16
+
17
+ function createMockResponse(statusCode: number): MockResponse {
18
+ const headers = new Map<string, string>();
19
+ return {
20
+ statusCode,
21
+ setHeader(name: string, value: string) {
22
+ headers.set(name.toLowerCase(), value);
23
+ },
24
+ getHeader(name: string) {
25
+ return headers.get(name.toLowerCase());
26
+ },
27
+ };
28
+ }
29
+
30
+ function registerBeforeResponseHook() {
31
+ let callback:
32
+ | ((event: H3Event, payload: { body?: unknown }) => void)
33
+ | undefined;
34
+
35
+ fedifyPlugin({
36
+ hooks: {
37
+ hook(name, registeredCallback) {
38
+ equal(name, "beforeResponse");
39
+ callback = registeredCallback;
40
+ },
41
+ },
42
+ });
43
+
44
+ ok(callback, "beforeResponse hook should be registered");
45
+ return callback;
46
+ }
47
+
48
+ test(
49
+ "plugin rewrites deferred 404 without matched route to 406",
50
+ () => {
51
+ const beforeResponse = registerBeforeResponseHook();
52
+ const response = createMockResponse(404);
53
+ const payload = { body: "original body" };
54
+ const event = {
55
+ context: {
56
+ [DEFERRED_NOT_ACCEPTABLE_CONTEXT_KEY]: true,
57
+ },
58
+ node: { res: response },
59
+ };
60
+
61
+ beforeResponse(event as unknown as H3Event, payload);
62
+
63
+ equal(response.statusCode, 406);
64
+ equal(response.getHeader("content-type"), "text/plain");
65
+ equal(response.getHeader("vary"), "Accept");
66
+ equal(payload.body, NOT_ACCEPTABLE_BODY);
67
+ equal(
68
+ event.context[DEFERRED_NOT_ACCEPTABLE_CONTEXT_KEY],
69
+ undefined,
70
+ );
71
+ },
72
+ );
73
+
74
+ test(
75
+ "plugin ignores 404 when deferred flag is absent",
76
+ () => {
77
+ const beforeResponse = registerBeforeResponseHook();
78
+ const response = createMockResponse(404);
79
+ const payload = { body: "regular 404" };
80
+ const event: H3Event = {
81
+ context: {},
82
+ node: { res: response },
83
+ } as unknown as H3Event;
84
+
85
+ beforeResponse(event, payload);
86
+
87
+ equal(response.statusCode, 404);
88
+ equal(response.getHeader("content-type"), undefined);
89
+ equal(payload.body, "regular 404");
90
+ },
91
+ );
92
+
93
+ test(
94
+ "plugin preserves shared-route 404 when Nuxt matched the route",
95
+ () => {
96
+ const beforeResponse = registerBeforeResponseHook();
97
+ const response = createMockResponse(404);
98
+ const payload = { body: "missing actor page" };
99
+ const event = {
100
+ context: {
101
+ [DEFERRED_NOT_ACCEPTABLE_CONTEXT_KEY]: true,
102
+ matchedRoute: {},
103
+ },
104
+ node: { res: response },
105
+ };
106
+
107
+ beforeResponse(event as unknown as H3Event, payload);
108
+
109
+ equal(response.statusCode, 404);
110
+ equal(response.getHeader("content-type"), undefined);
111
+ equal(response.getHeader("vary"), undefined);
112
+ equal(payload.body, "missing actor page");
113
+ equal(
114
+ event.context[DEFERRED_NOT_ACCEPTABLE_CONTEXT_KEY],
115
+ undefined,
116
+ );
117
+ },
118
+ );
@@ -0,0 +1,49 @@
1
+ import type { H3Event } from "h3";
2
+ import { getResponseStatus, setResponseHeader, setResponseStatus } from "h3";
3
+ import {
4
+ DEFERRED_NOT_ACCEPTABLE_CONTEXT_KEY,
5
+ NOT_ACCEPTABLE_BODY,
6
+ } from "./lib.ts";
7
+ import { resolveDeferredNotAcceptable } from "./logic.ts";
8
+
9
+ interface ResponsePayload {
10
+ body?: unknown;
11
+ }
12
+
13
+ interface MinimalNitroApp {
14
+ hooks: {
15
+ hook(
16
+ name: "beforeResponse",
17
+ callback: (event: H3Event, payload: ResponsePayload) => void,
18
+ ): void;
19
+ };
20
+ }
21
+
22
+ type NitroAppPlugin = (nitroApp: MinimalNitroApp) => void;
23
+
24
+ const fedifyPlugin: NitroAppPlugin = (nitroApp: MinimalNitroApp) => {
25
+ nitroApp.hooks.hook(
26
+ "beforeResponse",
27
+ (event: H3Event, payload: ResponsePayload) => {
28
+ const deferred =
29
+ event.context[DEFERRED_NOT_ACCEPTABLE_CONTEXT_KEY] === true;
30
+ delete event.context[DEFERRED_NOT_ACCEPTABLE_CONTEXT_KEY];
31
+
32
+ const negotiatedResponse = resolveDeferredNotAcceptable(
33
+ deferred,
34
+ getResponseStatus(event),
35
+ event.context.matchedRoute != null,
36
+ );
37
+ if (negotiatedResponse == null) return;
38
+
39
+ setResponseStatus(event, negotiatedResponse.status);
40
+ negotiatedResponse.headers.forEach((value: string, key: string) => {
41
+ setResponseHeader(event, key, value);
42
+ });
43
+
44
+ payload.body = NOT_ACCEPTABLE_BODY;
45
+ },
46
+ );
47
+ };
48
+
49
+ export default fedifyPlugin;