diff --git a/.agents/skills/create-integration-package/SKILL.md b/.agents/skills/create-integration-package/SKILL.md new file mode 100644 index 000000000..75905572c --- /dev/null +++ b/.agents/skills/create-integration-package/SKILL.md @@ -0,0 +1,331 @@ +--- +name: create-integration-package +description: >- + This skill is utilized when creating a web framework integration package. + After examining the given framework, a feasibility assessment is conducted + regarding the creation of an integration package. + If implementation is feasible, the package is generated; + if it is not possible, the rationale is provided to the user. +argument-hint: "Provide the name of the web framework you want to integrate with." +--- + + + +Adding an integration package to a web framework +================================================ + +Follow these steps in order to implement the integration package. + +1. Research the web framework +2. Implement the package +3. Add to `@fedify/init` +4. Test with `mise test:init` +5. Add an example +6. Lint, format, and final checks + + +Research the web framework +-------------------------- + +Research the web framework for which the integration package will be +implemented. Fedify operates as middleware via +[`Federation.fetch`](../../../packages/fedify/src/federation/federation.ts). +The critical question is whether the given framework can act as a server +framework and supports adding middleware. Search for and investigate +whether the relevant functionality is available. Assess feasibility based +on the research. If research indicates implementation is not possible, +explain the reasons in detail to the user and stop. If feasible, proceed +to create the package. Even during package creation, it may turn out to be +infeasible. In that case as well, explain the reasons in detail to the +user and stop. + + +Implement the package +--------------------- + +**Prioritize usability above all else.** The most important goal is that +the package integrates smoothly with the framework so users do not +experience friction when connecting it. + +Create the package directory inside the `packages/` directory. For example, if +the framework is named “framework”, create the directory `packages/framework/`. + +Unless there are significant hurdles, please set up the package to publish +on both JSR and NPM. + +Copy the template files from into the directory you created. Then, +implement the package according to the framework. Since the comments in the +template are instructions for the developer to follow, please remove them once +the implementation is complete. + +Add additional definitions as appropriate based on context. Aside from the +main integration function and the `ContextDataFactory` type, keep module +exports to a minimum to avoid confusing users. + +### Request flow + +When a request arrives, the integration middleware calls +`federation.fetch()`. If Fedify has a route for the path and the client's +`Accept` header includes an ActivityPub media type such as +`application/activity+json`, Fedify generates and returns the JSON-LD +response directly. Framework-side routing does not execute. + +### Request conversion + +Some frameworks define and use their own `Request` type internally instead +of the Web API `Request`. If the target framework does so, write +conversion functions within the integration package to translate between +the Web API `Request` and the framework's native `Request`. + +### 406 not acceptable + +The final failure 406 response uses this form: + +~~~~ typescript +new Response("Not acceptable", { + status: 406, + headers: { + "Content-Type": "text/plain", + Vary: "Accept", + }, +}); +~~~~ + +### Function naming conventions + +A consistent naming convention for the main function has not yet been +established, but there is an [open naming convention issue]. If the issue +has been resolved by the time this skill is executed, update this section. +As a temporary convention, respect conventions of the framework : name it +`fedifyMiddleware` if the official documentation calls it as middleware, or +`fedifyHandler` if it's called a handler. + +[open naming convention issue]: https://github.com/fedify-dev/fedify/issues/657 + +### Non-source files + +#### README.md + +The package README.md must include the following: + + - Package description + - Supported framework versions, if only specific versions are supported + - Installation instructions + - Usage instructions (with example code) + +#### `deno.json` + +A *deno.json* is required to publish to JSR. + +#### `package.json` + +A *package.json* is required to publish to npm. + +#### `tsdown.config.ts` + +A *tsdown.config.ts* is required for the build in Node.js and Bun +environments. + +### Other updates + +Refer to the “Adding a new package” section in *CONTRIBUTING.md* and +perform the required updates. Record the package addition in *CHANGES.md*. + +### Tests + +You can test the integration using `mise test:init`, which will be explained +later, but write unit tests as well if possible. Import the `test` function +from `@fedify/fixture` to write runtime-agnostic tests that work across +Deno, Node.js, and Bun. Name test files with the `*.test.ts` convention +(e.g., `src/mod.test.ts`). + +> **Warning**: `@fedify/fixture` is a **private** workspace package and +> must never be imported from published (non-test) source files. Only +> import it in `*.test.ts` files. + +### Implementation checklist + +1. Create the *packages/framework/* directory +2. Write *src/mod.ts*: + - Export the main integration middleware/handler function + - Implement `federation.fetch()` invocation with + `onNotFound`/`onNotAcceptable` + - Export the `ContextDataFactory` type + - Write conversion functions if the framework does not natively support + Web API `Request`/`Response` +3. Write *README.md* +4. Write *deno.json* (if publishing to JSR is intended) +5. Write *package.json* (if publishing to npm is intended) +6. Write *tsdown.config.ts* (if Node.js and Bun are supported) +7. Write tests if possible +8. Perform remaining updates per the “Adding a new package” section in + *CONTRIBUTING.md* +9. Record changes in *CHANGES.md* + + +Add to `@fedify/init` +--------------------- + +Add the new package to the `@fedify/init` package so users can select the +new framework via the `fedify init` command. Follow these steps. + +Steps may require code modifications not explicitly listed. For example, +if the new package needs specific configuration, utility functions in +`packages/init/src/webframeworks/utils.ts` may need updating. Make +modifications consistent with the existing code style and context. + +### Write the `WebFrameworkDescription` object + +Create a `packages/init/src/webframeworks/framework.ts` file and write the +`WebFrameworkDescription` object, referring to . Check +the specifications in the comments in `packages/init/src/types.ts` for +details. + +### Add to the `WEB_FRAMEWORK` array + +Add the new framework name to the end of the `WEB_FRAMEWORK` array in +`packages/init/src/const.ts`. + +~~~~ typescript +export const WEB_FRAMEWORK = [ + // ... other frameworks + "framework", // Fill with the framework name +]; +~~~~ + +### Add to the `webFrameworks` object + +Add the new `WebFrameworkDescription` object in alphabetical order to the +`webFrameworks` object in `packages/init/src/webframeworks/mod.ts`. + +~~~~ typescript +// packages/init/src/webframeworks/mod.ts + +// ... other imports +import framework from "./framework.ts"; // Fill with the framework name + +const webFrameworks: Record = { + // ... other frameworks + framework, // Fill with the framework name +}; +~~~~ + +### Add templates in `packages/init/src/templates/framework/` + +If additional files need to be generated, add template files under the +`packages/init/src/templates/framework/` directory. Template files must +end with the `.tpl` extension appended to their base name. Then, in +`packages/init/src/webframeworks/framework.ts`, load the templates using +the `readTemplate` function defined in `packages/init/src/lib.ts` and add +them to the `WebFrameworkDescription.init().files` object. + + +Test with `mise test:init` +-------------------------- + +Run `mise test:init` to verify that the new package is generated and runs +correctly. If a test fails, the output and error file paths are printed; +read them to diagnose the issue. + +Running `mise test:init` without arguments tests all option combinations +and can take a very long time. Use appropriate options to narrow the test +scope. + +Immediately remove test paths after completing the tests and analyzing any +resulting errors. + +At a minimum, test the following three combinations. + + - `mise test:init -w framework -m in-process -k in-memory --no-dry-run`: + Tests the new framework with the in-memory KV store and in-process message + queue, which are the most basic options. This combination verify that the + newly created package can be used without issues by minimizing dependencies + on other environments. + - `mise test:init -w framework`: Tests all package manager, KV store, + and message queue combinations with the framework selected. If a + required database is not installed or running, this combinations are + useless. Therefore, if the test output indicates that the databases are + not running, don't use this combination ever again for the session. + Instead, use the previous one or the next one. + - `mise test:init -m in-process -k in-memory --no-dry-run`: Fixes the + KV store and message queue and tests all web framework and package + manager combinations. This test is mandatory if you modified logic + beyond just writing the `WebFrameworkDescription` object. + +For details on options, run `mise test:init --help`. + +Some frameworks or combinations may be untestable. Analyze the test +results; if there are impossible combinations, identify the reason and add +the combination and reason as a key-value pair to the +`BANNED_LOOKUP_REASONS` object in +`packages/init/src/test/lookup.ts`. + + +Add an example +-------------- + +Create an `examples/framework/` app and write an example for the new +package. If Deno is supported, add a *deno.json* based on ; +if Node.js is supported, add *package.json* based on +and *tsdown.config.ts*. Depending on the supported environments, +add the example path to the `workspace` field in +the root *deno.json* and to the `packages` field in +*pnpm-workspace.yaml*. + +If the framework is backend-only and needs a frontend framework, and there +is no natural pairing like solidstart-solid, use Hono. + +Base the example on the files under the path. + describes the example's architecture. + describes the example's design. Both documents are +references for writing the example and are not needed in the actual +generated example app — do not create these two files. Copy the remaining +files as-is and modify as needed. + +If the framework does not have a prescribed entry point, use `src/main.ts` +as the application entry point. Define and export the framework app in +`src/app.ts`, then import and run it from the entry file. Register the +Fedify middleware in `src/app.ts`. Import `src/logging.ts` in the entry +file to initialize `@logtape/logtape`. When logging is needed, use the +`getLogger` function from `@logtape/logtape` to create a logger. + +### Test the example with `mise test:examples` + +Register the new example in `examples/test-examples/mod.ts`. Read the +comments above the example registry arrays in that file to determine +which array is appropriate and what fields are required. Follow the +patterns of existing entries. + +Before running the tests, ensure that the tunneling service is usable. +The tests use the tunneling service `pinggy.io` to make the example app +accessible to the test suite. If the tunneling service is not usable, +the tests may never finish or may fail due to a connection error. + +While developing the example, run only the new example to iterate +quickly: + +~~~~ bash +mise test:examples framework +~~~~ + +where `framework` is the `name` field of the registered entry. Pass +`--debug` for verbose output if the test fails. + +After the example is complete, run the full suite once to confirm nothing +is broken: + +~~~~ bash +mise test:examples +~~~~ + + +Lint, format, and final checks +------------------------------ + +Add keywords related to the framework in `.hongdown.toml` and `cspell.json` in +root path. Especially, the package name `@fedify/framework` should be added to +the `.hongdown.toml`. + +After implementation, run `mise run fmt && mise check`. +If there are lint or format errors, fix them and run the command again until +there are no errors. diff --git a/.agents/skills/create-integration-package/example/ARCHITECTURE.md b/.agents/skills/create-integration-package/example/ARCHITECTURE.md new file mode 100644 index 000000000..92a4d646b --- /dev/null +++ b/.agents/skills/create-integration-package/example/ARCHITECTURE.md @@ -0,0 +1,191 @@ + + +Fedify example architecture +=========================== + +This document defines the shared architecture for Fedify example applications. +Every example should follow these conventions regardless of the web framework +used, so that learners can compare examples and transfer knowledge between them. + + +Middleware integration +---------------------- + +Every Fedify framework adapter exposes a middleware or hook function that +intercepts incoming requests. Register this middleware at the top level of +the server so that it runs before any application routes. + +The middleware inspects the `Accept` and `Content-Type` headers. Requests +carrying ActivityPub media types (`application/activity+json`, +`application/ld+json`, etc.) or targeting well-known federation endpoints +are forwarded to the `Federation` instance. All other requests fall through +to the application's own routes. + +The specific API differs, but the role is identical: delegate federation +traffic to Fedify, let everything else pass through. + + +Reverse proxy support +--------------------- + +If needed, wrap the middleware (or the request handler it receives) with +`getXForwardedRequest` from the `x-forwarded-fetch` package. This rewrites +the request URL to respect `X-Forwarded-Host` and related headers, which is +required when the server runs behind a tunneling tool or reverse proxy during +local development. Apply this wrapping at the same level as the Fedify +middleware registration, before any routing logic executes. + + +Routing +------- + +### `GET /` + +The main page. Contains the following sections: + +**Search** + +A text input for searching fediverse accounts by handle. The client +debounces input with a 300ms delay, then sends a `GET` request with the +handle as a URL query parameter (e.g. `/?q=@user@example.com`). The server +resolves the handle using Fedify's `lookupObject` and returns the result. +The result shows: profile image, display name, handle, and a follow button. +If the local actor already follows the target, show an unfollow button +instead. + +**User info** + +Displays the local actor's profile. Because this is a demo there is exactly +one actor, `@demo`. + + - Profile image: `/demo-profile.png` + - Name: `"Fedify Demo"` + - Handle: `@demo` + - Summary: `"This is a Fedify Demo account."` + +**Following** + +Lists accounts the local actor follows. Shows the total count and, for +each account: profile image, display name, handle, and an unfollow button. + +**Followers** + +Lists accounts that follow the local actor. Shows the total count and, for +each account: profile image, display name, and handle. + +The following and followers sections update in real time via SSE (see below). + +**Compose** + +A text area and a submit button for writing a new post. On submission the +server creates a `Note`, stores it in `postStore`, wraps it in a `Create` +activity, and sends it to followers. If sending fails, the post is removed +from the store. + +**Posts** + +Lists all posts by the local actor in reverse chronological order. Each +entry shows the post content, published timestamp, and a link to the +single post detail page (`/users/{identifier}/posts/{id}`). + +### `GET /users/{identifier}` + +Actor profile page. Shares its path with the Fedify actor dispatcher. +When a federation peer requests this URL with an ActivityPub media type, the +middleware handles it. Otherwise the request falls through to this route, +which renders an HTML page showing: + + - Profile image + - Name + - Handle + - Summary + - Following count + - Followers count + +### `GET /users/{identifier}/posts/{id}` + +Single post detail page. Shares its path with the Fedify `Note` object +dispatcher. Same content-negotiation fallback as the actor profile: the +middleware serves ActivityPub JSON to federation peers, and this route +renders HTML for browsers. Shows: + + - Author profile (same layout as the actor profile page) + - Post content + - Published timestamp + +### `POST /post` + +Accepts post content from the compose form, creates a `Note`, stores it in +`postStore`, wraps it in a `Create` activity, and sends it to followers. +If sending fails, the post is removed from the store. Redirects back to +`/` on completion. + +### `POST /follow` + +Accepts a target actor URI, sends a `Follow` activity from the local actor, +and stores the relationship locally. + +### `POST /unfollow` + +Accepts a target actor URI, sends an `Undo(Follow)` activity, and removes +the relationship locally. + +### `GET /events` + +SSE endpoint. See the SSE section below. + + +Server-sent events +------------------ + +The `/events` endpoint keeps an open SSE connection to the client. +When the following or followers list changes (a follow is accepted, a +remote follow arrives, an unfollow occurs, etc.), the server pushes an +event so the page can update without a full reload. + +The server maintains a set of active SSE connections. Whenever the +follower or following store is mutated — inside inbox listeners or after a +local follow/unfollow request — it broadcasts an event to every open +connection. + +The client listens on an `EventSource` and replaces the relevant DOM +section with the received data. + + +Server-side data access +----------------------- + +Use Fedify's `RequestContext` to bridge between the framework routing layer +and the federation layer. Obtain a context by calling +`federation.createContext(request, contextData)` inside a route handler. +Through this context, routes can look up actors, resolve object URIs, and +invoke `sendActivity` without coupling to Fedify internals. + +Avoid accessing the data stores directly from route handlers when a +`RequestContext` method exists for the same purpose. This keeps the +routing layer thin and ensures that Fedify's internal bookkeeping (key +resolution, URI canonicalization, etc.) is applied consistently. + + +Federation +---------- + +Use `src/federation.ts`. + + +Storing +------- + +Use `src/store.ts` and the provided in-memory stores. + + +View rendering +-------------- + +See `DESIGN.md`. + + +Logging +------- + +Use `@logtape/logtape` and `src/logging.ts`. diff --git a/.agents/skills/create-integration-package/example/DESIGN.md b/.agents/skills/create-integration-package/example/DESIGN.md new file mode 100644 index 000000000..b03e3c881 --- /dev/null +++ b/.agents/skills/create-integration-package/example/DESIGN.md @@ -0,0 +1,295 @@ +Fedify example design system +============================ + +Visual theme & atmosphere +------------------------- + +Clean, functional, and developer-friendly. The aesthetic is minimal and +modern: a neutral canvas with a single bold gradient accent for profile +sections. The overall feel is “documentation site meets social app” — +sparse enough to read comfortably, colorful enough to feel alive. + +Dark and light themes are mandatory. The system detects +`prefers-color-scheme` and switches automatically; there is no manual +toggle. + +**Key Characteristics:** + + - Neutral canvas that inverts cleanly between light and dark + - Single gradient accent (purple) reserved for the profile header and + primary actions + - Card-based content layout with subtle shadows + - Monospace typesetting for handles and federation addresses + - No framework-specific branding in UI chrome — only the demo profile + and Fedify logo + + +Color palette & roles +--------------------- + +### Surface & background + +Two CSS custom properties control the entire theme inversion. +`--background` is `#ffffff` in light mode and `#0a0a0a` in dark mode. +`--foreground` is `#171717` in light mode and `#ededed` in dark mode. +All other surface colors derive from these two tokens through `rgba()` +or `color-mix()`. + +### Accent & brand + +Link text and input focus rings use `#3b82f6`. The focus ring shadow is +`rgba(59, 130, 246, 0.3)`. The profile gradient is +`linear-gradient(135deg, #667eea, #764ba2)` and applies exclusively to +the profile header background and the primary action button. + +### Neutral & semantic + +Handle badges and follower items use `#f3f4f6` background with `#000` +text. Card borders use `rgba(0, 0, 0, 0.05)` for subtle dividers and +`rgba(0, 0, 0, 0.1)` for post cards and forms. Textarea borders are +`rgba(0, 0, 0, 0.2)`. Post avatar rings are `#e5e7eb`. Text on +gradient backgrounds is always `white`. + +### Shadow system + +Four elevation levels. Elevation 1 (`0 2px 8px rgba(0,0,0,0.05)`) for +post cards and forms. Elevation 2 (`0 4px 20px rgba(0,0,0,0.08)`) for +info cards and detail cards. Elevation 3 +(`0 8px 32px rgba(0,0,0,0.1)`) for the profile header. Hover lift +(`0 8px 24px rgba(0,0,0,0.1)`) for card hover states. + + +Typography rules +---------------- + +### Font family + +Body text uses the system font stack: +`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif`. +No custom fonts or font loading. Monospace (`monospace`) is reserved for +federation handles and follower addresses. + +### Hierarchy + +Base font size is `16px`. Line height for readable content is `1.625`. + + - **User name** (profile header): `2.25rem`, weight `700` + - **Section title** (card headings, posts title): `1.5rem`, + weight `600`—`700` + - **User handle** (profile header): `1.25rem`, weight `500` + - **Body large** (bio, post detail content): `1.125rem`, weight `400`, + line-height `1.625` + - **Body** (post content, forms): `1rem`, weight `400`, + line-height `1.625` + - **Label** (info labels, metadata, timestamps): `0.875rem`, + weight `600` + +### Principles + +Weight `600`—`700` for headings; `400`—`500` for body and subtext. +Opacity (`0.6`—`0.9`) creates text hierarchy on gradient backgrounds +instead of color changes. + + +Component stylings +------------------ + +### Profile header + +Background is the profile gradient. Text is `white`. Padding `2rem`, +gap `2rem` between avatar and info. Border radius `1rem`. Shadow is +elevation 3. Layout is `flex row`, collapsing to `column` on mobile. + +### Avatar + +Profile header avatar is `7.5rem` square, `border-radius: 50%`, with a +`4px solid rgba(255,255,255,0.2)` border. Post card avatars are `3rem` +with a `2px solid #e5e7eb` border. Post detail avatars are `4rem` with +the same border. + +### Cards + +Background is `var(--background)`. Border is +`1px solid rgba(0,0,0,0.1)`. Border radius is `0.75rem`. Shadow is +elevation 1 or 2 depending on context. Post cards gain +`translateY(-2px)` and hover-lift shadow on hover. Transitions are +`transform 0.2s, box-shadow 0.2s`. + +### Search input + +Full-width text input with `border-radius: 0.5rem` and +`padding: 0.75rem`. Border is `1px solid rgba(0,0,0,0.2)`. On focus +the border becomes `#3b82f6` with a `0 0 0 2px` focus ring shadow. +Placeholder text reads “Search by handle (e.g. @user@example.com)”. + +### Search result + +Inline card below the search input. Shows a row with the target's +avatar (`3rem`, rounded), display name, handle (monospace badge), and an +action button (follow or unfollow depending on current state). Appears +only when a result is available; hidden otherwise. + +### Compose form + +Textarea is full width with `border-radius: 0.5rem` and +`padding: 0.75rem`. On focus the border becomes `#3b82f6` with a +`0 0 0 2px` focus ring shadow. Font inherits body font family. Resize +is vertical only. A submit button sits below the textarea, right-aligned. + +### Buttons (primary) + +Background is the profile gradient. Text is `white`. +Padding `0.5rem 1.5rem`. Border radius `0.5rem`. Font weight `600`. +On hover the button lifts `translateY(-1px)` and the shadow intensifies. +Transition matches cards. + +### Buttons (danger) + +Used for unfollow actions. Background is `#ef4444`. Text is `white`. +Same padding, radius, weight, and hover behavior as primary buttons. + +### Back link + +Background is `color-mix(in srgb, var(--foreground) 10%, transparent)`. +Padding `0.5rem 1rem`. Border radius `0.5rem`. Hover increases the +foreground mix to 15%. + +### Fedify badge + +Background `#7dd3fc`, text `white`, height `1.5rem`, +border radius `0.375rem`. A `::before` pseudo-element renders a 16x16 +Fedify logo icon. + + +Layout principles +----------------- + +### Spacing + +Base unit is `1rem` (16px). Common spacing values are `0.25rem`, +`0.5rem`, `0.75rem`, `1rem`, `1.5rem`, and `2rem`. Section gaps range +from `1.5rem` to `2rem`. + +### Containers + +Profile, posts, and post detail containers max out at `56rem` with +`2rem` padding. The home page container maxes at `780px` with `1rem` +padding. Profile container stretches to `min-height: 100vh`. + +### Grid + +All grids are single-column. Post grid gap is `1.5rem`. Info grid gap +is `1rem`. Home grid gap is `1rem`. Profile content grid gap is `2rem`. + +### Whitespace + +Generous vertical breathing room between sections. Card internal +padding is `1.5rem`—`2rem`. Dividers are bottom borders on list items +(`1px solid rgba(0,0,0,0.05)`); the last item has no border. + + +Responsive behavior +------------------- + +Single breakpoint at `768px`. + +Below the breakpoint: + + - Profile header switches to `flex-direction: column`, + `align-items: center`, `text-align: center` + - User name shrinks from `2.25rem` to `1.875rem` + - Container padding reduces from `2rem` to `1rem` + - Post detail card padding: `2rem` to `1.5rem` + - Author avatar: `4rem` to `3.5rem` + - Author name: `1.5rem` to `1.25rem` + - Post detail content: `1.125rem` to `1rem` + - Info items stack vertically with `0.25rem` gap + +No hover-dependent information. Hover effects add visual polish only. +Tap targets meet at least `44px` effective height through padding. + + +Do's and don'ts +--------------- + +### Do + + - Use CSS custom properties (`--background`, `--foreground`) for + theme-dependent values. + - Detect theme via `prefers-color-scheme`; apply class (`light`/`dark`) + on `` at runtime. + - Keep all layout in a single static CSS file under *public/*. + - Use `rgba()` or `color-mix()` for transparent overlays rather than + hard-coded gray values. + - Render handles and federation addresses in monospace with a light gray + badge background. + - Provide *demo-profile.png* and *fedify-logo.svg* in *public/*. + - Maintain the gradient accent exclusively for profile headers and + primary action buttons. + +### Don't + + - Don't add a CSS framework (Tailwind, Bootstrap). The example must + stay dependency-free on the styling side so that any framework + integration can adopt it. + - Don't introduce custom fonts or font loading. + - Don't use JavaScript for layout or styling beyond the dark-mode class + toggle. + - Don't create multiple themes or color schemes beyond light/dark. + - Don't use the gradient accent on secondary elements (back links, info + cards, text). + - Don't add animations beyond the card hover lift and button press + feedback. + + +Static assets +------------- + +All visual assets live in *public/* and are served at the site root: + + - *style.css* —- Complete stylesheet + - *theme.js* —- Dark/light class toggle script + - *demo-profile.png* —- Demo actor avatar + - *fedify-logo.svg* —- Fedify logo for badge and branding + +### Following / followers list + +Each list is a vertical stack of rows. Each row contains an avatar +(`3rem`, rounded), display name, and handle (monospace badge). Following +rows additionally include an unfollow button (danger style). A count +label sits above each list (e.g. “Following (3)”). When the list is +empty, show a single line of muted text. Both lists update in real time +via SSE without page reload. + + +Page structure +-------------- + +Every example must implement these pages. See *ARCHITECTURE.md* for the +full routing specification. + +### Home (`/`) + +Top to bottom: + +1. **Search** —- text input with debounced lookup; result card appears + inline below +2. **User info** —- profile header (gradient, avatar, name, handle, + bio) +3. **Following** —- count + account list with unfollow buttons +4. **Followers** —- count + account list +5. **Compose** —- textarea + submit button +6. **Posts** —- reverse-chronological post cards, each linking to the + detail page + +### Actor profile (`/users/{identifier}`) + +Profile header (gradient, avatar, name, handle, bio) followed by +following count and followers count. Content-negotiated: serves HTML to +browsers and ActivityPub JSON to federation peers. + +### Post detail (`/users/{identifier}/posts/{id}`) + +Back link to home, then author profile section (same layout as the actor +profile page), then the post content and a formatted timestamp. +Content-negotiated like the actor profile. diff --git a/.agents/skills/create-integration-package/example/README.md b/.agents/skills/create-integration-package/example/README.md new file mode 100644 index 000000000..5f0cadf26 --- /dev/null +++ b/.agents/skills/create-integration-package/example/README.md @@ -0,0 +1,55 @@ + + + + +프레임워크 example application +============================== + +A comprehensive example of building a federated server application using +[Fedify] with [프레임워크]. This example demonstrates how to create an +ActivityPub-compatible federated social media server that can interact with +other federated platforms like Mastodon, Pleroma, and other ActivityPub +implementations using the Fedify and [프레임워크]. + +[Fedify]: https://fedify.dev +[프레임워크]: https://프레임.워크/ + + +Running the example +------------------- + + + +~~~~ sh +# For Deno +deno task dev + +# For pnpm(Node.js) +pnpm dev +~~~~ + + +Communicate with other federated servers +---------------------------------------- + + + +1. Tunnel your local server to the internet using `fedify tunnel` + + ~~~~ sh + fedify tunnel 0000 + ~~~~ + +2. Open your browser tunneled URL and check the server is running properly. + +3. Search your handle and follow from other federated servers such as Mastodon + or Misskey. + + > [!NOTE] + > [ActivityPub Academy] is a great resource to learn how to interact + > with other federated servers using ActivityPub protocol. + +[ActivityPub Academy]: https://www.activitypub.academy/ diff --git a/.agents/skills/create-integration-package/example/deno.jsonc b/.agents/skills/create-integration-package/example/deno.jsonc new file mode 100644 index 000000000..44ac01f3e --- /dev/null +++ b/.agents/skills/create-integration-package/example/deno.jsonc @@ -0,0 +1,13 @@ +{ + "imports": { + // Add imports required for the framework you are integrating with. + // If packages are already added in the workspace, + // you don't need to add import maps for here. + }, + "tasks": { + // `dev` task STRONGLY RECOMMENDED for `mise test:example`. + // Other tasks can be added as needed. + // Follow the convention of the framework you are integrating with. + "dev": "deno run --watch -A src/main.ts" + } +} diff --git a/.agents/skills/create-integration-package/example/package.jsonc b/.agents/skills/create-integration-package/example/package.jsonc new file mode 100644 index 000000000..7c6bbb114 --- /dev/null +++ b/.agents/skills/create-integration-package/example/package.jsonc @@ -0,0 +1,29 @@ +{ + // Fill 프레임워크 with the name of the framework you want to integrate with + "name": "프레임워크-example", + "version": "0.0.1", + "private": true, + "type": "module", + "description": "Fedify app with 프레임워크 integration", + "scripts": { + // `dev` script STRONGLY RECOMMENDED for `mise test:example`. + // Other scripts can be added as needed. + // Follow the convention of the framework you are integrating with. + "dev": "" + }, + "dependencies": { + // Add packages required for the 프레임워크 integration here + // If packages are already added in the workspace, + // you can reference them with "catalog:". + // Check `pnpm-workspace.yaml` for more packages in the workspace. + "@fedify/fedify": "workspace:^", + "@fedify/프레임워크": "workspace:^", + "@fedify/vocab": "workspace:^", + "@logtape/logtape": "catalog:" + }, + "devDependencies": { + "typescript": "catalog:", + "@types/node": "catalog:" + // Add dev dependencies required for the 프레임워크 integration here + } +} diff --git a/.agents/skills/create-integration-package/example/public/demo-profile.png b/.agents/skills/create-integration-package/example/public/demo-profile.png new file mode 100644 index 000000000..d91cf5956 Binary files /dev/null and b/.agents/skills/create-integration-package/example/public/demo-profile.png differ diff --git a/.agents/skills/create-integration-package/example/public/fedify-logo.svg b/.agents/skills/create-integration-package/example/public/fedify-logo.svg new file mode 100644 index 000000000..ba4a7e371 --- /dev/null +++ b/.agents/skills/create-integration-package/example/public/fedify-logo.svg @@ -0,0 +1,206 @@ + + + + + + + + Fedify + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fedify + + + + diff --git a/.agents/skills/create-integration-package/example/public/style.css b/.agents/skills/create-integration-package/example/public/style.css new file mode 100644 index 000000000..3d8b3e6e5 --- /dev/null +++ b/.agents/skills/create-integration-package/example/public/style.css @@ -0,0 +1,504 @@ +:root { + --background: #ffffff; + --foreground: #171717; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: var(--background); + color: var(--foreground); + font-size: 16px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen, Ubuntu, Cantarell, sans-serif; +} + +a { + color: #3b82f6; + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +/* Profile Header */ +.profile-header { + display: flex; + gap: 2rem; + padding: 2rem; + margin-bottom: 2rem; + border-radius: 1rem; + color: white; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); +} + +.avatar-section { + flex-shrink: 0; +} + +.avatar { + width: 7.5rem; + height: 7.5rem; + border-radius: 50%; + object-fit: cover; + border: 4px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +} + +.user-info { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; +} + +.user-name { + font-size: 2.25rem; + font-weight: bold; + margin: 0 0 0.5rem 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.user-handle { + font-size: 1.25rem; + font-weight: 500; + margin-bottom: 1rem; + opacity: 0.9; +} + +.user-bio { + font-size: 1.125rem; + line-height: 1.625; + opacity: 0.95; + margin: 0; +} + +/* Profile Container & Content */ +.profile-container { + max-width: 56rem; + margin: 0 auto; + display: flex; + flex-direction: column; + justify-content: center; + align-content: center; + padding: 2rem; + min-height: 100vh; +} + +.profile-content { + display: grid; + gap: 2rem; +} + +.info-card { + border-radius: 0.75rem; + border: 1px solid rgba(0, 0, 0, 0.05); + background: var(--background); + color: var(--foreground); + padding: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); +} + +.info-card h3 { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 1.5rem 0; +} + +.info-grid { + display: grid; + gap: 1rem; +} + +.info-item { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 0.75rem 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); +} + +.info-item:last-child { + border-bottom: none; +} + +.info-label { + font-size: 0.875rem; + font-weight: 600; + color: color-mix(in srgb, var(--foreground) 60%, transparent); +} + +.fedify-anchor { + display: inline-flex; + align-items: center; + gap: 0.25rem; + height: 1.5rem; + padding: 0.125rem 0.25rem; + border-radius: 0.375rem; + background: #7dd3fc; + color: white; + font-weight: 500; + text-decoration: none; +} + +.fedify-anchor::before { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + background-image: url("/fedify-logo.svg"); + background-size: 16px 16px; + vertical-align: middle; + margin-bottom: 0.125rem; +} + +/* Post Form */ +.post-form { + max-width: 56rem; + margin: 2rem auto; + padding: 1.5rem; + border-radius: 0.75rem; + border: 1px solid rgba(0, 0, 0, 0.1); + background: var(--background); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); +} + +.form-group { + margin-bottom: 1rem; +} + +.form-label { + display: block; + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--foreground); +} + +.form-textarea { + width: 100%; + resize: vertical; + border-radius: 0.5rem; + border: 1px solid rgba(0, 0, 0, 0.2); + padding: 0.75rem; + font-size: 1rem; + background: var(--background); + color: var(--foreground); + transition: border-color 0.2s, box-shadow 0.2s; + font-family: inherit; +} + +.form-textarea:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3); +} + +.post-button { + padding: 0.5rem 1.5rem; + border: none; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 600; + color: white; + cursor: pointer; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: transform 0.2s, box-shadow 0.2s; +} + +.post-button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +/* Posts Container & Grid */ +.posts-container { + max-width: 56rem; + margin: 0 auto; + padding: 0 2rem; +} + +.posts-title { + font-size: 1.5rem; + font-weight: bold; + margin-bottom: 1.5rem; + color: var(--foreground); +} + +.posts-grid { + display: grid; + gap: 1.5rem; +} + +.post-card { + border-radius: 0.75rem; + border: 1px solid rgba(0, 0, 0, 0.1); + background: var(--background); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + transition: transform 0.2s, box-shadow 0.2s; +} + +.post-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); +} + +.post-link { + display: block; + padding: 1.5rem; + text-decoration: none; + color: inherit; +} +.post-link:hover { + text-decoration: none; +} + +.post-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.post-avatar { + width: 3rem; + height: 3rem; + border-radius: 50%; + object-fit: cover; + border: 2px solid #e5e7eb; +} + +.post-user-info { + flex: 1; +} + +.post-user-name { + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 0.25rem 0; + color: var(--foreground); +} + +.post-user-handle { + font-size: 0.875rem; + opacity: 0.7; + color: var(--foreground); + margin: 0; +} + +.post-content { + font-size: 1rem; + line-height: 1.625; + color: var(--foreground); +} + +.post-content p { + margin: 0; +} + +/* Post Detail */ +.post-detail-container { + max-width: 56rem; + margin: 0 auto; + padding: 2rem; +} + +.post-detail-card { + border-radius: 0.75rem; + border: 1px solid rgba(0, 0, 0, 0.1); + background: var(--background); + padding: 2rem; + margin-bottom: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); +} + +.post-detail-author { + display: flex; + align-items: flex-start; + gap: 1rem; + padding-bottom: 1.5rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + text-decoration: none; + color: inherit; +} +.post-detail-author:hover { + text-decoration: none; +} + +.author-avatar { + width: 4rem; + height: 4rem; + border-radius: 50%; + object-fit: cover; + border: 2px solid #e5e7eb; +} + +.author-info { + flex: 1; +} + +.author-name { + font-size: 1.5rem; + font-weight: bold; + margin: 0 0 0.25rem 0; + color: var(--foreground); +} + +.author-handle { + font-size: 1rem; + font-weight: 500; + opacity: 0.7; + margin: 0 0 0.5rem 0; + color: var(--foreground); +} + +.post-timestamp { + font-size: 0.875rem; + opacity: 0.6; + color: var(--foreground); +} + +.post-detail-content { + padding: 1.5rem 0; + font-size: 1.125rem; + line-height: 1.625; + color: var(--foreground); +} + +.post-detail-content p { + margin: 0; +} + +.back-link { + display: inline-block; + margin-bottom: 1.5rem; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-weight: 500; + color: var(--foreground); + background: color-mix(in srgb, var(--foreground) 10%, transparent); + text-decoration: none; + transition: background 0.2s; +} + +.back-link:hover { + background: color-mix(in srgb, var(--foreground) 15%, transparent); + text-decoration: none; +} + +/* Home Page */ +.home-container { + max-width: 780px; + margin: 2rem auto; + display: grid; + gap: 1rem; + padding: 1rem; +} + +.home-logo { + display: block; + width: 8rem; + height: 8rem; + margin: 0 auto; +} + +.home-banner { + display: flex; + flex-wrap: wrap; + justify-content: center; + font-family: monospace; + line-height: 1.2; + white-space: pre; +} + +.home-handle { + font-family: monospace; + background: #f3f4f6; + color: #000; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + user-select: all; +} + +.follower-item { + font-family: monospace; + background: #f3f4f6; + color: #000; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; +} + +.follower-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + width: max-content; + list-style: none; +} + +/* Responsive */ +@media (max-width: 768px) { + .profile-container { + padding: 1rem; + } + + .profile-header { + flex-direction: column; + align-items: center; + gap: 1rem; + text-align: center; + } + + .user-name { + font-size: 1.875rem; + } + + .info-item { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .posts-container { + padding: 0 1rem; + } + + .post-form { + padding: 1rem; + } + + .post-detail-container { + padding: 1rem; + } + + .post-detail-card { + padding: 1.5rem; + } + + .author-avatar { + width: 3.5rem; + height: 3.5rem; + } + + .author-name { + font-size: 1.25rem; + } + + .post-detail-content { + font-size: 1rem; + } +} diff --git a/.agents/skills/create-integration-package/example/public/theme.js b/.agents/skills/create-integration-package/example/public/theme.js new file mode 100644 index 000000000..8d521d625 --- /dev/null +++ b/.agents/skills/create-integration-package/example/public/theme.js @@ -0,0 +1,7 @@ +"use strict"; +var mq = window.matchMedia("(prefers-color-scheme: dark)"); +document.body.classList.add(mq.matches ? "dark" : "light"); +mq.addEventListener("change", function (e) { + document.body.classList.remove("light", "dark"); + document.body.classList.add(e.matches ? "dark" : "light"); +}); diff --git a/.agents/skills/create-integration-package/example/src/federation.ts b/.agents/skills/create-integration-package/example/src/federation.ts new file mode 100644 index 000000000..d4952e85d --- /dev/null +++ b/.agents/skills/create-integration-package/example/src/federation.ts @@ -0,0 +1,163 @@ +import { + createFederation, + generateCryptoKeyPair, + InProcessMessageQueue, + MemoryKvStore, +} from "@fedify/fedify"; +import { + Accept, + Endpoints, + Follow, + Image, + Note, + Person, + PUBLIC_COLLECTION, + type Recipient, + Undo, +} from "@fedify/vocab"; + +const federation = createFederation({ + kv: new MemoryKvStore(), + queue: new InProcessMessageQueue(), +}); + +const IDENTIFIER = "demo"; + +federation + .setActorDispatcher( + "/users/{identifier}", + async (context, identifier) => { + if (identifier != IDENTIFIER) { + return null; + } + const keyPairs = await context.getActorKeyPairs(identifier); + return new Person({ + id: context.getActorUri(identifier), + name: "Fedify Demo", + summary: "This is a Fedify Demo account.", + preferredUsername: identifier, + icon: new Image({ url: new URL("/demo-profile.png", context.url) }), + url: new URL("/", context.url), + inbox: context.getInboxUri(identifier), + endpoints: new Endpoints({ sharedInbox: context.getInboxUri() }), + publicKey: keyPairs[0].cryptographicKey, + assertionMethods: keyPairs.map((keyPair) => keyPair.multikey), + }); + }, + ) + .setKeyPairsDispatcher(async (_, identifier) => { + if (identifier != IDENTIFIER) { + return []; + } + const keyPairs = keyPairsStore.get(identifier); + if (keyPairs) { + return keyPairs; + } + const { privateKey, publicKey } = await generateCryptoKeyPair(); + keyPairsStore.set(identifier, [{ privateKey, publicKey }]); + return [{ privateKey, publicKey }]; + }); + +federation + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Follow, async (context, follow) => { + if ( + follow.id == null || + follow.actorId == null || + follow.objectId == null + ) { + return; + } + const result = context.parseUri(follow.objectId); + if (result?.type !== "actor" || result.identifier !== IDENTIFIER) { + return; + } + const follower = await follow.getActor(context) as Person; + if (!follower?.id || follower.id === null) { + throw new Error("follower is null"); + } + await context.sendActivity( + { identifier: result.identifier }, + follower, + new Accept({ + id: new URL( + `#accepts/${follower.id.href}`, + context.getActorUri(IDENTIFIER), + ), + actor: follow.objectId, + object: follow, + }), + ); + relationStore.set(follower.id.href, follower); + }) + .on(Undo, async (context, undo) => { + const activity = await undo.getObject(context); + if (activity instanceof Follow) { + if (activity.id == null) { + return; + } + if (undo.actorId == null) { + return; + } + relationStore.delete(undo.actorId.href); + } else { + console.debug(undo); + } + }); + +federation.setObjectDispatcher( + Note, + "/users/{identifier}/posts/{id}", + (ctx, values) => { + const id = ctx.getObjectUri(Note, values); + const post = postStore.get(id); + if (post == null) return null; + return new Note({ + id, + attribution: ctx.getActorUri(values.identifier), + to: PUBLIC_COLLECTION, + cc: ctx.getFollowersUri(values.identifier), + content: post.content, + mediaType: "text/html", + published: post.published, + url: id, + }); + }, +); + +federation + .setFollowersDispatcher( + "/users/{identifier}/followers", + () => { + const followers = Array.from(relationStore.values()); + const items: Recipient[] = followers.map((f) => ({ + id: f.id, + inboxId: f.inboxId, + endpoints: f.endpoints, + })); + return { items }; + }, + ); + +federation.setNodeInfoDispatcher("/nodeinfo/2.1", (ctx) => { + return { + software: { + /** + * Fill `` with the actual framework name. + * Lowercase, digits, and hyphens only. + */ + name: "fedify-", + version: "0.0.1", + homepage: new URL(ctx.canonicalOrigin), + }, + protocols: ["activitypub"], + usage: { + // Usage statistics is hard-coded here for demonstration purposes. + // You should replace these with real statistics: + users: { total: 1, activeHalfyear: 1, activeMonth: 1 }, + localPosts: postStore.getAll().length, + }, + }; +}); + +export default federation; diff --git a/.agents/skills/create-integration-package/example/src/logging.ts b/.agents/skills/create-integration-package/example/src/logging.ts new file mode 100644 index 000000000..3b43c28c6 --- /dev/null +++ b/.agents/skills/create-integration-package/example/src/logging.ts @@ -0,0 +1,23 @@ +import { configure, getConsoleSink } from "@logtape/logtape"; +import { AsyncLocalStorage } from "node:async_hooks"; + +await configure({ + contextLocalStorage: new AsyncLocalStorage(), + sinks: { + console: getConsoleSink(), + }, + filters: {}, + loggers: [ + { + category: ["default", "example"], + lowestLevel: "debug", + sinks: ["console"], + }, + { category: "fedify", lowestLevel: "info", sinks: ["console"] }, + { + category: ["logtape", "meta"], + lowestLevel: "warning", + sinks: ["console"], + }, + ], +}); diff --git a/.agents/skills/create-integration-package/example/src/main.ts b/.agents/skills/create-integration-package/example/src/main.ts new file mode 100644 index 000000000..54324dcb0 --- /dev/null +++ b/.agents/skills/create-integration-package/example/src/main.ts @@ -0,0 +1,2 @@ +import "./logging.ts"; +import "./app.ts"; diff --git a/.agents/skills/create-integration-package/example/src/store.ts b/.agents/skills/create-integration-package/example/src/store.ts new file mode 100644 index 000000000..507531494 --- /dev/null +++ b/.agents/skills/create-integration-package/example/src/store.ts @@ -0,0 +1,45 @@ +import { Note, Person } from "@fedify/vocab"; + +declare global { + var keyPairsStore: Map>; + var relationStore: Map; + var postStore: PostStore; +} + +class PostStore { + #map: Map = new Map(); + #timeline: URL[] = []; + constructor() {} + append(posts: Note[]) { + posts.filter((p) => p.id && !this.#map.has(p.id.toString())) + .forEach((p) => { + this.#map.set(p.id!.toString(), p); + this.#timeline.push(p.id!); + }); + } + get(id: URL) { + return this.#map.get(id.toString()); + } + getAll() { + return this.#timeline.toReversed() + .map((id) => id.toString()) + .map((id) => this.#map.get(id)!) + .filter((p) => p); + } + delete(id: URL) { + const existed = this.#map.delete(id.toString()); + if (existed) { + this.#timeline = this.#timeline.filter((i) => i.href !== id.href); + } + } +} + +const keyPairsStore = globalThis.keyPairsStore ?? new Map(); +const relationStore = globalThis.relationStore ?? new Map(); +const postStore = globalThis.postStore ?? new PostStore(); + +// this is just a hack for the demo +// never do this in production, use safe and secure storage +globalThis.keyPairsStore = keyPairsStore; +globalThis.relationStore = relationStore; +globalThis.postStore = postStore; diff --git a/.agents/skills/create-integration-package/init/framework.ts b/.agents/skills/create-integration-package/init/framework.ts new file mode 100644 index 000000000..e3d24590b --- /dev/null +++ b/.agents/skills/create-integration-package/init/framework.ts @@ -0,0 +1,58 @@ +// packages/init/src/webframeworks/프레임워크.ts +// The import paths are written based on the files in +// `packages/init/src/webframeworks/` where the actual files must exist, +// so do not modify them unless necessary. + +import deps from "../json/deps.json" with { type: "json" }; +import { WebFrameworkDescription } from "../types.ts"; +import { defaultDenoDependencies, defaultDevDependencies } from "./const.ts"; +import { getInstruction } from "./utils.ts"; + +const frameworkDescription: WebFrameworkDescription = { + label: "프레임워크", // Fill 프레임워크 with the official framework name + packageManagers: [ + // List the package managers that support this framework, + // the list should be a subset of `PACKAGE_MANAGER` from `../const.ts`. + // If the framework is compatible with all package managers, + // you can just use `packageManagers: PACKAGE_MANAGER`. + ], + defaultPort: 0, // Fill in the default port of the framework + init: ({ + // Destructure necessary parameters from the argument + packageManager: pm, + }) => ({ + command: [ + // Optional shell command to run before scaffolding e.g., `create-next-app`. + // Split the command into an array of command and arguments, + // e.g., `["npx", "create-next-app@latest"]`. + ], + dependencies: pm === "deno" + ? { + // Use `deps.json` for version numbers, + // e.g., `"@fedify/프레임워크": deps["@fedify/프레임워크"]`. + ...defaultDenoDependencies, + } + : { + // Use `deps.json` for version numbers, + // e.g., `"@fedify/프레임워크": deps["@fedify/프레임워크"]`. + }, + devDependencies: { + // Use `deps.json` for version numbers, + // e.g., `"@fedify/프레임워크": deps["@fedify/프레임워크"]`. + ...defaultDevDependencies, + }, + federationFile: "**/federation.ts", + loggingFile: "**/logging.ts", + tasks: { + // If `command` create a project with `tasks` in `deno.json` (or `script`s in + // `package.json`) to run application, this could be unnecessary. + // In the tasks of the finally generated application, at least include + // a `dev` task to run the development server. `dev` task is used by + // `mise test:init` to run tests against the generated project. + // For Node.js/Bun, `lint: "eslint ."` is needed. + }, + instruction: getInstruction(pm, 0 /* Replace with default port */), + }), +}; + +export default frameworkDescription; diff --git a/.agents/skills/create-integration-package/package/README.md b/.agents/skills/create-integration-package/package/README.md new file mode 100644 index 000000000..b2af492da --- /dev/null +++ b/.agents/skills/create-integration-package/package/README.md @@ -0,0 +1,72 @@ + + + + +@fedify/프레임워크: Integrate Fedify with 프레임워크 +==================================================== + +[![JSR][JSR badge]][JSR] +[![npm][npm badge]][npm] +[![Matrix][Matrix badge]][Matrix] +[![Follow @fedify@hollo.social][@fedify@hollo.social badge]][@fedify@hollo.social] + +This package provides a simple way to integrate [Fedify] with [프레임워크]. + +[JSR badge]: https://jsr.io/badges/@fedify/프레임워크 +[JSR]: https://jsr.io/@fedify/프레임워크 +[npm badge]: https://img.shields.io/npm/v/@fedify/프레임워크?logo=npm +[npm]: https://www.npmjs.com/package/@fedify/프레임워크 +[Matrix badge]: https://img.shields.io/matrix/fedify%3Amatrix.org +[Matrix]: https://matrix.to/#/#fedify:matrix.org +[@fedify@hollo.social badge]: https://fedi-badge.deno.dev/@fedify@hollo.social/followers.svg +[@fedify@hollo.social]: https://hollo.social/@fedify +[Fedify]: https://fedify.dev/ +[프레임워크]: https://프레임.워크/ + + +Installation +------------ + + + +~~~~ bash +deno add jsr:@fedify/프레임워크 +# or +npm add @fedify/프레임워크 +# or +pnpm add @fedify/프레임워크 +# or +yarn add @fedify/프레임워크 +# or +bun add @fedify/프레임워크 +~~~~ + + +Usage +----- + +First, create your `Federation` instance in a server utility file, +e.g., *src/federation.ts*: + +~~~~ typescript +import { createFederation, MemoryKvStore } from "@fedify/fedify"; + +const federation = createFederation({ + kv: new MemoryKvStore(), +}); + +// ... configure your federation ... + +export default federation; +~~~~ + +Then, add Fedify middleware to your server: + +~~~~ typescript +import fedifyHandler from "@fedify/프레임워크"; +import federation from "./federation.ts"; + +const fedifyMiddleware = fedifyHandler(federation); + +app.use(fedifyMiddleware); +~~~~ diff --git a/.agents/skills/create-integration-package/package/deno.jsonc b/.agents/skills/create-integration-package/package/deno.jsonc new file mode 100644 index 000000000..1042c3470 --- /dev/null +++ b/.agents/skills/create-integration-package/package/deno.jsonc @@ -0,0 +1,28 @@ +{ + "name": "@fedify/프레임워크", // Replace `프레임워크` with the framework name in lowercase + "version": "*.*.*", // Sync with packages/fedify/deno.json + "license": "MIT", + "exports": { + ".": "./src/mod.ts" + }, + "imports": { + // Add Fedify and framework dependencies here. + // Use JSR packages in Deno, and alias to the npm name when they differ. + // Example: + // "hono": "jsr:@hono/hono@^4" + }, + "exclude": [ + "dist", + "node_modules" + ], + "publish": { + "exclude": [ + "**/*.test.ts", // If there are test files + "tsdown.config.ts" + ] + }, + "tasks": { + "check": "deno fmt --check && deno lint && deno check src/*.ts", + "test": "deno test" // Add if --allow-* for permissions is needed + } +} diff --git a/.agents/skills/create-integration-package/package/package.jsonc b/.agents/skills/create-integration-package/package/package.jsonc new file mode 100644 index 000000000..7882492ae --- /dev/null +++ b/.agents/skills/create-integration-package/package/package.jsonc @@ -0,0 +1,66 @@ +{ + "name": "@fedify/프레임워크", // Fill 프레임워크 with the framework name in lowercase + "version": "*.*.*", // Sync with packages/fedify/package.json + "description": "Integration Package for Fedify with 프레임워크", // Fill 프레임워크 with the framework name + "keywords": [ + "Fedify", + "Federation", + // Add relevant keywords for the framework + ], + "author": { // Fill in author information + "name": "", + "email": "", + "url": "", + }, + "repository": { + "type": "git", + "url": "git+https://github.com/fedify-dev/fedify.git", + "directory": "packages/프레임워크" // Fill 프레임워크 with the framework name + }, + "homepage": "https://fedify.dev/", + "license": "MIT", + "bugs": { + "url": "https://github.com/fedify-dev/fedify/issues" + }, + "funding": [ + "https://opencollective.com/fedify", + "https://github.com/sponsors/dahlia" + ], + "type": "module", + "main": "./dist/mod.cjs", + "module": "./dist/mod.js", + "types": "./dist/mod.d.ts", + "exports": { + ".": { + "types": { + "import": "./dist/mod.d.ts", + "require": "./dist/mod.d.cts", + "default": "./dist/mod.d.ts" + }, + "import": "./dist/mod.js", + "require": "./dist/mod.cjs", + "default": "./dist/mod.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/", + "package.json" + ], + "peerDependencies": { + "@fedify/fedify": "workspace:^", + // Add relevant peer dependencies for the framework + }, + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + // Add other relevant peer dependencies for the framework + }, + "scripts": { + "build:self": "tsdown", + "build": "pnpm --filter @fedify/프레임워크... run build:self", + "prepack": "pnpm build", + "prepublish": "pnpm build" + } +} diff --git a/.agents/skills/create-integration-package/package/src/mod.ts b/.agents/skills/create-integration-package/package/src/mod.ts new file mode 100644 index 000000000..e1961ae2e --- /dev/null +++ b/.agents/skills/create-integration-package/package/src/mod.ts @@ -0,0 +1,17 @@ +import { Federation } from "@fedify/fedify"; +import type { FrameworkMiddlewareHandler } from "프레임워크"; + +// `FrameworkContext` could be unnecessary. +// Remove it if the framework's middleware handler does not provide a context object. + +export type ContextDataFactory = ( + context: FrameworkContext, +) => TContextData | Promise; + +export function fedifyMiddleware( + federation: Federation, + contextDataFactory: ContextDataFactory = + (() => void 0 as TContextData), +): FrameworkMiddlewareHandler { + // Implement handler or middleware +} diff --git a/.agents/skills/create-integration-package/package/tsdown.config.ts b/.agents/skills/create-integration-package/package/tsdown.config.ts new file mode 100644 index 000000000..54e25f61b --- /dev/null +++ b/.agents/skills/create-integration-package/package/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/mod.ts"], + format: ["esm", "cjs"], + platform: "node", + outExtensions({ format }) { + return { + js: format === "cjs" ? ".cjs" : ".js", + dts: format === "cjs" ? ".d.cts" : ".d.ts", + }; + }, +}); diff --git a/AGENTS.md b/AGENTS.md index b23d7d2b9..020c40f65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,11 +158,10 @@ Common tasks ### Implementing framework integrations -1. Create a new package in *packages/* directory for new integrations -2. Follow pattern from existing integration packages (*packages/hono/*, - *packages/sveltekit/*) -3. Use standard request/response interfaces for compatibility -4. Consider creating example applications in *examples/* that demonstrate usage +A detailed step-by-step guide is available in +*.agents/skills/create-integration-package/SKILL.md*. It covers the entire +workflow: researching the framework, creating the package, adding it to +`fedify init`, testing, and writing an example. ### Creating database adapters diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b73c580e..e41f9bbad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -208,6 +208,15 @@ When adding a new package to the monorepo, the following files must be updated: - If using pnpm catalog for dependency management: Add to `catalog` in *pnpm-workspace.yaml*. +### Adding a web framework integration + +A step-by-step guide for implementing a web framework integration package is +available in *.agents/skills/create-integration-package/SKILL.md*. Although +the file is primarily designed for AI coding agents, the instructions are +written so that human contributors can also read and follow them. The guide +covers the entire workflow from researching the framework through creating +the package, adding it to `fedify init`, testing, and writing an example. + ### Dependency management Fedify uses two package managers: diff --git a/examples/test-examples/mod.ts b/examples/test-examples/mod.ts index 0ed78181f..d83dac360 100644 --- a/examples/test-examples/mod.ts +++ b/examples/test-examples/mod.ts @@ -126,6 +126,25 @@ type TestResult = | { name: string; status: "skip"; reason: string }; // ─── Example Registry ───────────────────────────────────────────────────────── +// +// Every example directory under examples/ must be registered in exactly one of +// the arrays below. The test runner scans the examples/ directory and reports +// any unregistered directories as warnings. +// +// - SERVER_EXAMPLES – Long-running HTTP servers. The runner starts the +// server, opens a tunnel, and verifies federation via +// `fedify lookup`. Most integration framework examples +// belong here. +// - SCRIPT_EXAMPLES – Standalone scripts (no server). The runner executes +// the command and checks the exit code. +// - MULTI_HANDLE_EXAMPLES – Scripts that accept an ActivityPub handle as +// their last argument. Multiple handles are tried in +// order; the test passes if any exits with code 0. +// - SKIPPED_EXAMPLES – Examples that cannot be tested automatically. +// Provide a reason string explaining why. +// +// See the interface definitions above for the full set of fields each entry +// accepts. const SERVER_EXAMPLES: ServerExample[] = [ { diff --git a/packages/init/src/test/server.ts b/packages/init/src/test/server.ts index 25be5d982..a455fa4ac 100644 --- a/packages/init/src/test/server.ts +++ b/packages/init/src/test/server.ts @@ -1,6 +1,8 @@ -import $ from "@david/dax"; +import { spawn } from "node:child_process"; import { createWriteStream, type WriteStream } from "node:fs"; import { join as joinPath } from "node:path"; +import process from "node:process"; +import { Readable } from "node:stream"; import { printErrorMessage } from "../utils.ts"; import { ensurePortReleased, killProcessOnPort } from "./port.ts"; @@ -45,20 +47,22 @@ export async function serverClosure( await releasePort?.(); const devCommand = cmd.split(" "); - const serverProcess = $`${devCommand}` - .cwd(dir) - .env("PORT", String(defaultPort)) - .stdin("null") - .stdout("piped") - .stderr("piped") - .noThrow() - .spawn(); + const child = spawn(devCommand[0], devCommand.slice(1), { + cwd: dir, + env: { ...process.env, PORT: String(defaultPort) }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, // creates a new process group so we can kill the tree + }); + + // Prevent unhandled exception when the process is killed + child.on("error", () => {}); - // Prevent unhandled rejection when the process is killed - serverProcess.catch(() => {}); + // Convert Node.js readable streams to Web ReadableStreams for tee() + const stdoutWeb = Readable.toWeb(child.stdout!) as ReadableStream; + const stderrWeb = Readable.toWeb(child.stderr!) as ReadableStream; - const [stdoutForFile, stdoutForPort] = serverProcess.stdout().tee(); - const [stderrForFile, stderrForPort] = serverProcess.stderr().tee(); + const [stdoutForFile, stdoutForPort] = stdoutWeb.tee(); + const [stderrForFile, stderrForPort] = stderrWeb.tee(); // Shared signal to cancel all background stream readers on cleanup const cleanup = new AbortController(); @@ -82,8 +86,18 @@ export async function serverClosure( }); return await callback(port); } finally { + // Kill the entire process group (tsx watch + all its children) + try { + if (child.pid != null) { + process.kill(-child.pid, "SIGKILL"); + } + } catch { + // Process group already exited + } + + // Also kill the child directly in case it wasn't in the group try { - serverProcess.kill("SIGKILL"); + child.kill("SIGKILL"); } catch { // Process already exited }