diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b3126e8a..4618c2794 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -545,6 +545,17 @@ The test results are stored in `/tmp/fedify-init//`(UNIX). > to configure the init command to use local workspace packages instead of > published versions. +#### Testing the examples + +If you want to test the example projects, you can run the following command +from the root: + +~~~~ bash +mise run test:examples +~~~~ + +This command runs the tests for all example projects. + ### Building the docs If you want to change the Fedify docs, you would like to preview the changes diff --git a/examples/cloudflare-workers/package.json b/examples/cloudflare-workers/package.json index 186b9f52b..26544c337 100644 --- a/examples/cloudflare-workers/package.json +++ b/examples/cloudflare-workers/package.json @@ -14,6 +14,7 @@ "wrangler": "^4.18.0" }, "dependencies": { + "@fedify/cfworkers": "workspace:^", "@fedify/fedify": "workspace:^", "@fedify/vocab": "workspace:^" } diff --git a/examples/custom-collections/main.ts b/examples/custom-collections/main.ts index fdedf9377..55cda8f81 100644 --- a/examples/custom-collections/main.ts +++ b/examples/custom-collections/main.ts @@ -1,4 +1,5 @@ -import { createFederation, MemoryKvStore, Note } from "@fedify/fedify"; +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { Note } from "@fedify/vocab"; // Mock data - in a real application, this would query your database const POSTS = [ diff --git a/examples/elysia/app.ts b/examples/elysia/app.ts index 7b36589cf..e3fe22e2e 100644 --- a/examples/elysia/app.ts +++ b/examples/elysia/app.ts @@ -11,7 +11,7 @@ federation.setNodeInfoDispatcher("/nodeinfo/2.1", (ctx) => { return { software: { name: "fedify-elysia", // Lowercase, digits, and hyphens only. - version: { major: 1, minor: 0, patch: 0 }, + version: "1.0.0", homepage: new URL(ctx.canonicalOrigin), }, protocols: ["activitypub"], diff --git a/examples/fastify/index.ts b/examples/fastify/index.ts index fdea0772b..7940b78b5 100644 --- a/examples/fastify/index.ts +++ b/examples/fastify/index.ts @@ -1,7 +1,7 @@ import { createFederation, MemoryKvStore } from "@fedify/fedify"; import { Person } from "@fedify/vocab"; import Fastify from "fastify"; -import fedifyPlugin from "../../packages/fastify/src/index.ts"; +import fedifyPlugin from "@fedify/fastify"; const fastify = Fastify({ logger: true }); diff --git a/examples/h3/index.ts b/examples/h3/index.ts index fd775ae2f..07b72a287 100644 --- a/examples/h3/index.ts +++ b/examples/h3/index.ts @@ -1,3 +1,4 @@ +import { integrateFederation, onError } from "@fedify/h3"; import { createApp, createRouter, @@ -5,8 +6,7 @@ import { setResponseHeader, toWebHandler, } from "h3"; -import { integrateFederation, onError } from "../src"; -import { federation } from "./federation"; +import { federation } from "./federation.ts"; export const app = createApp({ onError, diff --git a/examples/hono-sample/main.ts b/examples/hono-sample/main.ts index 629f19fd9..46a59b9cd 100644 --- a/examples/hono-sample/main.ts +++ b/examples/hono-sample/main.ts @@ -1,4 +1,4 @@ -import { createFederation, MemoryKvStore } from "@fedify/fedify/federation"; +import { createFederation, MemoryKvStore } from "@fedify/fedify"; import { federation } from "@fedify/hono"; import { Person } from "@fedify/vocab"; import { Hono } from "hono"; diff --git a/examples/next14-app-router/next.config.mjs b/examples/next14-app-router/next.config.mjs new file mode 100644 index 000000000..b108e1a2e --- /dev/null +++ b/examples/next14-app-router/next.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/examples/next14-app-router/next.config.ts b/examples/next14-app-router/next.config.ts deleted file mode 100644 index e9ffa3083..000000000 --- a/examples/next14-app-router/next.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - /* config options here */ -}; - -export default nextConfig; diff --git a/examples/next15-app-router/tsconfig.json b/examples/next15-app-router/tsconfig.json index 7203eb046..46cb83092 100644 --- a/examples/next15-app-router/tsconfig.json +++ b/examples/next15-app-router/tsconfig.json @@ -22,6 +22,12 @@ "~/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "app/.well-known/**/*.ts" + ], "exclude": ["node_modules"] } diff --git a/examples/sveltekit-sample/src/lib/federation.ts b/examples/sveltekit-sample/src/lib/federation.ts index 81e3c6899..d1dc07952 100644 --- a/examples/sveltekit-sample/src/lib/federation.ts +++ b/examples/sveltekit-sample/src/lib/federation.ts @@ -2,7 +2,6 @@ import { createFederation, generateCryptoKeyPair, MemoryKvStore, - PUBLIC_COLLECTION, } from "@fedify/fedify"; import { Accept, @@ -11,6 +10,7 @@ import { Image, Note, Person, + PUBLIC_COLLECTION, type Recipient, Undo, } from "@fedify/vocab"; diff --git a/examples/sveltekit-sample/src/lib/fetch.ts b/examples/sveltekit-sample/src/lib/fetch.ts index 645ea7509..c9818ea9e 100644 --- a/examples/sveltekit-sample/src/lib/fetch.ts +++ b/examples/sveltekit-sample/src/lib/fetch.ts @@ -1,4 +1,5 @@ -import { Note, type RequestContext } from "@fedify/fedify"; +import { type RequestContext } from "@fedify/fedify"; +import { Note } from "@fedify/vocab"; import type { Post, User } from "./types"; import { postStore } from "./store"; diff --git a/examples/sveltekit-sample/src/lib/store.ts b/examples/sveltekit-sample/src/lib/store.ts index 74dc319df..3e2f5b9e6 100644 --- a/examples/sveltekit-sample/src/lib/store.ts +++ b/examples/sveltekit-sample/src/lib/store.ts @@ -1,4 +1,4 @@ -import type { Note, Person } from "@fedify/fedify"; +import type { Note, Person } from "@fedify/vocab"; declare global { var keyPairsStore: Map>; diff --git a/examples/sveltekit-sample/src/routes/users/[identifier]/posts/+page.server.ts b/examples/sveltekit-sample/src/routes/users/[identifier]/posts/+page.server.ts index eef9958eb..e6abb54a3 100644 --- a/examples/sveltekit-sample/src/routes/users/[identifier]/posts/+page.server.ts +++ b/examples/sveltekit-sample/src/routes/users/[identifier]/posts/+page.server.ts @@ -1,7 +1,7 @@ import { default as federation, default as fedi } from "$lib/federation"; import { getPosts, getUser } from "$lib/fetch"; import { postStore } from "$lib/store"; -import { Create, Note } from "@fedify/fedify"; +import { Create, Note } from "@fedify/vocab"; import { error, redirect } from "@sveltejs/kit"; import type { Action, Actions, PageServerLoad } from "./$types"; diff --git a/examples/sveltekit-sample/vite.config.ts b/examples/sveltekit-sample/vite.config.ts index 86683174d..bf5fb74b5 100644 --- a/examples/sveltekit-sample/vite.config.ts +++ b/examples/sveltekit-sample/vite.config.ts @@ -1,7 +1,8 @@ -import tailwindcss from "@tailwindcss/vite"; import { sveltekit } from "@sveltejs/kit/vite"; +import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [tailwindcss(), sveltekit()], + server: { allowedHosts: true }, }); diff --git a/examples/test-examples/mod.ts b/examples/test-examples/mod.ts new file mode 100644 index 000000000..34d1fb7a2 --- /dev/null +++ b/examples/test-examples/mod.ts @@ -0,0 +1,846 @@ +/** + * Test runner for Fedify example projects. + * + * For server-based examples, starts the server, creates a public tunnel with + * `fedify tunnel` (via `deno task cli tunnel`), and verifies federation is + * working via `fedify lookup`. For script-based examples, runs them directly + * and checks the exit code. + * + * Usage (from repository root): + * deno run --allow-all examples/test-examples/mod.ts [options] [examples...] + * + * Options: + * --timeout MS Server readiness timeout in ms (default: 10000) + * --debug Enable debug-level logging via @logtape/logtape + * + * If example names are provided as positional arguments, only those examples + * are tested. Otherwise all examples are tested. + * + * Example: + * deno run --allow-all examples/test-examples/mod.ts express koa + * deno run --allow-all examples/test-examples/mod.ts --debug hono-sample + */ + +import $, { type CommandChild } from "@david/dax"; +import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; +import { fromFileUrl, join } from "@std/path"; + +// ─── Paths ──────────────────────────────────────────────────────────────────── + +const EXAMPLES_DIR = fromFileUrl(new URL("../", import.meta.url)); +const REPO_ROOT = fromFileUrl(new URL("../../", import.meta.url)); + +// ─── Logging ────────────────────────────────────────────────────────────────── +// +// We configure logtape before everything else so that log calls in helpers +// work even if they execute at module-initialization time. +// +// All test-runner logs live under the ["fedify", "examples"] category. +// Library-internal fedify logs are intentionally excluded so they don't flood +// the output. Pass --debug to lower the level from "info" to "debug". + +const debugMode = Deno.args.includes("--debug") || Deno.args.includes("-d"); + +await configure({ + sinks: { console: getConsoleSink() }, + filters: {}, + loggers: [ + { + category: ["fedify", "examples"], + lowestLevel: debugMode ? "debug" : "info", + sinks: ["console"], + filters: [], + }, + { + // Suppress logtape's own meta-logs unless something is wrong. + category: ["logtape", "meta"], + lowestLevel: "warning", + sinks: ["console"], + filters: [], + }, + ], +}); + +const logger = getLogger(["fedify", "examples", "test-runner"]); + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** An example that starts a long-running HTTP server. */ +interface ServerExample { + name: string; + /** Directory name inside examples/ */ + dir: string; + /** Optional build command to run before starting the server */ + buildCmd?: string[]; + /** Working directory for buildCmd (defaults to the example directory) */ + buildCwd?: string; + /** Command to start the server */ + startCmd: string[]; + /** Working directory for startCmd (defaults to the example directory) */ + startCwd?: string; + /** Port the server listens on */ + port: number; + /** ActivityPub actor username to look up via WebFinger */ + actor: string; + /** URL to poll until the server responds (any HTTP status counts as ready) */ + readyUrl: string; + /** Override the global ready-timeout (ms) for this example */ + readyTimeout?: number; + /** Extra environment variables injected into the server process */ + env?: Record; +} + +/** An example that is a standalone script (not a server). */ +interface ScriptExample { + name: string; + dir: string; + cmd: string[]; + description: string; +} + +/** + * A script example that accepts an ActivityPub handle as its last argument. + * Each handle in `handles` is tried in order; the test passes as soon as any + * one of them exits with code 0. + */ +interface MultiHandleExample { + name: string; + dir: string; + /** Command prefix — the handle is appended as the final argument. */ + cmd: string[]; + /** Handles to try, in order. Pass if any succeeds. */ + handles: string[]; + description: string; +} + +/** An example that is intentionally not tested automatically. */ +interface SkippedExample { + name: string; + reason: string; +} + +type TestResult = + | { name: string; status: "pass"; output: string } + | { name: string; status: "fail"; error: string; output: string } + | { name: string; status: "skip"; reason: string }; + +// ─── Example Registry ───────────────────────────────────────────────────────── + +const SERVER_EXAMPLES: ServerExample[] = [ + { + // Deno-native Hono server; actor path is /{identifier} but only "sample" + // is registered. + name: "hono-sample", + dir: "hono-sample", + startCmd: ["deno", "run", "--allow-all", "main.ts"], + port: 8000, + actor: "sample", + readyUrl: "http://localhost:8000/", + }, + { + // h3 server exported as a Fetch handler; run with `deno serve`. + // Executed from the repo root so Deno workspace imports resolve correctly. + name: "h3", + dir: "h3", + startCmd: [ + "deno", + "serve", + "--allow-all", + "--port", + "8000", + "examples/h3/index.ts", + ], + startCwd: REPO_ROOT, + port: 8000, + actor: "demo", + readyUrl: "http://localhost:8000/", + }, + { + // Express server; app.ts reads process.env.PORT (default 8000). + name: "express", + dir: "express", + startCmd: ["pnpm", "start"], + port: 8000, + actor: "demo", + readyUrl: "http://localhost:8000/", + }, + { + // Fastify server; actor path is /users/{identifier}. + name: "fastify", + dir: "fastify", + startCmd: ["pnpm", "start"], + port: 3000, + actor: "demo", + readyUrl: "http://localhost:3000/", + }, + { + // Koa server; actor path is /users/{identifier}. + name: "koa", + dir: "koa", + startCmd: ["pnpm", "start"], + port: 3000, + actor: "demo", + readyUrl: "http://localhost:3000/", + }, + { + // Elysia/Bun server; actor path is /{identifier} but only "sample" works. + name: "elysia", + dir: "elysia", + startCmd: ["bun", "run", "app.ts"], + port: 3000, + actor: "sample", + readyUrl: "http://localhost:3000/", + }, + { + // Next.js 14 app router; actor path is /users/{identifier}. + // Requires a build step before starting. + name: "next14-app-router", + dir: "next14-app-router", + buildCmd: ["pnpm", "build"], + startCmd: ["pnpm", "start"], + port: 3000, + actor: "demo", + readyUrl: "http://localhost:3000/", + readyTimeout: 30_000, + }, + { + // Next.js 15 app router; actor path is /users/{identifier}. + // Requires a build step before starting. + name: "next15-app-router", + dir: "next15-app-router", + buildCmd: ["pnpm", "build"], + startCmd: ["pnpm", "start"], + port: 3000, + actor: "demo", + readyUrl: "http://localhost:3000/", + readyTimeout: 30_000, + }, + { + // Next.js integration example using @fedify/next middleware. + // Requires a build step before starting. + name: "next-integration", + dir: "next-integration", + buildCmd: ["pnpm", "build"], + startCmd: ["pnpm", "start"], + port: 3000, + actor: "demo", + readyUrl: "http://localhost:3000/", + readyTimeout: 30_000, + }, + { + // SvelteKit sample using @fedify/sveltekit; actor path is /users/{identifier}. + // Built with vite; served with vite preview on port 4173. + name: "sveltekit-sample", + dir: "sveltekit-sample", + buildCmd: ["pnpm", "build"], + startCmd: ["pnpm", "preview"], + port: 4173, + actor: "demo", + readyUrl: "http://localhost:4173/", + }, +]; + +const SCRIPT_EXAMPLES: ScriptExample[] = [ + { + // Self-contained federation demo; creates a federation in-process and + // performs a single fetch. No server is started. + name: "custom-collections", + dir: "custom-collections", + cmd: ["deno", "run", "--allow-all", "main.ts"], + description: "Custom collection demonstration (in-process federation)", + }, +]; + +const MULTI_HANDLE_EXAMPLES: MultiHandleExample[] = [ + { + // Looks up a real fediverse actor; passes if any handle resolves. + name: "actor-lookup-cli", + dir: "actor-lookup-cli", + cmd: ["deno", "run", "--allow-all", "main.ts"], + handles: ["@hongminhee@hackers.pub", "@hongminhee@hollo.social"], + description: "Actor lookup CLI (real fediverse handle)", + }, +]; + +const SKIPPED_EXAMPLES: SkippedExample[] = [ + { + name: "cloudflare-workers", + reason: "Requires Cloudflare Workers environment and wrangler CLI", + }, + { + name: "fresh", + reason: + "No actor dispatcher configured; federation lookup cannot be verified", + }, +]; + +// ─── ANSI Colors ────────────────────────────────────────────────────────────── + +const c = { + bold: (s: string) => `\x1b[1m${s}\x1b[0m`, + dim: (s: string) => `\x1b[2m${s}\x1b[0m`, + green: (s: string) => `\x1b[32m${s}\x1b[0m`, + red: (s: string) => `\x1b[31m${s}\x1b[0m`, + yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, + cyan: (s: string) => `\x1b[36m${s}\x1b[0m`, +}; + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +/** + * Drains a ReadableStream into `chunks` in the background, logging each chunk + * at DEBUG level so that raw server/tunnel output is visible with --debug. + */ +function drainLogging( + stream: ReadableStream, + chunks: string[], + streamLogger: ReturnType, +): void { + (async () => { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + chunks.push(text); + const trimmed = text.trim(); + if (trimmed) streamLogger.debug("{output}", { output: trimmed }); + } + } catch { + // Stream may error when the process is killed. + } + })(); +} + +/** + * Polls `url` every 500 ms until the server responds with any HTTP status. + * Returns true if ready before `timeoutMs`, false otherwise. + */ +async function waitForServer(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(2_000) }); + await res.body?.cancel(); + return true; + } catch { + await new Promise((r) => setTimeout(r, 500)); + } + } + return false; +} + +// ─── Process Management ─────────────────────────────────────────────────────── + +/** + * Finds all processes **listening** on `port` (TCP LISTEN state only) via + * `lsof -ti : -sTCP:LISTEN` and sends SIGKILL to each of them. + * + * Using `-sTCP:LISTEN` is critical: without it `lsof` also returns processes + * that merely have an established _client_ connection to the port (e.g. the + * test-runner itself after calling `waitForServer`), which would cause the + * runner to kill itself. + */ +async function killPortUsers(port: number): Promise { + let pids: string[]; + try { + const text = await $`lsof -ti ${`:${port}`} -sTCP:LISTEN` + .stderr("null") + .noThrow() + .text(); + pids = text.trim().split("\n").filter(Boolean); + } catch { + return; // lsof unavailable or no match — nothing to do. + } + for (const pid of pids) { + try { + await $`kill -9 ${pid}`.quiet("both").noThrow(); + logger.debug("Force-killed PID {pid} on port {port}", { pid, port }); + } catch { + // Already gone. + } + } +} + +/** + * Sends SIGKILL to `child` immediately. A rejection handler is attached to + * the CommandChild promise (which extends Promise) so that the + * eventual rejection from the killed process does not surface as an unhandled + * promise rejection. We intentionally do **not** await the promise because + * dax keeps it pending until all piped streams are fully consumed, which may + * never happen once the process is forcibly killed. + */ +function forceKillChild(child: CommandChild): void { + child.catch(() => {}); + try { + child.kill("SIGKILL"); + } catch { + // Process already exited. + } +} + +// ─── Tunnel ─────────────────────────────────────────────────────────────────── + +/** + * Starts `fedify tunnel -s pinggy.io ` and waits up to `timeoutMs` + * for the tunnel URL to appear in its output. The tunnel process is kept + * alive and returned to the caller; it must be killed when no longer needed. + * + * Returns `null` if the URL was not found before the timeout. + */ +async function startTunnel( + port: number, + timeoutMs: number, +): Promise<{ child: CommandChild; url: string } | null> { + const tunnelLogger = getLogger(["fedify", "examples", "tunnel"]); + tunnelLogger.info("Opening localhost.run tunnel on port {port}", { port }); + + const child = $`deno task cli tunnel -s pinggy.io ${String(port)}` + .cwd(REPO_ROOT) + .stdout("piped") + .stderr("piped") + .noThrow() + .spawn(); + + // Accumulate text from both streams while logging each chunk at DEBUG. + const textChunks: string[] = []; + const decoder = new TextDecoder(); + + const readStream = (stream: ReadableStream) => { + (async () => { + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + textChunks.push(text); + const trimmed = text.trim(); + if (trimmed) tunnelLogger.debug("{output}", { output: trimmed }); + } + } catch { + // Stream may error when the process is killed. + } + })(); + }; + + readStream(child.stdout()); + readStream(child.stderr()); + + // Poll until we find an https URL in the accumulated output. + // The `message` template tag from @optique/run may wrap the URL in double + // quotes in non-TTY output, so we stop matching at whitespace or quotes. + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const match = textChunks.join("").match(/https:\/\/[^\s"']+/); + if (match) { + tunnelLogger.info("Tunnel established at {url}", { url: match[0] }); + return { child, url: match[0] }; + } + await new Promise((r) => setTimeout(r, 200)); + } + + tunnelLogger.error( + "Tunnel did not produce a URL within {timeout} ms", + { timeout: timeoutMs }, + ); + forceKillChild(child); + return null; +} + +// ─── Test Runners ───────────────────────────────────────────────────────────── + +async function testServerExample( + example: ServerExample, + defaultTimeoutMs: number, +): Promise { + const { + name, + dir, + buildCmd, + startCmd, + startCwd, + port, + actor, + readyUrl, + env, + } = example; + const exampleDir = join(EXAMPLES_DIR, dir); + const cwd = startCwd ?? exampleDir; + const timeoutMs = example.readyTimeout ?? defaultTimeoutMs; + const serverLogger = getLogger(["fedify", "examples", name]); + + // ── Build step (if configured) ──────────────────────────────────────────── + if (buildCmd != null) { + const buildCwd = example.buildCwd ?? exampleDir; + console.log(c.cyan(`\n[${name}]`) + " Building…"); + console.log(c.dim(` cmd : ${buildCmd.join(" ")}`)); + console.log(c.dim(` cwd : ${buildCwd}`)); + serverLogger.info("Building {name}", { name, cmd: buildCmd.join(" ") }); + + let buildResult; + try { + buildResult = await $`${buildCmd}` + .cwd(buildCwd) + .stdout("piped") + .stderr("piped") + .noThrow(); + } catch (e) { + const error = `Build command not found: ${buildCmd[0]}`; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: String(e) }; + } + + const buildOutput = buildResult.stdout + buildResult.stderr; + serverLogger.debug("Build output:\n{output}", { + output: buildOutput.trim(), + }); + + if (buildResult.code !== 0) { + const error = `Build failed with exit code ${buildResult.code}`; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: buildOutput }; + } + serverLogger.info("{name} build succeeded", { name }); + console.log(c.dim(` build succeeded`)); + } + + // Kill any process already using the port before we try to bind it. + serverLogger.debug("Clearing port {port} before start", { port }); + await killPortUsers(port); + + console.log(c.cyan(`\n[${name}]`) + " Starting server…"); + console.log(c.dim(` cmd : ${startCmd.join(" ")}`)); + console.log(c.dim(` cwd : ${cwd}`)); + if (env) console.log(c.dim(` env : ${JSON.stringify(env)}`)); + + serverLogger.info("Starting {name}", { name, cmd: startCmd.join(" "), cwd }); + + let serverChild: CommandChild; + try { + let cmd = $`${startCmd}`.cwd(cwd).stdout("piped").stderr("piped") + .noThrow(); + if (env) cmd = cmd.env(env); + serverChild = cmd.spawn(); + } catch (e) { + const error = `Command not found: ${startCmd[0]}`; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: String(e) }; + } + + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + drainLogging(serverChild.stdout(), stdoutChunks, serverLogger); + drainLogging(serverChild.stderr(), stderrChunks, serverLogger); + + const collectServerOutput = () => + stdoutChunks.join("") + stderrChunks.join(""); + + let tunnelChild: CommandChild | null = null; + + try { + console.log( + c.dim( + ` waiting for server at ${readyUrl} (timeout: ${timeoutMs} ms)…`, + ), + ); + serverLogger.debug("Polling {url}", { url: readyUrl }); + + const ready = await waitForServer(readyUrl, timeoutMs); + if (!ready) { + const error = `Server did not become ready within ${timeoutMs} ms`; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: collectServerOutput() }; + } + + serverLogger.info("{name} is ready; opening tunnel on port {port}", { + name, + port, + }); + console.log(c.dim(` server ready — opening tunnel on port ${port}…`)); + + const tunnel = await startTunnel(port, 30_000); + if (tunnel == null) { + const error = "fedify tunnel did not produce a URL within 30s"; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: collectServerOutput() }; + } + + tunnelChild = tunnel.child; + const tunnelHostname = new URL(tunnel.url).hostname; + const handle = `@${actor}@${tunnelHostname}`; + + console.log(c.dim(` tunnel URL : ${tunnel.url}`)); + console.log(c.dim(` running : fedify lookup ${handle} -d`)); + serverLogger.info("Running fedify lookup {handle}", { handle }); + + const lookup = await $`deno task cli lookup ${handle} -d` + .cwd(REPO_ROOT) + .stdout("piped") + .stderr("piped") + .noThrow(); + + const lookupOutput = lookup.stdout + lookup.stderr; + serverLogger.debug("Lookup output:\n{output}", { + output: lookupOutput.trim(), + }); + + if (lookup.code === 0) { + serverLogger.info("{name} passed", { name }); + return { name, status: "pass", output: lookupOutput }; + } + const error = `fedify lookup exited with code ${lookup.code}`; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: lookupOutput }; + } finally { + // Force-kill tunnel first (it holds a connection to the server). + if (tunnelChild != null) { + serverLogger.debug("Force-killing tunnel process"); + forceKillChild(tunnelChild); + } + serverLogger.debug("Force-killing server process"); + forceKillChild(serverChild); + + // Kill any lingering processes still bound to the port. + serverLogger.debug("Killing remaining processes on port {port}", { port }); + await killPortUsers(port); + } +} + +async function testScriptExample(example: ScriptExample): Promise { + const { name, dir, cmd, description } = example; + const cwd = join(EXAMPLES_DIR, dir); + const scriptLogger = getLogger(["fedify", "examples", name]); + + console.log(c.cyan(`\n[${name}]`) + ` Running: ${c.dim(description)}`); + console.log(c.dim(` cmd : ${cmd.join(" ")}`)); + scriptLogger.info("Running script {name}: {cmd}", { + name, + cmd: cmd.join(" "), + }); + + const result = await $`${cmd}` + .cwd(cwd) + .stdout("piped") + .stderr("piped") + .noThrow(); + + const output = result.stdout + result.stderr; + scriptLogger.debug("Script output:\n{output}", { output: output.trim() }); + + if (result.code === 0) { + scriptLogger.info("{name} passed", { name }); + return { name, status: "pass", output }; + } + const error = `Script exited with code ${result.code}`; + scriptLogger.error("{error}", { error }); + return { name, status: "fail", error, output }; +} + +async function testMultiHandleExample( + example: MultiHandleExample, +): Promise { + const { name, dir, cmd, handles, description } = example; + const cwd = join(EXAMPLES_DIR, dir); + const scriptLogger = getLogger(["fedify", "examples", name]); + + console.log(c.cyan(`\n[${name}]`) + ` Running: ${c.dim(description)}`); + scriptLogger.info("Testing {name} with {count} handle(s)", { + name, + count: handles.length, + }); + + let lastOutput = ""; + for (const handle of handles) { + const fullCmd = [...cmd, handle]; + console.log(c.dim(` cmd : ${fullCmd.join(" ")}`)); + scriptLogger.info("Trying handle {handle}", { handle }); + + const result = await $`${fullCmd}` + .cwd(cwd) + .stdout("piped") + .stderr("piped") + .noThrow(); + + const output = result.stdout + result.stderr; + scriptLogger.debug("Output:\n{output}", { output: output.trim() }); + lastOutput = output; + + if (result.code === 0) { + scriptLogger.info("{name} passed with handle {handle}", { name, handle }); + return { name, status: "pass", output }; + } + scriptLogger.warn("Handle {handle} failed with code {code}", { + handle, + code: result.code, + }); + } + + const error = `All ${handles.length} handle(s) failed`; + scriptLogger.error("{error}", { error }); + return { name, status: "fail", error, output: lastOutput }; +} + +// ─── Result Printer ─────────────────────────────────────────────────────────── + +function printInlineResult(result: TestResult): void { + if (result.status === "pass") { + console.log(c.green(` ✓ ${result.name}`)); + } else if (result.status === "fail") { + console.log(c.red(` ✗ ${result.name}: ${result.error}`)); + } else { + console.log(c.yellow(` ⊘ ${result.name}: ${result.reason}`)); + } +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + // ── Parse CLI arguments ────────────────────────────────────────────────── + let defaultTimeoutMs = 10_000; + const filterNames = new Set(); + + for (let i = 0; i < Deno.args.length; i++) { + const arg = Deno.args[i]; + if (arg === "--timeout" && i + 1 < Deno.args.length) { + defaultTimeoutMs = Number(Deno.args[++i]); + } else if (arg.startsWith("--timeout=")) { + defaultTimeoutMs = Number(arg.slice("--timeout=".length)); + } else if (arg === "--debug" || arg === "-d") { + // already handled above at module level + } else if (!arg.startsWith("--")) { + filterNames.add(arg); + } + } + + const shouldRun = (name: string) => + filterNames.size === 0 || filterNames.has(name); + + // ── Banner ─────────────────────────────────────────────────────────────── + console.log(c.bold("Fedify Example Test Runner")); + console.log(`Tunnel service: localhost.run (via \`fedify tunnel\`)`); + console.log(`Ready timeout : ${defaultTimeoutMs} ms`); + if (debugMode) console.log(`Debug logging : enabled`); + if (filterNames.size > 0) { + console.log(`Running only : ${[...filterNames].join(", ")}`); + } + console.log(); + + logger.info("Test runner started", { debugMode, defaultTimeoutMs }); + + // ── Run examples ───────────────────────────────────────────────────────── + const results: TestResult[] = []; + const usedPorts = new Set(); + + for (const example of SERVER_EXAMPLES) { + if (!shouldRun(example.name)) continue; + usedPorts.add(example.port); + const result = await testServerExample(example, defaultTimeoutMs); + results.push(result); + printInlineResult(result); + } + + for (const example of SCRIPT_EXAMPLES) { + if (!shouldRun(example.name)) continue; + const result = await testScriptExample(example); + results.push(result); + printInlineResult(result); + } + + for (const example of MULTI_HANDLE_EXAMPLES) { + if (!shouldRun(example.name)) continue; + const result = await testMultiHandleExample(example); + results.push(result); + printInlineResult(result); + } + + for (const example of SKIPPED_EXAMPLES) { + if (!shouldRun(example.name)) continue; + const result: TestResult = { + name: example.name, + status: "skip", + reason: example.reason, + }; + results.push(result); + printInlineResult(result); + } + + // ── Final port cleanup ─────────────────────────────────────────────────── + // After all tests are done, ensure no processes remain on the used ports. + if (usedPorts.size > 0) { + logger.debug("Final cleanup: killing remaining processes on used ports"); + for (const port of usedPorts) { + await killPortUsers(port); + } + } + + // ── Unregistered examples ──────────────────────────────────────────────── + // Collect every example name that appears in any registry. + const registeredNames = new Set([ + ...SERVER_EXAMPLES.map((e) => e.name), + ...SCRIPT_EXAMPLES.map((e) => e.name), + ...MULTI_HANDLE_EXAMPLES.map((e) => e.name), + ...SKIPPED_EXAMPLES.map((e) => e.name), + ]); + + // Scan the examples/ directory for sub-directories that are not registered. + const unregistered: string[] = []; + for await (const entry of Deno.readDir(EXAMPLES_DIR)) { + if (!entry.isDirectory) continue; + // The test-examples directory is the test runner itself — skip it. + if (entry.name === "test-examples") continue; + if (!registeredNames.has(entry.name)) { + unregistered.push(entry.name); + } + } + + // ── Summary ────────────────────────────────────────────────────────────── + const passed = results.filter((r) => r.status === "pass"); + const failed = results.filter((r) => r.status === "fail"); + const skipped = results.filter((r) => r.status === "skip"); + + console.log(c.bold("\n─────────────────── Summary ───────────────────")); + console.log(c.green(` ✓ Passed : ${passed.length}`)); + console.log(c.red(` ✗ Failed : ${failed.length}`)); + console.log(c.yellow(` ⊘ Skipped: ${skipped.length}`)); + + if (unregistered.length > 0) { + unregistered.sort(); + console.log( + c.yellow( + `\n ⚠ ${unregistered.length} example(s) not registered in test runner:`, + ), + ); + for (const name of unregistered) { + console.log(c.yellow(` • ${name}`)); + } + logger.warn( + "{count} unregistered example(s): {names}", + { count: unregistered.length, names: unregistered.join(", ") }, + ); + } + + logger.info("Test run complete", { + passed: passed.length, + failed: failed.length, + skipped: skipped.length, + }); + + if (failed.length > 0) { + console.log(c.bold(c.red("\nFailed examples:"))); + for (const r of failed) { + if (r.status !== "fail") continue; + console.log(c.red(` • ${r.name}: ${r.error}`)); + // Show the last 20 lines of combined output as a quick hint. + const preview = r.output.trim().split("\n").slice(-20).join("\n"); + if (preview) console.log(c.dim(preview)); + } + Deno.exit(1); + } + + Deno.exit(0); +} + +await main(); diff --git a/mise.toml b/mise.toml index 2091c4700..9995d6011 100644 --- a/mise.toml +++ b/mise.toml @@ -169,6 +169,10 @@ for PACKAGE in ${usage_packages}; do done ''' +[tasks."test:examples"] +description = "Run tests for all example projects" +run = "deno run -A examples/test-examples/mod.ts" + # Snapshot updates # Note: vocab uses @std/testing/snapshot which only works on Deno # vocab-tools has separate snapshots for each runtime diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index 7c9596f79..1bc759d67 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -70,9 +70,7 @@ export function integrateFederation( } function fromERequest(req: ERequest): Request { - const url = `${req.protocol}://${ - req.header("Host") ?? req.hostname - }${req.url}`; + const url = `${req.protocol}://${req.host ?? req.header("Host")}${req.url}`; const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (Array.isArray(value)) { diff --git a/packages/nestjs/src/fedify.middleware.ts b/packages/nestjs/src/fedify.middleware.ts index a0e0e53d4..606f232b3 100644 --- a/packages/nestjs/src/fedify.middleware.ts +++ b/packages/nestjs/src/fedify.middleware.ts @@ -1,10 +1,10 @@ +import type { Federation } from "@fedify/fedify"; import { Injectable, type NestMiddleware, type Type } from "@nestjs/common"; import type { NextFunction, Request as ERequest, Response as EResponse, } from "express"; -import type { Federation } from "@fedify/fedify"; import { Buffer } from "node:buffer"; export type ContextDataFactory = ( @@ -68,9 +68,7 @@ export function integrateFederation( } function fromERequest(req: ERequest): Request { - const url = `${req.protocol}://${ - req.header("Host") ?? req.hostname - }${req.url}`; + const url = `${req.protocol}://${req.host ?? req.header("Host")}${req.url}`; const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (Array.isArray(value)) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aad4f3995..a92e27611 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -346,6 +346,9 @@ importers: examples/cloudflare-workers: dependencies: + '@fedify/cfworkers': + specifier: workspace:^ + version: link:../../packages/cfworkers '@fedify/fedify': specifier: workspace:^ version: link:../../packages/fedify @@ -13890,8 +13893,8 @@ snapshots: '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3) eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-react: 7.37.5(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.32.0(jiti@2.5.1)) @@ -13910,8 +13913,8 @@ snapshots: '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2) eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-react: 7.37.5(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.32.0(jiti@2.5.1)) @@ -13934,22 +13937,22 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 - eslint: 9.32.0(jiti@2.5.1) + eslint: 8.57.1 get-tsconfig: 4.10.1 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 @@ -13960,22 +13963,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) - transitivePeerDependencies: - - supports-color - - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.1 - eslint: 8.57.1 - get-tsconfig: 4.10.1 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color @@ -13990,25 +13978,25 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2) eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3) eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color @@ -14041,7 +14029,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -14052,7 +14040,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -14070,7 +14058,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -14081,7 +14069,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3