diff --git a/apps/web/content/docs/content-engine/access-api.mdx b/apps/web/content/docs/content-engine/access-api.mdx index 52cd0aebfe..7d2608655b 100644 --- a/apps/web/content/docs/content-engine/access-api.mdx +++ b/apps/web/content/docs/content-engine/access-api.mdx @@ -57,7 +57,7 @@ The constants are exported from `@vitnode/core/content`: `CONTENT_ACCESS_ACTIONS `createContentAccess(c)` from `@vitnode/core/content/server` checks the current viewer inside a Hono route. Roles are read from the database once per request; the results are memoized for that request. -```ts title="plugins/forum/src/api/modules/forum/stats.routes.ts" +```ts title="plugins/forum/src/api/modules/forum/routes/show-topic-slug.route.ts" import { createContentAccess } from "@vitnode/core/content/server"; const access = createContentAccess(c); diff --git a/apps/web/content/docs/content-engine/container-hierarchy.mdx b/apps/web/content/docs/content-engine/container-hierarchy.mdx index 03224bcc4e..9d59a9b951 100644 --- a/apps/web/content/docs/content-engine/container-hierarchy.mdx +++ b/apps/web/content/docs/content-engine/container-hierarchy.mdx @@ -68,7 +68,7 @@ A collection holds at most 5,000 containers (`limitReached`, 409). The whole tre `createContentContainerService` is the one way to read and change a collection. The AdminCP routes and the public routes both use it, so your code gets the same rules, cache expiry and events. Call it inside any Hono handler with the request context. Here is a route you could add to the newsroom plugin. It files a desk under an **Archive** desk and locks it: -```ts title="src/api/modules/desks/archive.route.ts" +```ts title="src/api/modules/desks/routes/archive.route.ts" import { createContentContainerService } from "@vitnode/core/content/server"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; diff --git a/apps/web/content/docs/content-engine/events.mdx b/apps/web/content/docs/content-engine/events.mdx index fc55bd3a69..2a833255e8 100644 --- a/apps/web/content/docs/content-engine/events.mdx +++ b/apps/web/content/docs/content-engine/events.mdx @@ -104,7 +104,7 @@ export const announceStoryListener = buildEventListener({ ### Add it to a top-level module -VitNode collects listeners only from the `events` array of a module passed directly to `buildApiPlugin`. A listener on a nested module is silently ignored. +VitNode collects listeners only from the `events` array of a module passed directly to `buildApiPlugin`. A listener on a nested module never runs: `buildApiPlugin` throws in development to say so, and [`vitnode doctor`](/docs/dev/cli/doctor) reports it. Production ignores it. ```ts title="plugins/newsroom/src/api/modules/admin/admin.module.ts (excerpt)" export const adminModule = buildModule({ diff --git a/apps/web/content/docs/content-engine/generate.mdx b/apps/web/content/docs/content-engine/generate.mdx new file mode 100644 index 0000000000..0bfffeab51 --- /dev/null +++ b/apps/web/content/docs/content-engine/generate.mdx @@ -0,0 +1,136 @@ +--- +title: Generate a content type +description: Scaffold an administrative VitNode Content Engine content type - definition, model, admin module, AdminCP screen and labels - with vitnode generate resource --kind content. +icon: Wand +--- + +`vitnode g resource --kind content` writes the files [First content type](/docs/content-engine/create-a-content-type) walks through by hand: the definition, the model and its table export, the staff API mount, the AdminCP sidebar entry and screen, and the labels. The Content Engine then generates the CRUD routes and permissions, exactly as for a hand-written content type. The generator never writes a migration and never applies one. + +The result is **administrative only**: staff create and edit records in the AdminCP. It has no public API, no member writes, no drafts and no ownership rules, because each of those changes who can read or write the records. Turn them on in the definition when you decide, following the linked guides. + +## Before you begin + +- A plugin made with [Create plugin](/docs/dev/plugins/create), with a terminal in its folder (here `plugins/site-notes`). +- A database, to apply the migration at the end. + + + + + +### Preview the content type + +Name the resource, and list the fields as `name:type` with `text`, `richText`, `boolean`, `int` or `number`. A `?` after a name makes the field optional, and the first required `text` field becomes the title: + +```bash +pnpm vitnode g resource articles --kind content --fields "title:text,body:richText,featured?:boolean" --singular Article --dry-run +``` + +```txt + would create src/content/articles.ts content type sitenotes.articles + would create src/database/articles.ts model and table site_notes_articles + would create src/api/modules/admin/admin.module.ts module "admin"; mount articlesContent in the admin module + would create src/admin/nav.tsx AdminCP sidebar entry + would create src/admin/content.tsx AdminCP screens, re-using the sidebar list + would update src/api/builders.ts bind the Content Engine admin builder + would update src/config.api.ts register adminModule in buildApiPlugin + would update src/locales/en.json AdminCP labels for the content type +``` + +The names are derived, never guessed from meaning: + +| Value | Derived as | Override | +| ----------------- | ----------------------------------------------------- | ---------------- | +| Content type id | Plugin name without dashes, a dot, the resource name | `--content-id` | +| Table | Plugin name in snake_case, `_`, the resource name | `--table` | +| Permission module | The part of the id after the dot: `articles` | - | +| AdminCP path | `/admin/content//` | - | +| Id strategy | `serial`, the Content Engine default | `--id-type uuid` or `bigint` | +| Singular label | The plural, `Articles` | `--singular` | + +The generator declares no permissions. The Content Engine gives the permission module `can_view`, `can_create`, `can_edit` and `can_delete`, but only when the plugin does not declare that module in `permissionStaff`, inline or through [`defineStaffPermissions`](/docs/dev/working-with-users/staff-permission-refs). A plugin that already declares `articles` with all four is fine. One that declares only some of them would leave staff routes nobody can be granted, so the command stops before writing: + +```txt +✖ The plugin declares the permission module "articles" without can_create, can_edit, can_delete. + The Content Engine adds can_view, can_create, can_edit, can_delete to "articles" only when the plugin does not declare it, and src/config.api.ts declares it with can_view. + The generated staff routes would require permissions nobody can be granted. Nothing was written. + Add the missing ones to permissionStaff.admin["articles"] in src/config.api.ts: + { permission: "can_create", dependsOn: ["can_view"] }, + { permission: "can_edit", dependsOn: ["can_view"] }, + { permission: "can_delete", dependsOn: ["can_view"] }, + Or remove the "articles" entry, so the Content Engine declares all four itself. +``` + +It also stops, with the steps to finish by hand, when it cannot read the plugin's permission declarations: `permissionStaff` has to be an object literal or `staffPermissions.config`, not a variable such as `permissionStaff: permissions`. + + + + + +### Generate it + +Run the command without `--dry-run`. The definition it writes: + +```ts title="plugins/site-notes/src/content/articles.ts" +import { defineContentType, field } from "@vitnode/core/content"; + +export const articlesContentType = defineContentType({ + id: "sitenotes.articles", + tableName: "site_notes_articles", + fields: { + title: field.text({ maxLength: 200, required: true }), + body: field.richText({ required: true }), + featured: field.boolean({ defaultValue: false }), + }, + admin: { + path: "site-notes/articles", + titleField: "title", + list: { + columns: ["title", "updatedAt"], + }, + }, +}); +``` + +In an existing plugin, the generator adds to what is there: the model joins the `contentTypes` of an existing `buildContentAdminModule(...)` in the `admin` module, the entry joins `adminNav.contentTypes`, and labels are only added, never overwritten. It stops before writing, with the lines to add by hand, when `src/admin/content.tsx` builds its own `adminContent` instead of re-exporting `adminNav`. + + + + + +### Build and migrate + +Migrations are generated from the plugin's build output, so the order matters: + +```bash +pnpm vitnode build # in plugins/site-notes, or keep pnpm dev running +cd ../../apps/api # the app with drizzle.config.ts; apps/web in a single-app setup +pnpm vitnode db generate # writes the migration for site_notes_articles +pnpm vitnode db migrate # applies it, after asking +``` + +Review the migration before you apply it. The root `pnpm db:migrate` script of a Turborepo workspace generates and applies in one go without asking, so it skips that review. See [Database commands](/docs/dev/cli/database). + + + + + +## Check the result + +Run `pnpm vitnode plugin validate` in the plugin: **AdminCP navigation** and **Database schema** pass. Then open **AdminCP → Site notes → Articles**, choose **Create Article**, and save one. `pnpm vitnode inspect routes` lists the generated staff routes, such as `POST /site-notes/admin/content/articles` guarded by `articles.can_create`, and restricted administrators see the four permissions under **Site notes → Articles**. + +The guides below continue the hand-written `newsroom.article` tutorial; apply their configuration to `articlesContentType` the same way. + +## Related + + + + + diff --git a/apps/web/content/docs/content-engine/meta.json b/apps/web/content/docs/content-engine/meta.json index 9257ce7687..31d58c10e5 100644 --- a/apps/web/content/docs/content-engine/meta.json +++ b/apps/web/content/docs/content-engine/meta.json @@ -56,6 +56,7 @@ "activity-logs", "---Customization---", "admincp", + "generate", "services", "events", "live-refresh", diff --git a/apps/web/content/docs/content-engine/services.mdx b/apps/web/content/docs/content-engine/services.mdx index 0a459f2168..7d5ae03fce 100644 --- a/apps/web/content/docs/content-engine/services.mdx +++ b/apps/web/content/docs/content-engine/services.mdx @@ -25,7 +25,7 @@ story?.title; // string, and a typo is a compile error `publicService` returns exactly what the [public API](/docs/content-engine/public-api) would, so a custom public route can never leak a draft: -```ts title="plugins/newsroom/src/api/modules/stories/by-category.route.ts" +```ts title="plugins/newsroom/src/api/modules/stories/routes/by-category.route.ts" handler: async c => { const { publicService } = storyContent; if (!publicService) throw new Error("Stories have no public API."); diff --git a/apps/web/content/docs/content-engine/threads-editing.mdx b/apps/web/content/docs/content-engine/threads-editing.mdx index 8b41417aac..8d862b7dbf 100644 --- a/apps/web/content/docs/content-engine/threads-editing.mdx +++ b/apps/web/content/docs/content-engine/threads-editing.mdx @@ -262,18 +262,18 @@ Add `delivery` to the comment type. This is an excerpt: Delivery claims the type's `publicApi.path` across the whole site. That's why this guide picked `article-comments`. The Blog plugin already delivers `comments`, and two plugins can't share a path. -The redirect needs the article's slug, and a comment only knows its article's id. Add a small public route that answers it. Create `src/api/modules/articles/articles.module.ts`: +The redirect needs the article's slug, and a comment only knows its article's id. Add a small public route that answers it. Create `src/api/modules/articles/routes/show-slug.route.ts`: -```ts title="plugins/newsroom/src/api/modules/articles/articles.module.ts" +```ts title="plugins/newsroom/src/api/modules/articles/routes/show-slug.route.ts" import { z } from "@hono/zod-openapi"; import { setPageCacheTags } from "@vitnode/core/api/lib/page-cache"; import { contentPublicListTag } from "@vitnode/core/content"; import { HTTPException } from "hono/http-exception"; -import { buildModule, buildRoute } from "@/api/builders"; +import { buildRoute } from "@/api/builders"; import { articleContent } from "@/database/articles"; -const articleSlugRoute = buildRoute({ +export const articleSlugRoute = buildRoute({ route: { method: "get", path: "/{id}/slug", @@ -311,6 +311,14 @@ const articleSlugRoute = buildRoute({ return c.json({ id: article.id, slug: article.slug }, 200); }, }); +``` + +Group it in a module. Create `src/api/modules/articles/articles.module.ts`: + +```ts title="plugins/newsroom/src/api/modules/articles/articles.module.ts" +import { buildModule } from "@/api/builders"; + +import { articleSlugRoute } from "./routes/show-slug.route"; export const articlesModule = buildModule({ name: "articles", diff --git a/apps/web/content/docs/dev/advanced/meta.json b/apps/web/content/docs/dev/advanced/meta.json index e0cb3d7c69..dea60eb3eb 100644 --- a/apps/web/content/docs/dev/advanced/meta.json +++ b/apps/web/content/docs/dev/advanced/meta.json @@ -7,6 +7,8 @@ "rate-limiter", "rate-limiter-debugging", "queue", + "queue-typed-payloads", + "queue-reference", "redis" ] } diff --git a/apps/web/content/docs/dev/advanced/queue-reference.mdx b/apps/web/content/docs/dev/advanced/queue-reference.mdx new file mode 100644 index 0000000000..ab412c245a --- /dev/null +++ b/apps/web/content/docs/dev/advanced/queue-reference.mdx @@ -0,0 +1,160 @@ +--- +title: Queue reference +description: Options, types, errors and JSON rules of VitNode's queue API - buildQueueTask, typed and by-name dispatch, QueueTaskInput and QueueTaskPayload. +icon: BookText +--- + +import { TypeTable } from "fumadocs-ui/components/type-table"; + +Exact signatures and behavior of the background task queue. For a walkthrough, start with [Queue](/docs/dev/advanced/queue). + +## `buildQueueTask` + +Server-only. Two forms: + +| Import | Form | Returns | +| ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------- | +| `buildQueueTask` from your plugin's `@/api/builders` (`bindApiPlugin(manifest).buildQueueTask`) | Typed: takes `payload`, fills in `pluginId` | `QueueTaskDefinition` | +| `buildQueueTask` from `@vitnode/core/api/lib/queue` | Untyped: no `payload`. Also takes a typed task with an explicit `pluginId` | `BuildQueueTaskReturn` | + + Promise | void", + }, + maxAttempts: { + default: "3", + description: + "Attempts before the task is failed, unless the dispatch sets its own.", + type: "number", + }, + leaseSeconds: { + default: "600", + description: + "How long a worker may hold a claimed task before it counts as abandoned.", + type: "number", + }, + description: { + description: "A note for developers. Core does not display it.", + type: "string", + }, + }} +/> + +Register either form in a top-level module's `queueTasks` array. + +## `dispatch()` + +`c.get("queue").dispatch()` has two overloads. Both resolve to `{ deduplicated: boolean; id: number | null }` and only insert a row. + +```ts +dispatch(task: QueueTaskDefinition, payload: QueueTaskInput, options?: QueueDispatchOptions) +dispatch(args: QueueDispatchArgs) +``` + +| Overload | Identity | Payload check | +| -------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| `dispatch(task, payload, options)` | The definition's `pluginId` and `name`; the options cannot override them | Compile time against the schema input, then runtime validation. Throws when the task is not registered or another task holds its name. | +| `dispatch({ name, payload, pluginId, ... })` | `pluginId`, else the current route's plugin, else `@vitnode/core` | Runtime validation only, and only when the registered task has a schema. A name with no registered task is still queued, as before. | + +", + }, + pluginId: { + description: "By-name form only: plugin that owns the task.", + default: "the current route's plugin", + type: "string", + }, + }} +/> + +## Types + +All from `@vitnode/core/api/lib/queue`, except `QueueDispatchOptions` and `QueueDispatchResult` from `@vitnode/core/api/models/queue`. + +| Type | Meaning | +| ------------------------------------------- | ----------------------------------------------------------- | +| `QueueTaskDefinition` | A typed task, as the bound builder returns it. | +| `QueueTaskInput` | What `dispatch(task, payload)` accepts: the schema's input. | +| `QueueTaskPayload` | What the handler receives: the schema's output. | +| `QueueDispatchOptions` | The third argument of the typed `dispatch`. | +| `QueueDispatchResult` | `{ deduplicated, id }`. | + +Input and output differ when the schema transforms. With `at: z.iso.datetime().transform(value => new Date(value))`, `dispatch` takes a string and the handler gets a `Date`. + +## JSON payloads + +A payload is stored as JSON in `core_queue.payload` and parsed back for the handler. Before validating, `dispatch` copies the payload to JSON and throws `QueuePayloadValidationError` instead of letting `JSON.stringify` change it: + +- **Rejected:** `Date`, `Map`, `Set` and other class instances, `NaN`, `Infinity`, `bigint`, functions, circular references, and `undefined` or missing (sparse) array items. +- **Omitted:** object properties set to `undefined`, exactly as JSON drops them. Make such fields `.optional()`. +- **Stored:** the JSON input, never the schema's output. The worker parses it again, so a transform runs on every attempt and stored rows stay readable after a transform changes. + +The schema's input type must be JSON, so `z.date()` or `z.map()` in a payload schema is a compile error. Use `z.iso.datetime()`, optionally with a `.transform()`. + +## Errors + +| Error | Thrown by | When | Enqueued | +| ----------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------- | -------- | +| `QueuePayloadValidationError` | `dispatch` | The payload is not JSON, or the schema rejects it. `reason` lists paths and issue codes, never values. | No | +| `QueueTaskRegistrationError` | `dispatch(task, ...)` | The task is not registered, or a different task is registered under its name. | No | +| `QueueTaskRegistrationError` | `buildApiPlugin` | A typed task is registered by another plugin, or shares its name with a different task of the same plugin. | - | + +When the worker finds a stored payload its task's schema rejects, the row becomes **failed** at once with `Invalid payload: …` in `lastError`; it is not retried and is kept. A row whose task is not registered keeps the old behavior: it is retried with "No handler registered" until it runs out of attempts. + +## Related + +- [Migrate a task to a schema](/docs/dev/advanced/queue-typed-payloads) +- [Queue](/docs/dev/advanced/queue) diff --git a/apps/web/content/docs/dev/advanced/queue-typed-payloads.mdx b/apps/web/content/docs/dev/advanced/queue-typed-payloads.mdx new file mode 100644 index 0000000000..0f42c5159b --- /dev/null +++ b/apps/web/content/docs/dev/advanced/queue-typed-payloads.mdx @@ -0,0 +1,94 @@ +--- +title: Migrate a task to a schema +description: Add a Zod payload schema to an existing VitNode queue task without breaking rows that are already queued, and see what happens to a stored payload the schema rejects. +icon: FileCheck +--- + +An untyped queue task reads `Record` and checks every field by hand. Giving it a `payload` schema moves that check to the task's definition: dispatches are type-checked and validated before they are stored, and the handler receives parsed data. This guide migrates the untyped `announce-note` task and keeps every queued row working. + +## Before you begin + +- An untyped task registered on a top-level module, such as the `announce-note` task from an earlier version of the [Queue](/docs/dev/advanced/queue) guide, created with `buildQueueTask` from `@vitnode/core/api/lib/queue`. +- `buildQueueTask` exported from your plugin's `src/api/builders.ts`, as shown in [Bind the task builder](/docs/dev/advanced/queue#bind-the-task-builder-to-your-plugin). + + + + + +### Write a schema that accepts the rows already queued + +Look at the payloads your code dispatches today. Every pending row was stored with one of them, and the worker parses it with the new schema before the handler runs. A row the schema rejects fails for good, so the schema must accept every old shape: + +```ts +z.object({ noteId: z.number().int().positive() }); +``` + +Unknown keys are fine: `z.object` drops them. A field that old rows lack must be `.optional()` (or have a `.default()`), or those rows fail. + + + + + +### Add the schema and keep the name + +Switch the import to the bound builder, add `payload`, and read the fields from the handler's second argument. Keep `name` exactly as it was: pending rows find their handler by plugin and name. + +```ts title="plugins/site-notes/src/api/tasks/announce-note.task.ts" +import { buildQueueTask } from "@vitnode/core/api/lib/queue"; // [!code --] +import { buildQueueTask } from "@/api/builders"; // [!code ++] + +export const announceNoteTask = buildQueueTask({ + name: "announce-note", + payload: z.object({ noteId: z.number().int().positive() }), // [!code ++] + maxAttempts: 5, + handler: async (c, payload) => { // [!code --] + const { noteId } = zodPayload.parse(payload); // [!code --] + handler: async (c, { noteId }) => { // [!code ++] + // unchanged + }, +}); +``` + +The task stays registered in the same `queueTasks` array. Nothing is migrated in the database: rows keep their JSON, and the change takes effect when the API restarts. + + + + + +### Switch the dispatches when you are ready + +Existing `dispatch({ name: "announce-note", payload })` calls keep working, and are now validated against the schema: an invalid payload throws `QueuePayloadValidationError` instead of being queued. Change them to the typed form one at a time to get compile-time checks: + +```ts +await c + .get("queue") + .dispatch({ name: "announce-note", payload: { noteId: id } }); // [!code --] +await c.get("queue").dispatch(announceNoteTask, { noteId: id }); // [!code ++] +``` + +`dedupeKey`, `availableAt`, `priority`, `queue`, `maxAttempts` and `tx` move to the third argument. + + + + + +## Check the result + +1. Build the plugin (`vitnode dev` running is enough), then run `npx vitnode inspect jobs` in it. The `announce-note` row shows `schema` in the **Payload** column; tasks you have not migrated show `untyped`. The command never prints payloads. +2. Dispatch the task and run the processor, as in [Check the result](/docs/dev/advanced/queue#check-the-result). It completes as before. +3. Optional: queue a row the schema rejects by inserting one by hand, for example with `payload` set to `{"noteId": "3"}`. After the next processor run, **Debug Panel → Queue** shows it as **failed** with `1/5` attempts and `Invalid payload: payload.noteId: invalid_type (expected number)`. + +## What happens to an invalid stored payload + +The worker parses each row with the task's schema right before the handler runs. When the schema rejects it, the row becomes **failed** at once, without using the remaining attempts or the backoff, because retrying cannot change stored data. The row is kept, so you can find it in the Debug Panel and dispatch a corrected task. The error names each issue's path and code, never the value, so a payload that holds an email address does not leak into logs. + +A schema that throws, for example an async refinement whose database query fails, is not a validation failure: the attempt fails and is retried like any handler error. + +## Ownership and registration + +A typed task belongs to the plugin whose bound builder created it. `buildApiPlugin` throws `QueueTaskRegistrationError` when another plugin registers it, or when two different tasks of one plugin share a name. `dispatch(task, payload)` throws the same error when the task is not registered in the running API, so a typed dispatch can never queue work nothing would run. + +## Related + +- [Queue reference](/docs/dev/advanced/queue-reference): every option, type and error. +- [Queue](/docs/dev/advanced/queue): retries, leases, deduplication and the worker. diff --git a/apps/web/content/docs/dev/advanced/queue.mdx b/apps/web/content/docs/dev/advanced/queue.mdx index aeb47107c1..dd4ed43fc8 100644 --- a/apps/web/content/docs/dev/advanced/queue.mdx +++ b/apps/web/content/docs/dev/advanced/queue.mdx @@ -1,11 +1,9 @@ --- title: Queue -description: Run slow work in the background with VitNode's PostgreSQL queue. Define a task, validate its payload, dispatch it from a route, and follow it to completion with retries, delays, leases and deduplication. +description: Run slow work in the background with VitNode's PostgreSQL queue. Define a typed task with a payload schema, dispatch it from a route, and follow it to completion with retries, delays, leases and deduplication. icon: ListOrdered --- -import { TypeTable } from "fumadocs-ui/components/type-table"; - import { ImgDocs } from "@/components/fumadocs/img"; import queuePanel from "./queue-debug-panel.webp"; @@ -20,24 +18,44 @@ The queue moves work out of the request. A route stores a task in the `core_queu +### Bind the task builder to your plugin + +Add `buildQueueTask` to the builders in `src/api/builders.ts`, next to `buildModule` and `buildRoute`: + +```ts title="plugins/site-notes/src/api/builders.ts" +import { bindApiPlugin } from "@vitnode/core/api/lib/bind-api-plugin"; + +import { manifest } from "@/manifest.gen"; + +const apiBuilders = bindApiPlugin(manifest); + +export const buildModule = apiBuilders.buildModule; +export const buildRoute = apiBuilders.buildRoute; +export const buildQueueTask = apiBuilders.buildQueueTask; // [!code ++] +``` + +The bound builder stamps every task with your plugin id, so its rows are stored under `site-notes` whichever route dispatches it. + + + + + ### Create the task Create `src/api/tasks/announce-note.task.ts`: ```ts title="plugins/site-notes/src/api/tasks/announce-note.task.ts" -import { buildQueueTask } from "@vitnode/core/api/lib/queue"; import { eq } from "drizzle-orm"; import { z } from "zod"; +import { buildQueueTask } from "@/api/builders"; import { site_notes } from "@/database/notes"; -const zodPayload = z.object({ noteId: z.number().int().positive() }); - export const announceNoteTask = buildQueueTask({ name: "announce-note", + payload: z.object({ noteId: z.number().int().positive() }), maxAttempts: 5, - handler: async (c, payload) => { - const { noteId } = zodPayload.parse(payload); + handler: async (c, { noteId }) => { const [note] = await c .get("db") .select({ title: site_notes.title }) @@ -52,7 +70,8 @@ export const announceNoteTask = buildQueueTask({ }); ``` -- **The payload is stored as JSON**, so the handler receives it as `Record`. `buildQueueTask` is not generic, so validate the payload yourself, here with Zod. An invalid payload throws, which counts as a failed attempt. +- **`payload` is the task's contract.** `dispatch()` type-checks against the schema's input and validates before inserting; the handler receives the schema's output, parsed again just before it runs. `noteId` is a `number` here, with no cast. +- **The payload is stored as JSON.** The schema's input must be JSON, so use `z.iso.datetime()` rather than `z.date()`. See [JSON payloads](/docs/dev/advanced/queue-reference#json-payloads). - **Returning completes the task. Throwing fails the attempt** and schedules a retry. - **`name` is unique within the plugin.** Tasks are matched to handlers by plugin id and name. - **`c.get("log").debug()`** writes to the console and to the Debug Panel's log explorer. @@ -69,8 +88,8 @@ Add the task to the `queueTasks` of `notesModule`, which `config.api.ts` passes import { buildModule } from "@/api/builders"; import { announceNoteTask } from "@/api/tasks/announce-note.task"; // [!code ++] -import { listNotesRoute } from "./list.route"; -import { showNoteRoute } from "./show.route"; +import { listNotesRoute } from "./routes/list.route"; +import { showNoteRoute } from "./routes/show.route"; export const notesModule = buildModule({ name: "notes", @@ -79,7 +98,7 @@ export const notesModule = buildModule({ }); ``` -Queue tasks are read only from modules passed to `buildApiPlugin`. Declared on a nested module, such as `admin/notes`, a task is never registered, and its dispatches fail with "No handler registered". +Queue tasks are read only from modules passed to `buildApiPlugin`. Declared on a nested module, such as `admin/notes`, a task is never registered, and dispatching it throws `QueueTaskRegistrationError`. `buildApiPlugin` throws in development to say so, and [`vitnode doctor`](/docs/dev/cli/doctor) reports it. @@ -87,12 +106,13 @@ Queue tasks are read only from modules passed to `buildApiPlugin`. Declared on a ### Dispatch it from a route -Create `src/api/modules/admin/notes/announce.route.ts`. It queues the task and returns what `dispatch()` reported: +Create `src/api/modules/admin/notes/routes/announce.route.ts`. It queues the task and returns what `dispatch()` reported: -```ts title="plugins/site-notes/src/api/modules/admin/notes/announce.route.ts" +```ts title="plugins/site-notes/src/api/modules/admin/notes/routes/announce.route.ts" import { z } from "@hono/zod-openapi"; import { buildRoute } from "@/api/builders"; +import { announceNoteTask } from "@/api/tasks/announce-note.task"; export const announceNoteRoute = buildRoute({ adminStaffPermission: { module: "notes", permission: "can_create" }, @@ -118,11 +138,13 @@ export const announceNoteRoute = buildRoute({ }, handler: async c => { const { id } = c.req.valid("param"); - const { deduplicated, id: taskId } = await c.get("queue").dispatch({ - name: "announce-note", - payload: { noteId: id }, - dedupeKey: `announce-note:${id}`, - }); + const { deduplicated, id: taskId } = await c + .get("queue") + .dispatch( + announceNoteTask, + { noteId: id }, + { dedupeKey: `announce-note:${id}` }, + ); return c.json({ deduplicated, taskId }, 202); }, @@ -131,7 +153,7 @@ export const announceNoteRoute = buildRoute({ Then add `announceNoteRoute` to the `routes` of `adminNotesModule` in `src/api/modules/admin/notes/notes.module.ts`, after `createNoteRoute`. -`dispatch()` only inserts a row and returns `{ deduplicated, id }`. It never runs the handler. The task belongs to the plugin of the route that dispatched it; pass `pluginId` to queue another plugin's task. +`dispatch(task, payload, options)` only inserts a row and returns `{ deduplicated, id }`. It never runs the handler. A missing or mistyped field is a compile error, and a value the schema rejects at runtime throws `QueuePayloadValidationError` before anything is stored. The task's name and plugin come from the definition, so the options cannot change them. @@ -162,7 +184,11 @@ Then add `announceNoteRoute` to the `routes` of `adminNotesModule` in `src/api/m path: "/{id}/announce", args: { params: { id: 3 } }, }); - setResult(response.ok ? JSON.stringify(await response.json()) : String(response.status)); + setResult( + response.ok + ? JSON.stringify(await response.json()) + : String(response.status), + ); } finally { setIsPending(false); } @@ -206,11 +232,11 @@ Then add `announceNoteRoute` to the `routes` of `adminNotesModule` in `src/api/m `availableAt` is the earliest time the task may run. The default is now: ```ts -await c.get("queue").dispatch({ - name: "announce-note", - payload: { noteId: id }, - availableAt: new Date(Date.now() + 60 * 60 * 1000), // in an hour -}); +await c.get("queue").dispatch( + announceNoteTask, + { noteId: id }, + { availableAt: new Date(Date.now() + 60 * 60 * 1000) }, // in an hour +); ``` The task still waits for the next processor run after that time. `priority` (higher first, default `0`) orders tasks that are due at the same time. @@ -227,6 +253,8 @@ throw new QueueDeferError(new Date(Date.now() + 60_000), "Provider rate limit"); A failed task stays failed. Fix the cause and dispatch again. +A stored payload the schema rejects, for example a row queued before the schema changed, fails at once without a retry: no number of attempts can fix it. The row keeps `Invalid payload: …` in `lastError`, naming paths and issue codes but never values. See [Migrate a task to a schema](/docs/dev/advanced/queue-typed-payloads). + ## Leases and abandoned tasks A worker that claims a task holds it for the task's `leaseSeconds`, `600` by default. If the worker dies, or the handler is still running when the lease ends, the task counts as abandoned. It returns to `pending`, or becomes **failed** when it has no attempts left, and another run picks it up. @@ -244,7 +272,7 @@ Pass `tx` to insert the task with the transaction that writes your data, so it i ```ts await c.get("db").transaction(async tx => { const [note] = await tx.insert(site_notes).values(values).returning(); - await c.get("queue").dispatch({ name: "announce-note", payload: { noteId: note.id }, tx }); + await c.get("queue").dispatch(announceNoteTask, { noteId: note.id }, { tx }); }); ``` @@ -254,56 +282,20 @@ Each `process-queue` run claims tasks in batches of 25, runs up to 5 at once, hi **Debug Panel → Queue** lists every task with its queue, status, attempts, next run, creation time and last error, filterable by status. It is read-only and needs the `queue → can_view` staff permission. -## `buildQueueTask` options +## Dispatch by name -) => Promise | void", - }, - maxAttempts: { - default: "3", - description: "Attempts before the task is failed, unless the dispatch sets its own.", - type: "number", - }, - leaseSeconds: { - default: "600", - description: "How long a worker may hold a claimed task before it counts as abandoned.", - type: "number", - }, - description: { - description: "A note for developers. Core does not display it.", - type: "string", - }, - }} -/> +`dispatch({ name, payload })` still queues any task by its name, typed or not, and is the only way to queue a task without a schema. When the registered task has a `payload` schema, the payload is validated against it first. The by-name form defaults to the plugin of the current route; pass `pluginId` to queue another plugin's task. -## `dispatch()` options - -" }, - availableAt: { description: "Earliest time the task may run.", default: "now", type: "Date" }, - dedupeKey: { description: "Drops the dispatch while an active task has the same key.", type: "string" }, - maxAttempts: { description: "Overrides the task's attempt limit.", type: "number" }, - priority: { description: "Higher runs first.", default: "0", type: "number" }, - queue: { description: "Queue name. The default worker drains every queue except `ai`.", default: '"default"', type: "string" }, - pluginId: { description: "Plugin that owns the task.", default: "the current route's plugin", type: "string" }, - tx: { description: "A transaction to insert with.", type: "Transaction" }, - }} -/> +```ts +await c + .get("queue") + .dispatch({ name: "announce-note", payload: { noteId: id } }); +``` -`dispatch()` returns `Promise<{ deduplicated: boolean; id: number | null }>`. +Every option, type and error is listed in the [queue reference](/docs/dev/advanced/queue-reference). ## Related +- [Migrate a task to a schema](/docs/dev/advanced/queue-typed-payloads): add a schema to an existing task without breaking queued rows. - [Cron](/docs/dev/cron): recurring jobs, and the adapters that run the queue. - [Events](/docs/dev/events): react to something that happened, inside the request. diff --git a/apps/web/content/docs/dev/ai/actions.mdx b/apps/web/content/docs/dev/ai/actions.mdx index ee5c7c318b..9e7324fc1f 100644 --- a/apps/web/content/docs/dev/ai/actions.mdx +++ b/apps/web/content/docs/dev/ai/actions.mdx @@ -226,7 +226,7 @@ TypeScript infers `output` as `string` from the action's `outputSchema`. When a Create an `ai` module for the route: -```ts title="plugins/forum/src/api/modules/admin/ai/ai.admin.module.ts" +```ts title="plugins/forum/src/api/modules/admin/ai/ai.module.ts" import { buildModule } from '@/api/builders' import { summarizeTopicRoute } from './routes/summarize.route' @@ -240,7 +240,7 @@ export const aiAdminModule = buildModule({ Then add it to the forum's existing `admin` module: ```ts title="plugins/forum/src/api/modules/admin/admin.module.ts (excerpt)" -import { aiAdminModule } from './ai/ai.admin.module' // [!code ++] +import { aiAdminModule } from './ai/ai.module' // [!code ++] export const adminModule = buildModule({ name: 'admin', diff --git a/apps/web/content/docs/dev/ai/usage.mdx b/apps/web/content/docs/dev/ai/usage.mdx index 624e7b885e..649c340c19 100644 --- a/apps/web/content/docs/dev/ai/usage.mdx +++ b/apps/web/content/docs/dev/ai/usage.mdx @@ -43,7 +43,7 @@ Without an `id`, each resolver returns the first model of its list in the [AI co Generate text in a staff-only API route. This one belongs to the `site-notes` plugin from the [learning path](/docs/dev/working-with-users/staff-permissions); add it to the plugin's admin `notes` module like any other route: -```ts title="plugins/site-notes/src/api/modules/admin/notes/summarize.route.ts" +```ts title="plugins/site-notes/src/api/modules/admin/notes/routes/summarize.route.ts" import { generateText } from 'ai' import { z } from 'zod' diff --git a/apps/web/content/docs/dev/audit-logs/record-plugin-events.mdx b/apps/web/content/docs/dev/audit-logs/record-plugin-events.mdx index 34bdac0b08..7c7ca9f333 100644 --- a/apps/web/content/docs/dev/audit-logs/record-plugin-events.mdx +++ b/apps/web/content/docs/dev/audit-logs/record-plugin-events.mdx @@ -27,7 +27,7 @@ Replace the handler of `createNoteRoute`. Write the entry in the same transaction as the change. A rolled-back insert then leaves no entry behind, and a failed audit write fails the insert instead of quietly losing the trail. -```ts title="plugins/site-notes/src/api/modules/admin/notes/create.route.ts" +```ts title="plugins/site-notes/src/api/modules/admin/notes/routes/create.route.ts" export const createNoteRoute = buildRoute({ adminStaffPermission: { module: 'notes', permission: 'can_create' }, route: { diff --git a/apps/web/content/docs/dev/cache/api.mdx b/apps/web/content/docs/dev/cache/api.mdx index b9658ae4cf..d1f6d9fbee 100644 --- a/apps/web/content/docs/dev/cache/api.mdx +++ b/apps/web/content/docs/dev/cache/api.mdx @@ -76,4 +76,4 @@ The TTL is a safety net, not the invalidation. Without `delete`, visitors see ou 3. Create a note and run the same command. The key is gone until the next call to the stats route. -For every method, including `get`, `set`, `flush` and locks, see the [API cache reference](/docs/dev/cache/reference). +To type-check keys and values and validate what Redis returns, define the value with `defineCacheEntry` instead: see [Typed cache entries](/docs/dev/cache/typed-entries). For every method, including `get`, `set`, `flush` and locks, see the [API cache reference](/docs/dev/cache/reference). diff --git a/apps/web/content/docs/dev/cache/index.mdx b/apps/web/content/docs/dev/cache/index.mdx index fba4907a54..89e53878cb 100644 --- a/apps/web/content/docs/dev/cache/index.mdx +++ b/apps/web/content/docs/dev/cache/index.mdx @@ -8,11 +8,12 @@ VitNode caches in three places. The app keeps API responses in TanStack Query, s ## Pick a layer -| You want to | Use | Guide | -| --------------------------------------------------------------- | -------------------------- | -------------------------------------------- | -| Render SSR data without refetching it, and skip repeat requests | App cache (TanStack Query) | [Cache app data](/docs/dev/cache/app) | -| Stop running the same slow database query for every visitor | API cache (Redis) | [Cache API data](/docs/dev/cache/api) | -| Look up `remember`, `delete`, `flush` and the other methods | API cache (Redis) | [API cache reference](/docs/dev/cache/reference) | +| You want to | Use | Guide | +| --------------------------------------------------------------- | -------------------------- | ---------------------------------------------------- | +| Render SSR data without refetching it, and skip repeat requests | App cache (TanStack Query) | [Cache app data](/docs/dev/cache/app) | +| Stop running the same slow database query for every visitor | API cache (Redis) | [Cache API data](/docs/dev/cache/api) | +| Cache a value with a typed key and a validated shape | API cache (Redis) | [Typed cache entries](/docs/dev/cache/typed-entries) | +| Look up `remember`, `delete`, `flush` and the other methods | API cache (Redis) | [API cache reference](/docs/dev/cache/reference) | | Render a public page once and send that HTML to every visitor | Page cache | [Cache server-rendered pages](/docs/dev/cache/pages) | Most features use both. The API caches the expensive part once, and each visitor's app keeps its own copy of the response. [How it differs from other caches](/docs/dev/cache/pages-invalidation#how-it-differs-from-other-caches) compares all three layers side by side. diff --git a/apps/web/content/docs/dev/cache/meta.json b/apps/web/content/docs/dev/cache/meta.json index 0edd0e5391..2aaca66213 100644 --- a/apps/web/content/docs/dev/cache/meta.json +++ b/apps/web/content/docs/dev/cache/meta.json @@ -6,6 +6,7 @@ "index", "app", "api", + "typed-entries", "pages", "pages-invalidation", "reference", diff --git a/apps/web/content/docs/dev/cache/reference.mdx b/apps/web/content/docs/dev/cache/reference.mdx index 742d1990d9..d361cf3def 100644 --- a/apps/web/content/docs/dev/cache/reference.mdx +++ b/apps/web/content/docs/dev/cache/reference.mdx @@ -8,26 +8,28 @@ icon: List ## Methods -| Method | Returns | Behavior | -| -------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | -| `get(key)` | `Promise` | Reads and parses a value. Returns `null` on a miss or on a Redis error. | -| `set(key, value, ttlSeconds?)` | `Promise` | Stores `value` as JSON. Without `ttlSeconds`, or with `0`, the key never expires. | -| `has(key)` | `Promise` | Whether the key exists. `false` on a Redis error. | -| `remember(key, ttlSeconds, loader)` | `Promise` | Returns the cached value when `get` finds one. Otherwise runs `loader`, stores its result with `set` and returns it. | -| `delete(key)` | `Promise` | Deletes one key, or every key in an array. | -| `flush()` | `Promise` | Deletes every key of the current plugin. Other plugins' keys and unrelated data in the same Redis instance are left untouched. | -| `acquireLock(key, ttlSeconds)` | `Promise` | Takes a lock that expires after `ttlSeconds`. `true` when this call holds the lock, `false` when another caller does. | -| `releaseLock(key)` | `Promise` | Releases a lock taken with `acquireLock`. | -| `status()` | `Promise<{ configured: boolean; connected: boolean }>` | Pings Redis. Both fields are `false` without Redis. | +| Method | Returns | Behavior | +| -------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `get(key)` | `Promise` | Reads and parses a value. Returns `null` on a miss or on a Redis error. | +| `set(key, value, ttlSeconds?)` | `Promise` | Stores `value` as JSON. Without `ttlSeconds`, or with `0`, the key never expires. | +| `has(key)` | `Promise` | Whether the key exists. `false` on a Redis error. | +| `remember(key, ttlSeconds, loader)` | `Promise` | Returns the cached value when `get` finds one. Otherwise runs `loader`, stores its result with `set` and returns it. | +| `delete(key)` | `Promise` | Deletes one key, or every key in an array. | +| `flush()` | `Promise` | Deletes every key and typed entry of the current plugin. Other plugins' keys and unrelated data in the same Redis instance are left untouched. | +| `entry(definition, ...keyArgs)` | `CacheEntryHandle` | A typed entry. See [Typed entries](#typed-entries). | +| `acquireLock(key, ttlSeconds)` | `Promise` | Takes a lock that expires after `ttlSeconds`. `true` when this call holds the lock, `false` when another caller does. | +| `releaseLock(key)` | `Promise` | Releases a lock taken with `acquireLock`. | +| `status()` | `Promise<{ configured: boolean; connected: boolean }>` | Pings Redis. Both fields are `false` without Redis. | `getSystem`, `getSystemMany`, `setSystem` and `deleteSystem` also exist. They write to a namespace shared with VitNode core, so plugins should use the plugin-scoped methods above. ## Keys -| Method | Stored as | -| -------------------------------------------------- | ------------------------------------- | -| `get`, `set`, `has`, `remember`, `delete`, `flush` | `vitnode:cache:{pluginId}:{key}` | -| `acquireLock`, `releaseLock` | `vitnode:cache:__system__:lock:{key}` | +| Method | Stored as | +| -------------------------------------------------- | ------------------------------------------------------------------------------ | +| `get`, `set`, `has`, `remember`, `delete`, `flush` | `vitnode:cache:{pluginId}:{key}` | +| `acquireLock`, `releaseLock` | `vitnode:cache:__system__:lock:{key}` | +| `entry(...)` | `vitnode:cache-entry:{definition pluginId}:{name}:v{version}:{JSON key parts}` | The plugin ID comes from the route that handles the request. Lock keys are not prefixed with it, so include your plugin ID in a lock key, for example `site-notes:rebuild-stats`. @@ -39,6 +41,31 @@ Values go through `JSON.stringify` and `JSON.parse`: - `null` is stored, but `get` returns `null` for it, so `remember` treats it as a miss and runs the loader again. - `undefined` is not stored. +## Typed entries + +`defineCacheEntry` from your plugin's `@/api/builders` (`bindApiPlugin(manifest).defineCacheEntry`) declares an entry; `c.get("cache").entry(definition, ...keyArgs)` reads and writes it. A walkthrough is in [Typed cache entries](/docs/dev/cache/typed-entries). + +| Option | Type | Required | Default | Description | +| ------------ | ---------------------------------------------- | -------- | ---------- | ----------------------------------------------------------------------------------------------- | +| `name` | `string` | Yes | - | Letters, digits, `.`, `_` and `-`, up to 100 characters. Unique within the plugin. | +| `version` | `number` | Yes | - | Whole number, at least 1. Bump it to drop every stored entry. Never derived from the schema. | +| `schema` | `z.ZodType` | Yes | - | Validates writes and reads. Its input must be JSON; `z.date()` is a compile error. | +| `ttlSeconds` | `number` | Yes | - | Whole number, at least 1. Capped at 15 by the in-memory fallback. | +| `key` | `(...args) => (string \| number \| boolean)[]` | No | `() => []` | Builds the key parts from `entry()`'s arguments. At most 8 parts; strings up to 256 characters. | + +| Handle method | Returns | Behavior | +| ------------------ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| `get()` | `Promise` | The schema's output, or `undefined` on a miss. A stored `null`, `false`, `0` or `""` is a hit. | +| `set(value)` | `Promise` | Validates the schema's input and stores its JSON for `ttlSeconds`. Throws `CacheEntryValidationError` when invalid. | +| `remember(loader)` | `Promise` | Returns the parsed hit, or runs `loader`, validates, stores and returns the parsed result. Throws like `set` for an invalid result. | +| `delete()` | `Promise` | Removes the entry. | +| `key` | `string` | The storage key, for logs. | + +- **Keys** belong to the plugin in the definition, not to the route handling the request. Key parts are encoded as a JSON array, so `"1"` and `1` are different keys and no part can collide with a string-API key. +- **Reads** treat malformed JSON, a value the schema rejects, a different `version`, no store and a Redis error as a miss; a rejected value is deleted. +- **Writes** copy the value to JSON first and reject a `Date`, `Map`, class instance, `NaN`, `Infinity`, `bigint`, circular reference, or `undefined` or missing (sparse) array item; `undefined` object properties are omitted. What is stored is that JSON (the schema's input), not the schema's output. +- **Errors:** `CacheEntryValidationError` for an invalid write; `CacheEntryDefinitionError` for an invalid `name`, `version`, `ttlSeconds` or key part. Redis errors are never thrown. + ## Without Redis and when Redis fails When the `redis` option in `vitnode.api.config.ts` is not set, the cache lives in the memory of each API process: diff --git a/apps/web/content/docs/dev/cache/typed-entries.mdx b/apps/web/content/docs/dev/cache/typed-entries.mdx new file mode 100644 index 0000000000..c144543049 --- /dev/null +++ b/apps/web/content/docs/dev/cache/typed-entries.mdx @@ -0,0 +1,118 @@ +--- +title: Typed cache entries +description: Define a VitNode API cache entry with defineCacheEntry - a name, version, key and Zod schema - so reads and writes are type-checked and validated, then expire it after a write. +icon: ShieldCheck +--- + +`defineCacheEntry` declares one kind of cached value in the API: how its key is built, what it holds, and how long it lives. Reads come back typed and validated, writes are checked before they reach Redis, and a stored value that no longer fits is treated as a miss. In this guide the `site-notes` plugin caches the number of notes per author. + +## Before you begin + +- The `site-notes` plugin from the beginner path, with a `countNotesByAuthor(c, authorId)` query in your code. Any slow read works the same way. +- Optional: [Redis](/docs/dev/advanced/redis). Without it the entry lives in a per-process memory store for at most 15 seconds. + + + + +### Bind `defineCacheEntry` to your plugin + +Add it to the builders in `src/api/builders.ts`: + +```ts title="plugins/site-notes/src/api/builders.ts" +export const buildModule = apiBuilders.buildModule +export const buildRoute = apiBuilders.buildRoute +export const defineCacheEntry = apiBuilders.defineCacheEntry // [!code ++] +``` + +The bound version stores every entry under `site-notes`, whichever plugin's route reads it, so a listener running in a core request reaches the same key. + + + + +### Define the entry + +Create `src/api/cache/author-stats.cache.ts`: + +```ts title="plugins/site-notes/src/api/cache/author-stats.cache.ts" +import { z } from 'zod' + +import { defineCacheEntry } from '@/api/builders' + +export const authorStatsCache = defineCacheEntry({ + name: 'author-stats', + version: 1, + key: (authorId: number) => [authorId], + schema: z.object({ + notes: z.number().int().nonnegative(), + lastNoteAt: z.iso.datetime().nullable(), + }), + ttlSeconds: 300, +}) +``` + +- **`key`** turns the entry's arguments into key parts. Its parameters become the arguments of `entry()`, so `authorId` must be a number wherever the entry is used. Omit `key` for an entry with a single value. +- **`schema`** checks every write and every read. Values are stored as JSON, so its input must be JSON: `z.iso.datetime()` instead of `z.date()`. +- **`version`** is yours to bump. Change it when the stored shape or its meaning changes, and every old entry becomes a miss at once. + + + + +### Read through the entry + +In the route handler, wrap the query in `remember`: + +```ts title="plugins/site-notes/src/api/modules/notes/routes/author-stats.route.ts" +import { authorStatsCache } from '@/api/cache/author-stats.cache' + +// in the handler: +const { authorId } = c.req.valid('param') +const stats = await c + .get('cache') + .entry(authorStatsCache, authorId) + .remember(async () => await countNotesByAuthor(c, authorId)) + +return c.json(stats, 200) +``` + +On a hit, `remember` returns the stored value parsed by the schema. On a miss, it runs the loader, validates the result, stores it for 300 seconds and returns the parsed value. Both paths return the same type. A loader result the schema rejects throws `CacheEntryValidationError` and nothing is stored, so a bug in the query surfaces instead of being cached. + + + + +### Expire the entry after a write + +In every route that creates or deletes a note, delete the author's entry after the write succeeds: + +```ts title="plugins/site-notes/src/api/modules/admin/notes/routes/create.route.ts" +const note = await createNote(c, c.req.valid('json')) +await c.get('cache').entry(authorStatsCache, note.authorId).delete() // [!code ++] +``` + +The TTL is a safety net. Without `delete`, the old count stays for up to five minutes. + + + + +## Check the result + +1. Call the stats route for an author twice. With Redis connected, list the key: + + ```bash + redis-cli --scan --pattern 'vitnode:cache-entry:site-notes:*' + ``` + + The output includes `vitnode:cache-entry:site-notes:author-stats:v1:[1]` for author `1`. + +2. Create a note for that author and run the command again. The key is gone until the next call. +3. Change `version` to `2` and call the route. A new `…:v2:[1]` key appears; the `v1` key is never read again and expires on its TTL. + +## What counts as a miss + +`get()` returns `undefined` and `remember()` runs the loader when nothing is stored, when there is no cache store, when Redis fails, or when the stored value is malformed JSON or rejected by the schema. A rejected value is deleted on the way. A stored `null`, `false`, `0` or `""` is a hit, unlike the string API's `remember`, which reloads `null`. + +The value is stored as the JSON of what you passed in, the schema's input, and parsed on every read. Keys that the schema strips are stored too, so return only the fields you want in Redis. + +## Related + +- [API cache reference](/docs/dev/cache/reference#typed-entries): every option, key and error. +- [Cache API data](/docs/dev/cache/api): the string-keyed API. diff --git a/apps/web/content/docs/dev/captcha/custom-adapter.mdx b/apps/web/content/docs/dev/captcha/custom-adapter.mdx index 49c5f3c321..a902c3738b 100644 --- a/apps/web/content/docs/dev/captcha/custom-adapter.mdx +++ b/apps/web/content/docs/dev/captcha/custom-adapter.mdx @@ -121,7 +121,7 @@ Nothing on the client makes a route require a token. `withCaptcha: true` on the route does, in a [plugin](/docs/dev/plugins/create) exactly as in core. This is the route from the overview, trimmed to the relevant part: -```ts title="plugins/site-notes/src/api/modules/contact/send.route.ts" +```ts title="plugins/site-notes/src/api/modules/contact/routes/send.route.ts" import { buildRoute } from '@/api/builders' export const sendContactRoute = buildRoute({ @@ -236,7 +236,7 @@ hook exists. - + diff --git a/apps/web/content/docs/dev/captcha/index.mdx b/apps/web/content/docs/dev/captcha/index.mdx index eff64942bc..f2f46a44a5 100644 --- a/apps/web/content/docs/dev/captcha/index.mdx +++ b/apps/web/content/docs/dev/captcha/index.mdx @@ -42,7 +42,7 @@ Registration, password reset and the resend-confirmation form are protected stra Add `withCaptcha: true` to the route definition. This `site-notes` route takes a message for the site owner: -```ts title="plugins/site-notes/src/api/modules/contact/send.route.ts" +```ts title="plugins/site-notes/src/api/modules/contact/routes/send.route.ts" import { z } from "@hono/zod-openapi" import { buildRoute } from "@/api/builders" diff --git a/apps/web/content/docs/dev/cli/doctor.mdx b/apps/web/content/docs/dev/cli/doctor.mdx new file mode 100644 index 0000000000..9ef1db09e1 --- /dev/null +++ b/apps/web/content/docs/dev/cli/doctor.mdx @@ -0,0 +1,121 @@ +--- +title: Doctor and inspect +description: Check VitNode plugins for registration, permission and configuration problems with vitnode doctor, and list their routes, jobs and event listeners with vitnode inspect. +icon: Stethoscope +--- + +`vitnode doctor` finds the mistakes that compile fine but break at runtime: a module nobody registered, a route guarded by a permission nobody can be granted, two routes on the same URL, a cron job that never runs. `vitnode inspect` lists what each plugin actually registers. Both read source and build output only: no server starts, no job runs and no database is needed. + +Run them inside an app to cover every plugin it configures plus the workspace's other plugins, or inside a plugin for just that one. Name one plugin by id, package name or folder: `pnpm vitnode doctor site-notes`. + +## Check plugins + +```bash +pnpm vitnode doctor +``` + +```txt +◆ VitNode + Doctor + + site-notes ../../plugins/site-notes + ✓ Package 0.1.0 + ✓ Plugin definition + ✓ API definition + ✓ Routes 1 route + ✓ Translations en + ! API registration + Module orphanModule (src/api/modules/orphan/orphan.module.ts) is not registered in any modules array. + ✓ Fetcher registry + ✓ App registration src/vitnode.api.config.ts + ✖ Staff permissions + POST /site-notes/admin/notes requires the admin permission notes.can_publish, which the plugin does not declare in permissionStaff.admin. + ✓ Endpoints 4 routes + ✖ Background registrations + cronJobs on nested module "admin/notes" (cleanup) never run: move them to the top-level module "admin". + + 8 checks passed, 1 warning, 2 failed, 0 skipped. + +✖ 1 plugin with problems: site-notes +``` + +The first checks are the ones [`vitnode plugin validate`](/docs/dev/cli/plugins#validate-plugins) runs, with the same rules. Doctor adds: + +| Check | Reads | Fails or warns when | +| ------------------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| API registration | Source | Warns: a `buildModule` or `buildRoute` export under `src/api/modules` is not reached from `config.api.ts` | +| Fetcher registry | Source | Fails: `config.api.ts` does not export `type VitNodeApiPlugin`. Warns: `types/api-registry.gen.d.ts` is missing or not in `tsconfig.json` | +| App registration | Source | Fails: the app lists the plugin in `vitnode.config.ts` but no `vitnode.api.config.ts` imports `/config.api`. Warns the other way round | +| Staff permissions | Build output | Fails: a route's `adminStaffPermission` or `moderatorStaffPermission` is not declared. Warns: a route under `admin/` has no `adminStaffPermission` | +| Endpoints | Build output | Fails: two routes answer the same method and path; only the first one is reachable | +| Background registrations | Build output | Fails: cron jobs, events, queue tasks or WebSockets sit on a nested module, where they never run | + +A check that cannot run says so: without build output, **Build output** fails with "Run vitnode build in the plugin's folder", the definition checks are not run, and the four build-output checks are `skipped`. Nothing unchecked is reported as passing. When the source has entries the generator cannot follow, such as a spread in `modules`, **API registration** warns that its list may be incomplete. + +`vitnode doctor` exits with code `1` when any check fails, so it can gate CI after `pnpm build:plugins`. + +### Background work on nested modules + +`buildApiPlugin` collects content types and notification types from the whole module tree, but cron jobs, event listeners, queue tasks and WebSockets only from the modules passed to it directly. Production keeps ignoring nested ones, as it always has. In development (`NODE_ENV=development`), `buildApiPlugin` now throws with the same message doctor prints, so the mistake shows up the first time `pnpm dev` loads the API. + +### When the plugin's factory throws + +Doctor, `vitnode inspect` and `vitnode plugin validate` call the function that `config.api.ts` exports to read the plugin. When that call throws, as it does for nested background work in development, they print the original error under the call that threw: + +```txt + ✖ API definition + dist/src/config.api.js: calling the exported function siteNotesApiPlugin() threw, so no buildApiPlugin({ manifest, ... }) definition could be read: + Plugin "site-notes" declares background work on nested modules. buildApiPlugin only collects it from top-level modules: + - cronJobs on nested module "hello/jobs" (cleanup) never run: move them to the top-level module "hello". + ... + Staff permissions (skipped) + API definition failed - see above. + Endpoints (skipped) + API definition failed - see above. + Background registrations (skipped) + API definition failed - see above. +``` + +Doctor reports the error once, under **API definition**. **Staff permissions**, **Endpoints** and **Background registrations** need the definition, so they are skipped and point back to it. Fix the error it quotes and build again. A file that exports no factory at all fails the same way, with `dist/src/config.api.js exports no buildApiPlugin({ manifest, ... }) factory` instead. + +## List what plugins register + +```bash +pnpm vitnode inspect routes site-notes +``` + +```txt + site-notes ../../plugins/site-notes + Method Path Staff permission + ────────────────────────────────────────────────────────── + GET /site-notes/hello - + GET /site-notes/notes - + POST /site-notes/admin/notes admin notes.can_create +``` + +| Command | Lists | +| ------------------------ | ------------------------------------------------------------------------------------------------------- | +| `vitnode inspect routes` | Every route's method, path and declared staff permission | +| `vitnode inspect jobs` | Cron jobs with their schedule, and queue tasks with whether a schema validates their payload, by module | +| `vitnode inspect events` | Event listeners by event, with whether they are built from an event definition, and WebSockets by id, by module | + +Paths include the plugin id and sit under the app's API base path, `/api` in the starter apps. Generated Content Engine routes are listed too, with the permissions the engine assigned. + +The **Staff permission** column shows what `buildRoute` was given in `adminStaffPermission` and `moderatorStaffPermission`. It is not proof of everything a route checks: a `checkStaffPermission` call or an ownership rule inside the handler, or a custom middleware, is invisible here. + +Both commands read each plugin's `dist` folder, so build plugins first (`pnpm build:plugins`, or `vitnode dev` running). They print names, schedules, paths and permission keys only: never environment values, secrets or request data. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/cli/generate.mdx b/apps/web/content/docs/dev/cli/generate.mdx new file mode 100644 index 0000000000..10696edb6d --- /dev/null +++ b/apps/web/content/docs/dev/cli/generate.mdx @@ -0,0 +1,202 @@ +--- +title: Generate commands +description: Reference for vitnode generate - scaffold plugin API modules, typed routes, admin routes, services and API resources, with dry runs, collision checks and explicit registration. +icon: Wand +--- + +`vitnode generate` (alias `vitnode g`) writes plugin API code and wires it into the plugin's explicit registrations: a module into `buildApiPlugin({ modules })` or its parent's `modules`, a route into its module's `routes`. It edits your files in place and never discovers files at runtime, so what you see in `src/config.api.ts` stays the whole truth. + +| Command | Creates | Guide | +| -------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------- | +| `vitnode g module ` | `.module.ts`, registered in `config.api.ts` or a parent | [Generate a module](/docs/dev/plugins/api/generate-module) | +| `vitnode g route /` | `routes/.route.ts`, added to the module's `routes` | [Generate an endpoint](/docs/dev/plugins/api/generate-route) | +| `vitnode g route admin/...` | The same, guarded by a staff permission | [Protect an admin endpoint](/docs/dev/plugins/api/admin-routes) | +| `vitnode g service ` | `.service.ts`, an empty service factory | [Add a service](/docs/dev/plugins/api/services) | +| `vitnode g resource --kind api` | A module, schemas, service stubs and five CRUD routes in `routes/` | [Generate an API resource](/docs/dev/plugins/api/resources) | +| `vitnode g resource --kind content` | An administrative Content Engine content type, wired in | [Generate a content type](/docs/content-engine/generate) | + +## Where to run it + +Run it inside the plugin, through the plugin's own `vitnode`: + +```bash +cd plugins/site-notes +pnpm vitnode g module notes +``` + +The workspace root does not depend on `@vitnode/core`, so `pnpm vitnode` is not found there. From the root, point pnpm at the plugin: + +```bash +pnpm --filter site-notes exec vitnode g module notes +``` + +### Which plugin it writes to + +| You run it | Target | +| ---------------------------------------- | -------------------------------------------------------------------- | +| With `--project ` | The workspace plugin with exactly that package name, or that folder | +| Inside a plugin | That plugin | +| Anywhere else in the workspace (an app) | The only plugin with a `src/config.api.ts`, or a choice when there are several | + +With several candidates and no terminal to ask (CI, a script), the command stops with exit code `2` and lists them. Only plugins are supported targets for now: a plugin needs `src/config.api.ts` and the bound builders in `src/api/builders.ts`, which `create-vitnode-app --plugin` writes. When `builders.ts` is missing but `src/manifest.gen.ts` exists, the generator creates it. + +## Options + +Every generate command accepts: + +| Option | Description | +| ----------------------- | --------------------------------------------------------------------- | +| `--project ` | The plugin to write into, by exact package name or folder | +| `--dry-run` | Print the full diff of every planned change and write nothing | +| `--plain`, `--verbose` | Output style, as for every command. See [Output](/docs/dev/cli/output) | + +`vitnode g module `: + +| Option | Description | +| ----------- | ----------------------------------------------------------------- | +| `--admin` | Create it in the admin module tree: `notes` becomes `admin/notes` | +| `--parents` | Also create missing parent modules | + +`vitnode g route /`: + +| Option | Default | Description | +| ------------------------------------ | --------------- | --------------------------------------------------------------------------- | +| `-m, --method ` | From the name | `get`, `post`, `put`, `patch` or `delete` | +| `--path ` | From the name | Path relative to the module, in OpenAPI syntax: `/`, `/{id}`, `/{id}/archive` | +| `--id-type ` | `string` | Schema of every path parameter: `string`, or `number` for positive integers | +| `--admin` | - | Put the route in the admin module tree | +| `--permission ` | - | Staff permission that guards an admin route, e.g. `notes.can_create` | +| `--declare-permission` | - | Declare that permission in `config.api.ts` (and label it) when it is missing | +| `--parents` | - | Also create missing modules | + +`vitnode g service ` takes `--admin` with the same meaning as for modules. + +`vitnode g resource --kind api`: + +| Option | Default | Description | +| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------- | +| `--kind ` | - | Required. `api`, or `content` (below) | +| `--fields ` | Asked, or none | `name:type` pairs, comma-separated: `string`, `number`, `int`, `boolean`; `name?:type` is optional | +| `--id-type ` | `string` | `string` or `number` | +| `--admin` | - | Generate it in the admin module tree | +| `--permission-module ` | Asked; required when nothing can prompt | Admin resources: `can_view` guards list and show, `can_create`, `can_edit` and `can_delete` the rest | +| `--declare-permission` | - | Declare the missing permissions and their labels | +| `--parents` | - | Also create missing parent modules | + +The resource's module must be new or have no routes. No table, migration or storage is generated: every operation answers `501` until you implement the service. + +`vitnode g resource --kind content` takes `--fields` (required; types `text`, `richText`, `boolean`, `int`, `number`), `--id-type` (`serial`, `uuid` or `bigint`; default `serial`), `--content-id`, `--table` and `--singular`. It generates an administrative content type only - no public API, member writes or publication - and never writes or applies a migration. It declares no permissions: the Content Engine adds `can_view`, `can_create`, `can_edit` and `can_delete` to the type's permission module, but only when the plugin does not declare that module itself. A module the plugin declares with only some of the four stops the command with the lines to add. See [Generate a content type](/docs/content-engine/generate). + +### Route name defaults + +Every route gets its own file in the module's `routes/` folder: `notes/list` writes `src/api/modules/notes/routes/list.route.ts`, and `notes.module.ts` imports it from `./routes/list.route`. The route's file name and its URL are separate. These names imply a method and a path; any other name needs `--method` and `--path` (or answers them in an interactive terminal), because `archive.route.ts` is not served at `/archive` unless you say so. + +| Name | Method | Path | +| -------- | -------- | ------- | +| `list` | `GET` | `/` | +| `show` | `GET` | `/{id}` | +| `create` | `POST` | `/` | +| `update` | `PATCH` | `/{id}` | +| `delete` | `DELETE` | `/{id}` | + +### What a generated route does + +- **`GET`** answers `200` with sample data, `{ "sample": true, "message": "..." }` plus any path parameters, and a schema that matches it exactly. Replace both. +- **`POST`, `PUT` and `PATCH`** validate their path parameters and a JSON body (an empty `z.object({})` for you to fill in); **`DELETE`** validates only its path parameters. All of them answer `501 Not Implemented` and never pretend a write succeeded. +- **Admin routes** add `adminStaffPermission` and a `403` response. The permission must be declared (or added with `--declare-permission`); when the plugin's `admin` module mounts Content Engine modules and the permission module is undeclared, `g route` only warns, because a content type may generate it. `g resource --admin` always requires declared permissions. A staff permission decides who may call the route; it does not check access to a particular record. Do that in the handler when it matters. + +## Safety rules + +- **Dry run first.** `--dry-run` resolves, validates and formats everything, then prints the exact diff instead of writing. +- **No overwrites.** An existing file, an identifier already declared in the target file, or a module name already registered stops the command before anything is written. There is no `--force`. +- **No duplicate endpoints anywhere in the plugin.** A new route is checked against every route the plugin's module tree reaches, not only the target module, and against the other routes of the same command, such as a resource's five. Full paths are joined the way Hono mounts modules, so a `notes` route `GET /drafts` and a `notes/drafts` route `GET /` are both `GET /notes/drafts`. Parameter names don't count (`/{id}` and `/{noteId}` are the same route), but a trailing slash does, because routing is strict. The error names the method and path, the route that already answers it with its file and module, and where the new one would go: + + ```txt + ✖ GET /notes/drafts already exists. + listDraftsRoute in src/api/modules/notes/drafts/routes/list.route.ts (module "notes/drafts") answers GET /notes/drafts. + Destination: showDraftsNotesRoute in src/api/modules/notes/routes/show-drafts.route.ts (module "notes", path /drafts). + Nothing was written. + ``` + + A module registered under several parents is checked at every mount: if `first` and `second` both list `notesModule`, its `GET /items` is both `GET /first/notes/items` and `GET /second/notes/items`, and a new route on either path is refused. + + Parts of the tree the generator cannot read, such as the Content Engine modules under `/admin/content`, a module entry it cannot resolve, or a module that mounts itself through its own children, are not checked. A new route under one of them is still written, with a warning that says the check is incomplete. +- **Names are kept, not rewritten.** Module and route names are lower-case kebab-case, because a module name is its URL segment. `myNotes` is refused with a suggestion (`my-notes`); `..`, absolute paths and backslashes are refused outright. +- **Edits are surgical.** The generator parses your files and inserts text at exact positions, so comments, options and the `VitNodeApiPlugin` type stay as they were. New imports go in alphabetical order within their group. +- **Formatting follows the project.** Generated files are formatted with the project's own oxfmt and config. A file you already edited is formatted only if it was already formatted, so the diff never touches code the generator did not add. +- **Writes are checked and rolled back.** Every file is re-read just before writing; if one changed meanwhile, nothing is written. If a write fails halfway, the files already written are restored. A crash in the middle of the final renames can still leave some files written, which is why the summary lists them all. + +## Supported source patterns + +The generator reads source, never runs it. It follows these patterns: + +```ts title="src/config.api.ts" +buildApiPlugin({ + manifest, + modules: [notesModule, adminModule], // an array literal + permissionStaff: { admin: { notes: ["can_create"] } }, // object and array literals +}); +``` + +```ts title="src/api/modules/admin/admin.module.ts" +import { adminNotesModule } from "./notes/notes.module"; // imported with @/ or a relative path + +export const adminModule = buildModule({ + name: "admin", // a string literal + routes: [], // an array literal + modules: [adminNotesModule], +}); +``` + +`permissionStaff` may also be `staffPermissions.config`, where `staffPermissions` is a `defineStaffPermissions({...})` call in the plugin. The generator then reads and declares permissions in that call, and generated routes use typed references such as `staffPermissions.admin.notes.canCreate`. See [Typed permission references](/docs/dev/working-with-users/staff-permission-refs). + +Module and route entries must be identifiers that resolve to a `const x = buildModule({...})` or `buildRoute({...})` in the plugin. `buildContentAdminModule(...)` and `buildContentPublicModule(...)` are recognized as the generated `content` module. + +Anything else - a spread inside the array, a variable holding the array, a module from another package - stops the command before writing and prints the exact lines to add by hand. A route whose method or path cannot be read is reported as a warning, since the collision check could not cover it. + +### Which property the generator edits + +An object literal can name the same property twice or merge in a spread, and JavaScript keeps the value that comes last. The generator reads and edits only that effective property. This applies to `modules`, `routes`, `permissionStaff`, its `admin` entry and each module entry in it, and the `contentTypes` of `buildContentAdminModule(...)` and `adminNav`: + +| Source | What the generator does | +| ----------------------------------------------------------------------------- | ----------------------------------------- | +| The property written twice | Edits the last one, which is the one that runs | +| The property after a spread or an unknown computed key: `{ ...shared, modules: [...] }` | Edits it: nothing after it can replace it | +| A string-literal computed key: `{ ["modules"]: [...] }` | Reads it as `modules` | +| A spread of an object literal without the key: `{ ...{ manifest }, modules: [...] }` | Ignores the spread | +| A spread or an unknown computed key after the property: `{ modules: [...], ...shared }` | Stops: the spread may replace it | +| A getter, setter or method with that name | Stops: its value comes from running code | +| No such property, but a spread or an unknown computed key in the object | Stops: the spread may supply it, and adding the property would override or be overridden by it | + +When it stops, it says which line decides and what to change by hand: + +```txt +✖ The modules of buildApiPlugin cannot be read or edited safely. + At src/config.api.ts:15: a spread after modules may replace it. + Nothing was written. + To wire it by hand: + Create notes/notes.module.ts next to the parent module (src/api/modules/ for a top-level one): + export const notesModule = buildModule({ name: "notes", routes: [] }); + Import notesModule in src/config.api.ts and add it to buildApiPlugin({ modules }). +``` + +## After generating + +- **Types.** The plugin's own pages see new routes at once: its `types/api-registry.gen.d.ts` imports `VitNodeApiPlugin` from `src/config.api.ts`, and nothing in it needs regenerating. An app sees them once the plugin's declarations are rebuilt, which `vitnode dev` does on save; otherwise run `vitnode build` in the plugin. See [How the fetcher knows your routes](/docs/dev/plugins/api/modules#how-the-fetcher-knows-your-routes). +- **URLs.** A plugin is mounted at `/{plugin id}` under the app's API base path, `/api` in the starter apps, so `notes/list` in `site-notes` answers `GET /api/site-notes/notes`. +- **Checks.** Run `vitnode plugin validate` after a build to check the plugin the way an app loads it. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/cli/index.mdx b/apps/web/content/docs/dev/cli/index.mdx index 4df40b9bb6..7b0ba4a66d 100644 --- a/apps/web/content/docs/dev/cli/index.mdx +++ b/apps/web/content/docs/dev/cli/index.mdx @@ -1,6 +1,6 @@ --- title: CLI -description: The vitnode command runs, builds and starts VitNode apps, validates plugins, manages database migrations and translations. +description: The vitnode command runs, builds and starts VitNode apps, generates plugin API code, validates plugins, manages database migrations and translations. icon: SquareTerminal --- @@ -24,7 +24,7 @@ npx vitnode dev -Run it inside an app, an API app or a plugin folder, through your package manager. The workspace root of a monorepo does not depend on `@vitnode/core`, so `vitnode` is not found there. Generated projects already call it from their `package.json` scripts, so `pnpm dev` and `pnpm start` run `vitnode dev` and `vitnode start` for you. +Run it inside an app, an API app or a plugin folder, through your package manager. The workspace root of a monorepo does not depend on `@vitnode/core`, so `vitnode` is not found there; use `pnpm --filter exec vitnode ...` to run it in a package from the root. Generated projects already call it from their `package.json` scripts, so `pnpm dev` and `pnpm start` run `vitnode dev` and `vitnode start` for you. ## Commands @@ -35,6 +35,9 @@ Run it inside an app, an API app or a plugin folder, through your package manage | `vitnode start` | Start the production server | [Build and start](/docs/dev/cli/build) | | `vitnode plugin list` | List the plugins your app uses | [Plugins](/docs/dev/cli/plugins) | | `vitnode plugin validate` | Check plugins the way your app will load them | [Plugins](/docs/dev/cli/plugins) | +| `vitnode generate ` | Scaffold plugin API modules, routes, resources | [Generate](/docs/dev/cli/generate) | +| `vitnode doctor` | Find registration and permission problems | [Doctor](/docs/dev/cli/doctor) | +| `vitnode inspect ` | List routes, jobs or event listeners | [Doctor](/docs/dev/cli/doctor) | | `vitnode db generate` | Write a migration for schema changes | [Database](/docs/dev/cli/database) | | `vitnode db migrate` | Apply pending migrations and seed initial data | [Database](/docs/dev/cli/database) | | `vitnode db status` | Show the connection and what is pending | [Database](/docs/dev/cli/database) | @@ -60,7 +63,7 @@ In a Turborepo workspace the root has its own scripts that run these through Tur To create a plugin, use the project generator: `create-vitnode-app --plugin`. See [Create a plugin](/docs/dev/plugins/create). -Run `vitnode` alone (or `vitnode --help`) for a short overview, `vitnode --help` for a command's options, and `vitnode --version` for the installed `@vitnode/core` version. +Run `vitnode` alone (or `vitnode --help`) for a short overview of every command above, with examples such as `vitnode g route notes/list --dry-run` and `vitnode doctor`, `vitnode --help` for a command's options, and `vitnode --version` for the installed `@vitnode/core` version. ## How the CLI picks your project diff --git a/apps/web/content/docs/dev/cli/meta.json b/apps/web/content/docs/dev/cli/meta.json index 89974ce14c..9ae1f14ca6 100644 --- a/apps/web/content/docs/dev/cli/meta.json +++ b/apps/web/content/docs/dev/cli/meta.json @@ -1,6 +1,6 @@ { "title": "CLI", - "description": "Run, build and start VitNode apps, validate plugins and manage migrations with the vitnode command.", + "description": "Run, build and start VitNode apps, generate plugin API code, validate plugins and manage migrations with the vitnode command.", "icon": "SquareTerminal", "pages": [ "index", @@ -8,6 +8,8 @@ "build", "bundle-size", "plugins", + "generate", + "doctor", "database", "ci", "output" diff --git a/apps/web/content/docs/dev/cron/index.mdx b/apps/web/content/docs/dev/cron/index.mdx index 678221d604..96f1e18d8c 100644 --- a/apps/web/content/docs/dev/cron/index.mdx +++ b/apps/web/content/docs/dev/cron/index.mdx @@ -56,8 +56,8 @@ Add the job to the `cronJobs` array of a module passed to `buildApiPlugin({ modu import { buildModule } from '@/api/builders' import { countNotesCron } from '@/api/cron/count-notes.cron' // [!code ++] -import { listNotesRoute } from './list.route' -import { showNoteRoute } from './show.route' +import { listNotesRoute } from './routes/list.route' +import { showNoteRoute } from './routes/show.route' export const notesModule = buildModule({ name: 'notes', @@ -68,7 +68,7 @@ export const notesModule = buildModule({ Keep any other keys your module already has, such as `queueTasks` or `events`. -Only top-level modules are read. A job on a nested module, such as `admin/notes`, is never registered. +Only top-level modules are read. A job on a nested module, such as `admin/notes`, is never registered: `buildApiPlugin` throws in development to say so, and [`vitnode doctor`](/docs/dev/cli/doctor) reports it. diff --git a/apps/web/content/docs/dev/database/index.mdx b/apps/web/content/docs/dev/database/index.mdx index b463e3aa64..d4c6588fed 100644 --- a/apps/web/content/docs/dev/database/index.mdx +++ b/apps/web/content/docs/dev/database/index.mdx @@ -83,17 +83,17 @@ The notes get the ids `1`, `2` and `3` in that order, which later lessons use. W ### Read the table in the list route -Replace the handler of `src/api/modules/notes/list.route.ts`, and its imports. Select only the columns the response schema declares: +Replace the handler of `src/api/modules/notes/routes/list.route.ts`, and its imports. Select only the columns the response schema declares: -```ts title="plugins/site-notes/src/api/modules/notes/list.route.ts" +```ts title="plugins/site-notes/src/api/modules/notes/routes/list.route.ts" import { z } from "@hono/zod-openapi"; import { desc } from "drizzle-orm"; // [!code ++] import { buildRoute } from "@/api/builders"; import { site_notes } from "@/database/notes"; // [!code ++] -import { zodNote } from "./notes.schema"; -import { sampleNotes } from "./sample-notes"; // [!code --] +import { sampleNotes } from "../lib/sample-notes"; // [!code --] +import { zodNote } from "../notes.schema"; export const listNotesRoute = buildRoute({ route: { @@ -126,13 +126,14 @@ export const listNotesRoute = buildRoute({ ### Read the table in the detail route -Replace the handler of `src/api/modules/notes/show.route.ts` the same way. The params and responses stay as they are: +Replace the handler of `src/api/modules/notes/routes/show.route.ts` the same way. The params and responses stay as they are: -```ts title="plugins/site-notes/src/api/modules/notes/show.route.ts" +```ts title="plugins/site-notes/src/api/modules/notes/routes/show.route.ts" import { eq } from "drizzle-orm"; // [!code ++] import { site_notes } from "@/database/notes"; // [!code ++] -import { sampleNotes } from "./sample-notes"; // [!code --] + +import { sampleNotes } from "../lib/sample-notes"; // [!code --] // [!code ++:11] handler: async c => { @@ -148,7 +149,7 @@ import { sampleNotes } from "./sample-notes"; // [!code --] }, ``` -Then delete `src/api/modules/notes/sample-notes.ts`; nothing imports it any more. +Then delete `src/api/modules/notes/lib/sample-notes.ts`; nothing imports it any more. diff --git a/apps/web/content/docs/dev/database/pagination.mdx b/apps/web/content/docs/dev/database/pagination.mdx index 3620294d75..f245ea1802 100644 --- a/apps/web/content/docs/dev/database/pagination.mdx +++ b/apps/web/content/docs/dev/database/pagination.mdx @@ -21,9 +21,9 @@ When a list needs numbered pages, the 1, 2, 3 … 38 kind that admin tables show ### Add a paginated API route -Create `src/api/modules/notes/paged.route.ts`. Validate the query with `zodPaginationQuery`, describe the response with `zodPaginationPageInfo`, and let `withPagination` build the page: +Create `src/api/modules/notes/routes/paged.route.ts`. Validate the query with `zodPaginationQuery`, describe the response with `zodPaginationPageInfo`, and let `withPagination` build the page: -```ts title="plugins/site-notes/src/api/modules/notes/paged.route.ts" +```ts title="plugins/site-notes/src/api/modules/notes/routes/paged.route.ts" import { z } from '@hono/zod-openapi' import { withPagination, @@ -34,7 +34,7 @@ import { import { buildRoute } from '@/api/builders' import { site_notes } from '@/database/notes' -import { zodNote } from './notes.schema' +import { zodNote } from '../notes.schema' export const pagedNotesRoute = buildRoute({ route: { @@ -367,7 +367,7 @@ Open `/site-notes/browse`. The page lists up to 10 notes, newest first. With mor `withPagination` filters with `search` (text columns matched case-insensitively against `?search=`) and `where` (any extra condition). Neither is in `zodPaginationQuery`, so extend the query schema with the parameters your route accepts: -```ts title="plugins/site-notes/src/api/modules/notes/paged.route.ts (excerpt)" +```ts title="plugins/site-notes/src/api/modules/notes/routes/paged.route.ts (excerpt)" import { gte } from 'drizzle-orm' // in route.request diff --git a/apps/web/content/docs/dev/database/search.mdx b/apps/web/content/docs/dev/database/search.mdx index 95afd345f6..8ed55e89f7 100644 --- a/apps/web/content/docs/dev/database/search.mdx +++ b/apps/web/content/docs/dev/database/search.mdx @@ -22,7 +22,7 @@ icon: Search `zodPaginationQuery` has no `search` key, so extend it in the route's `request`: -```ts title="plugins/site-notes/src/api/modules/notes/paged.route.ts (excerpt)" +```ts title="plugins/site-notes/src/api/modules/notes/routes/paged.route.ts (excerpt)" request: { query: zodPaginationQuery.extend({ search: z.string().max(100).optional(), // [!code ++] @@ -37,7 +37,7 @@ request: { Pass the columns in `search`. Keep everything else from the pagination route, including `offset`: -```ts title="plugins/site-notes/src/api/modules/notes/paged.route.ts (excerpt)" +```ts title="plugins/site-notes/src/api/modules/notes/routes/paged.route.ts (excerpt)" const data = await withPagination({ c, params: { query: c.req.valid('query') }, diff --git a/apps/web/content/docs/dev/debugging/logging.mdx b/apps/web/content/docs/dev/debugging/logging.mdx index 2f9949093e..3a344f3c10 100644 --- a/apps/web/content/docs/dev/debugging/logging.mdx +++ b/apps/web/content/docs/dev/debugging/logging.mdx @@ -16,7 +16,7 @@ server's stdout. No setup, no adapter, no configuration. `c.get('log')` is available in any handler that has a Hono context - a route, a middleware, a cron job, a queue task, an event listener: -```ts title="plugins/site-notes/src/api/modules/hello/hello.route.ts" +```ts title="plugins/site-notes/src/api/modules/hello/routes/hello.route.ts" import { z } from '@hono/zod-openapi' import { buildRoute } from '@/api/builders' diff --git a/apps/web/content/docs/dev/email/index.mdx b/apps/web/content/docs/dev/email/index.mdx index e14184ff2c..e678c53a27 100644 --- a/apps/web/content/docs/dev/email/index.mdx +++ b/apps/web/content/docs/dev/email/index.mdx @@ -10,7 +10,7 @@ VitNode provides a unified transactional email service accessible via `c.get("em Once an [adapter](#configuring-email-adapters) is configured, any route can email the signed-in member: -```ts title="plugins/site-notes/src/api/modules/welcome/send.route.ts" +```ts title="plugins/site-notes/src/api/modules/welcome/routes/send.route.ts" import { HTTPException } from "hono/http-exception"; import { buildRoute } from "@/api/builders"; diff --git a/apps/web/content/docs/dev/email/templates.mdx b/apps/web/content/docs/dev/email/templates.mdx index 6096c9551f..0b70f91564 100644 --- a/apps/web/content/docs/dev/email/templates.mdx +++ b/apps/web/content/docs/dev/email/templates.mdx @@ -115,9 +115,9 @@ export default function NewNoteEmail({ note, user, ...props }: NewNoteEmailProps ### Send it from a route -Create `src/api/modules/admin/notes/email.route.tsx`. The `.tsx` extension lets the handler render the template with JSX: +Create `src/api/modules/admin/notes/routes/email.route.tsx`. The `.tsx` extension lets the handler render the template with JSX: -```tsx title="plugins/site-notes/src/api/modules/admin/notes/email.route.tsx" +```tsx title="plugins/site-notes/src/api/modules/admin/notes/routes/email.route.tsx" import { z } from "@hono/zod-openapi"; import { eq } from "drizzle-orm"; import { createTranslator } from "use-intl"; diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index 4fdcf19ac5..bc2c4eda8a 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -1513,6 +1513,8 @@ a wrong id into an audit trail. A listener that needs the category should watch **A listener would** remove the post from an external index or feed. +It is a [schema-defined event](/docs/dev/events/schema-events): `blogPostDeletedEvent` from `@vitnode/blog/api/event-definitions` validates every emit, so a listener built from that definition receives a checked `postId`. Listening by name works as before. + ### blog.category.created and blog.category.updated { const values = c.req.valid("json"); const [note] = await c @@ -107,7 +107,7 @@ export const notesModule = buildModule({ }); ``` -Listeners are collected only from top-level modules. On a nested module, such as `admin/notes`, a listener never runs, and nothing warns you. +Listeners are collected only from top-level modules. On a nested module, such as `admin/notes`, a listener never runs: `buildApiPlugin` throws in development to say so, and [`vitnode doctor`](/docs/dev/cli/doctor) reports it. Production ignores it silently. @@ -156,5 +156,6 @@ export const welcomeListener = buildEventListener({ ## Related +- [Schema-defined events](/docs/dev/events/schema-events): validate every emit with a Zod schema. - [Built-in events](/docs/dev/events/built-in-events): every event core and the Content Engine emit, with payloads. - [Custom event adapter](/docs/dev/events/custom-adapter): deliver events through your own transport. diff --git a/apps/web/content/docs/dev/events/meta.json b/apps/web/content/docs/dev/events/meta.json index f4e25442a4..ff55e0693a 100644 --- a/apps/web/content/docs/dev/events/meta.json +++ b/apps/web/content/docs/dev/events/meta.json @@ -2,5 +2,5 @@ "title": "Events", "description": "Emit typed domain events and react to them from any plugin", "icon": "Radio", - "pages": ["index", "built-in-events", "custom-adapter"] + "pages": ["index", "schema-events", "built-in-events", "custom-adapter"] } diff --git a/apps/web/content/docs/dev/events/schema-events.mdx b/apps/web/content/docs/dev/events/schema-events.mdx new file mode 100644 index 0000000000..3f4d9da093 --- /dev/null +++ b/apps/web/content/docs/dev/events/schema-events.mdx @@ -0,0 +1,167 @@ +--- +title: Schema-defined events +description: Define a VitNode event with defineEvent and a Zod payload schema, so every emit is validated before listeners run and listeners receive parsed, typed data. +icon: FileCheck +--- + +`defineEvent` gives an event a payload schema. `c.get("events").emit(event, payload)` then type-checks and validates the payload before the adapter or any listener sees it, and listeners built from the definition receive the schema's output. In this guide `site-notes` turns `site-notes.note.created` from [Events](/docs/dev/events) into a schema-defined event without renaming it or changing its payload. + +## Before you begin + +- The `site-notes.note.created` event, its emit in the create route and the `log-new-note` listener, from [Events](/docs/dev/events). + + + + + +### Bind `defineEvent` to your plugin + +Add it to the builders in `src/api/builders.ts`: + +```ts title="plugins/site-notes/src/api/builders.ts" +export const buildModule = apiBuilders.buildModule; +export const buildRoute = apiBuilders.buildRoute; +export const defineEvent = apiBuilders.defineEvent; // [!code ++] +``` + + + + + +### Define the event + +Create `src/api/events.ts`. Keep it free of handlers and database code, so another plugin can import the definition to listen: + +```ts title="plugins/site-notes/src/api/events.ts" +import { z } from "zod"; + +import { defineEvent } from "@/api/builders"; + +export const noteCreatedEvent = defineEvent({ + name: "site-notes.note.created", + payload: z.object({ + noteId: z.number().int().positive(), + title: z.string().min(1), + }), +}); +``` + +The payload must be a JSON object, so an adapter can carry it to another process: `z.date()` is a compile error, use `z.iso.datetime()`. + + + + + +### Register it and bridge the name + +Pass the definition to `buildApiPlugin` in `src/config.api.ts`: + +```ts title="plugins/site-notes/src/config.api.ts" +import { noteCreatedEvent } from "@/api/events"; // [!code ++] + +buildApiPlugin({ + manifest, + eventDefinitions: [noteCreatedEvent], // [!code ++] + modules: [notesModule, adminModule], +}); +``` + +Then, in `src/api/lib/events.ts`, derive the `VitNodeEvents` entry from the schema instead of writing it by hand: + +```ts title="plugins/site-notes/src/api/lib/events.ts" +import type { EventPayload } from "@vitnode/core/api/lib/event-definition"; // [!code ++] + +import type { noteCreatedEvent } from "@/api/events"; // [!code ++] + +declare module "@vitnode/core/api/models/events" { + interface VitNodeEvents { + "site-notes.note.created": { noteId: number; title: string }; // [!code --] + "site-notes.note.created": EventPayload; // [!code ++] + } +} +``` + +Registration makes every emit of the name validated, including `emit("site-notes.note.created", ...)` calls you have not converted, and makes the API refuse to start if another definition claims the same name. The bridge keeps string listeners typed with the same payload. + + + + + +### Emit with the definition + +In the create route, pass the definition instead of the name: + +```ts title="plugins/site-notes/src/api/modules/admin/notes/routes/create.route.ts" +await c.get("events").emit("site-notes.note.created", { // [!code --] +await c.get("events").emit(noteCreatedEvent, { // [!code ++] + noteId: note.id, + title: note.title, +}); +``` + +A missing or mistyped field is a compile error. At runtime, a payload the schema rejects throws `EventPayloadValidationError` before any listener runs. + + + + + +### Listen with the definition + +Change `event` in `src/api/lib/note-created.listener.ts` to the definition: + +```ts title="plugins/site-notes/src/api/lib/note-created.listener.ts" +import { noteCreatedEvent } from "@/api/events"; // [!code ++] + +export const noteCreatedListener = buildEventListener({ + event: "site-notes.note.created", // [!code --] + event: noteCreatedEvent, // [!code ++] + name: "log-new-note", + handler: async (c, payload, envelope) => { + /* unchanged */ + }, +}); +``` + +`payload` is now the schema's output. The listener stays in the same `events` array, matches the same name and runs in the same order. + + + + + +## Check the result + +1. Create a note, as in [Events](/docs/dev/events#check-the-result). The `New note #…` line appears in **Debug Panel → Log explorer** as before. +2. Run `npx vitnode inspect events` in the plugin after building it. The `site-notes.note.created` listener shows `schema` in the **Payload** column. + +## Validation failures and delivery failures + +| What went wrong | `emit(definition, payload)` | `emit("name", payload)` | +| ------------------------------------------------ | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| The payload is not JSON or the schema rejects it | Throws `EventPayloadValidationError`; nothing is delivered | Never throws: nothing is delivered, and `failures` holds one entry with `module: "validation"` | +| A listener throws | Logged and returned in `failures`; the other listeners still run | Same | +| The adapter throws | One failure with `module: "adapter"` | Same | + +The schema runs once per emit, before the adapter; every listener gets the same parsed value. Error messages name paths and issue codes, never payload values. An event name with no registered definition keeps the old behavior: no validation. + +## Local and external adapters + +The local adapter hands listeners the parsed output directly, so a transform such as `z.iso.datetime().transform(value => new Date(value))` gives listeners a `Date`. A [custom adapter](/docs/dev/events/custom-adapter) receives the same output in `envelope.payload` and serializes it itself; a `Date` becomes a string on the way. If events cross a process boundary, keep the schema's output JSON (no transforms) and parse the payload again with `noteCreatedEvent.payload` on the receiving side. Core's own events, some of which carry `Date` values, are not schema-defined and are unaffected. + +## Reference + +All from `@vitnode/core/api/lib/event-definition`, except the bound builder. + +| Export | Behavior | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `defineEvent({ name, payload, pluginId })` | Declares an event. The bound `defineEvent` from `@/api/builders` fills in `pluginId`. Names use letters, digits and `. _ - : / @`. | +| `EventInput` | What `emit` accepts: the schema's input. | +| `EventPayload` | What listeners receive: the schema's output. Use it for the `VitNodeEvents` bridge. | +| `EventPayloadValidationError` | Thrown by `emit(definition, payload)` for an invalid payload. `reason` names paths and codes. | +| `EventDefinitionError` | Two different definitions for one name, a plugin registering another plugin's definition, or an invalid name. Thrown at boot, or by `emit` for a conflicting definition. | + +`buildApiPlugin({ eventDefinitions })` takes the plugin's own definitions. A definition used by a typed listener is known without it, so another plugin's event never has to be registered to be listened to. + +## Related + +- [Events](/docs/dev/events): emit, listen, and how listeners run. +- [Built-in events](/docs/dev/events/built-in-events): `blog.post.deleted` is a schema-defined event. diff --git a/apps/web/content/docs/dev/payments/plugin.mdx b/apps/web/content/docs/dev/payments/plugin.mdx index 66b02ceecd..d38685a424 100644 --- a/apps/web/content/docs/dev/payments/plugin.mdx +++ b/apps/web/content/docs/dev/payments/plugin.mdx @@ -84,7 +84,7 @@ The route checks the member, looks the price up, and calls `c.get("payments").createCheckout()`. Return paths are same-site paths; core turns them into absolute URLs on `VITNODE_WEB_URL` and refuses anything else. -```ts title="plugins/payments-demo/src/api/modules/purchases/purchases.routes.ts" +```ts title="plugins/payments-demo/src/api/modules/purchases/routes/checkout.route.ts" handler: async c => { const user = c.get('user') if (!user) throw new HTTPException(401) @@ -173,7 +173,7 @@ The provider sends the customer back to `successPath`, but arriving there proves nothing. The status route reads core's payment and, while it is open, asks the provider for news, at most every 15 seconds: -```ts title="plugins/payments-demo/src/api/modules/purchases/purchase-status.ts" +```ts title="plugins/payments-demo/src/api/modules/purchases/lib/describe-purchase.ts" const attempts = await payments.listByReference(purchaseReference(purchase.id)) let latest = attempts[0] diff --git a/apps/web/content/docs/dev/plugins/api/admin-routes.mdx b/apps/web/content/docs/dev/plugins/api/admin-routes.mdx new file mode 100644 index 0000000000..1d145d041b --- /dev/null +++ b/apps/web/content/docs/dev/plugins/api/admin-routes.mdx @@ -0,0 +1,133 @@ +--- +title: Protect an admin endpoint +description: Generate a VitNode admin API route guarded by a staff permission, declare the permission and its AdminCP labels, and see what the guard does and does not check. +icon: ShieldCheck +--- + +Admin endpoints live in the plugin's `admin` module tree, so their URLs contain `/admin/` and need an AdminCP session. Each one also needs a staff permission, which `adminStaffPermission` checks before the handler runs. `vitnode g route` puts both in place. In this guide you add `POST /api/site-notes/admin/notes`, guarded by **Create notes**. + +## Before you begin + +- The `site-notes` plugin from the earlier generator guides, or a fresh one from [Create plugin](/docs/dev/plugins/create), with a terminal in `plugins/site-notes`. It must not have the `admin/notes` create route from the [Permissions](/docs/dev/working-with-users/staff-permissions) lesson, or step 2 stops with `POST /admin/notes already exists`. +- To try the guard with a restricted account, an account restricted as in the Permissions lesson's [Restrict the second account](/docs/dev/working-with-users/staff-permissions#restrict-the-second-account) step. + + + + + +### Preview the route + +`--admin` puts the route in the admin module tree: `notes/create` becomes `admin/notes/create`, the same as writing that path yourself. It never adds a guard to a public module. + +```bash +pnpm vitnode g route notes/create --admin --permission notes.can_create --parents --dry-run +``` + +```txt +✖ The staff permission notes.can_create is not declared. + Nobody can be granted a permission the plugin does not declare in src/config.api.ts: + permissionStaff: { admin: { notes: ["can_create"] } } + + Pass --declare-permission to add it (and its labels), or pick a declared one. +``` + +The generator checks the permission against `buildApiPlugin({ permissionStaff })` before writing anything. Every admin route needs one: leave out `--permission` and an interactive terminal offers the declared ones, CI gets an error. + +One exception: when the plugin's `admin` module mounts Content Engine modules and the permission module is not declared at all, the generator only warns and writes the route. A content type declares its own permission module (`articles` for `newsroom.articles`), which the generator cannot see in source. Run `pnpm vitnode doctor` after a build to confirm the permission exists. + + + + + +### Declare the permission and generate + +Add `--declare-permission`, and drop `--dry-run` once the diff looks right: + +```bash +pnpm vitnode g route notes/create --admin --permission notes.can_create --parents --declare-permission +``` + +```txt + ✓ created src/api/modules/admin/admin.module.ts module "admin"; register adminNotesModule in module "admin" + ✓ created src/api/modules/admin/notes/notes.module.ts module "admin/notes"; register createNotesRoute in module "admin/notes" + ✓ created src/api/modules/admin/notes/routes/create.route.ts POST /admin/notes + ✓ updated src/config.api.ts declare the admin permission notes.can_create; register adminModule in buildApiPlugin + ✓ updated src/locales/en.json AdminCP labels for notes.can_create +``` + +`--parents` created the `admin` and `admin/notes` modules, and `admin/notes/notes.module.ts` imports the route from `./routes/create.route`. `config.api.ts` now declares the permission: + +```ts title="plugins/site-notes/src/config.api.ts" +modules: [helloModule, adminModule], +permissionStaff: { admin: { notes: ["can_create"] } }, +``` + +and `en.json` gained the labels the AdminCP permission editor shows, `"site-notes:notes": "Notes"` and `"site-notes:notes:can_create": "Create notes"`. The generator only rewrites `en.json` when it is plain two-space JSON; otherwise it prints the keys to add by hand. + + + + + +### Read the guard + +The route carries the permission and documents the `403`: + +```ts title="plugins/site-notes/src/api/modules/admin/notes/routes/create.route.ts" +/** + * POST /admin/notes - a generated scaffold that answers 501 until implemented. + * + * Describe the input, add the success response to `responses`, then replace + * the `throw` with the real operation. + * + * The staff permission decides who may call it at all. It does not check + * access to a particular record - do that in the handler when it matters. + */ +export const createNotesRoute = buildRoute({ + adminStaffPermission: { module: "notes", permission: "can_create" }, + route: { + method: "post", + path: "/", + // ... + responses: { + 403: { description: "Requires the notes.can_create staff permission." }, + 501: { description: "Not implemented yet." }, + }, + }, + // ... +}); +``` + +The last paragraph of that comment matters for every write. `can_edit` lets an administrator call `PATCH /admin/notes/{id}`; whether they may edit *that* note, for example one owned by another team, is a decision your handler makes. See [Check a permission in code](/docs/dev/working-with-users/staff-permissions#check-a-permission-in-code). + + + + + +If the plugin declares its permissions with [`defineStaffPermissions`](/docs/dev/working-with-users/staff-permission-refs), the generator edits that definition instead and the route gets `adminStaffPermission: staffPermissions.admin.notes.canCreate`. + +## Check the result + +Generate a second admin route. The permission module and the `admin/notes` module now exist, so only the new pieces are added: + +```bash +pnpm vitnode g route admin/notes/delete --permission notes.can_delete --declare-permission +``` + +The diff appends `"can_delete"` to `notes: [...]` in `permissionStaff`, adds the label, and adds `deleteNotesRoute` to `admin/notes`. Build the plugin and run `pnpm vitnode plugin validate` to check it the way an app loads it. + +Until you implement them, both routes answer `501` to an administrator holding the permission and `403` to one who does not. To try it, reuse the `CheckNotes` button from [Generate an endpoint](/docs/dev/plugins/api/generate-route#call-it-from-a-page) with the call the generator printed: `method: "post"`, `module: "admin/notes"`, `path: "/"` and `args: { body: {} }`. Sign in to the AdminCP as each account, open the page and press it. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/plugins/api/generate-module.mdx b/apps/web/content/docs/dev/plugins/api/generate-module.mdx new file mode 100644 index 0000000000..a1f9b57e04 --- /dev/null +++ b/apps/web/content/docs/dev/plugins/api/generate-module.mdx @@ -0,0 +1,143 @@ +--- +title: Generate a module +description: Create a VitNode API module with vitnode generate module, preview the change with a dry run, and nest a child module inside a parent. +icon: Box +--- + +`vitnode g module` creates an API module and registers it for you: a top-level module in `buildApiPlugin({ modules })`, a child module in its parent's `modules`. Use it whenever a plugin needs a new URL segment, such as `/notes`, for a group of routes. + +## Before you begin + +- A fresh plugin made with [Create plugin](/docs/dev/plugins/create), such as `site-notes`. The generator guides, from this page to [Generate an API resource](/docs/dev/plugins/api/resources), are a second track next to the hand-written lessons ([API modules](/docs/dev/plugins/api/modules) to [Permissions](/docs/dev/working-with-users/staff-permissions)), and both build `notes`. Follow one track per plugin: in a plugin that already has the lessons' `notes` module, these commands stop with "already exists". +- A terminal in the plugin's folder, `plugins/site-notes`. From the workspace root, use `pnpm --filter site-notes exec vitnode ...` instead of `pnpm vitnode ...`. + + + + + +### Preview the change + +Add `--dry-run` to see exactly what the generator would write, without writing it: + +```bash +pnpm vitnode g module notes --dry-run +``` + +```txt +--- /dev/null ++++ b/src/api/modules/notes/notes.module.ts +@@ -0,0 +1,6 @@ ++import { buildModule } from "@/api/builders"; ++ ++export const notesModule = buildModule({ ++ name: "notes", ++ routes: [], ++}); + +--- a/src/config.api.ts ++++ b/src/config.api.ts +@@ -5,11 +5,12 @@ + import { manifest } from "@/manifest.gen"; + + import { helloModule } from "./api/modules/hello/hello.module"; ++import { notesModule } from "./api/modules/notes/notes.module"; + + export const siteNotesApiPlugin = () => + buildApiPlugin({ + manifest, +- modules: [helloModule], ++ modules: [helloModule, notesModule], + }); +``` + +The import follows the style the file already uses, here a relative path, and the rest of `config.api.ts`, including the `VitNodeApiPlugin` type, stays as it was. + + + + + +### Generate the module + +Run the same command without `--dry-run`: + +```bash +pnpm vitnode g module notes +``` + +```txt +◆ VitNode + Generate a module + + Plugin site-notes . + + ✓ created src/api/modules/notes/notes.module.ts module "notes" + ✓ updated src/config.api.ts register notesModule in buildApiPlugin + + Module notes is mounted at /site-notes/notes under the app's API base path (/api in the starter apps). + It has no routes yet, so there is nothing to call. Add one with: + vitnode g route notes/list +``` + +An empty module has no endpoint: `/api/site-notes/notes` answers `404` until you add a route. + + + + + +### Nest a child module + +A module path with a slash creates a child inside its parent. The parent has to exist, or you pass `--parents` to create it too: + +```bash +pnpm vitnode g module reports/weekly --parents +``` + +This creates `src/api/modules/reports/reports.module.ts` (registered in `config.api.ts`) and `src/api/modules/reports/weekly/weekly.module.ts`, registered only in the parent: + +```ts title="plugins/site-notes/src/api/modules/reports/reports.module.ts" +import { buildModule } from "@/api/builders"; + +import { reportsWeeklyModule } from "./weekly/weekly.module"; + +export const reportsModule = buildModule({ + name: "reports", + routes: [], + modules: [reportsWeeklyModule], +}); +``` + +Its routes are served under `/api/site-notes/reports/weekly`, and the fetcher calls them with `module: "reports/weekly"`. Without `--parents`, a missing parent stops the command with a hint, or asks first in an interactive terminal. + + + + + +## Check the result + +Run the generator for the same module again. It refuses, because the module is now registered: + +```bash +pnpm vitnode g module notes +``` + +```txt +✖ Module "notes" already exists. + Declared as notesModule in src/api/modules/notes/notes.module.ts. +``` + +The generator never overwrites a file or a registration. Admin modules work the same way under `admin/`; [Protect an admin endpoint](/docs/dev/plugins/api/admin-routes) creates them together with a guarded route. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/plugins/api/generate-route.mdx b/apps/web/content/docs/dev/plugins/api/generate-route.mdx new file mode 100644 index 0000000000..b19fe8d229 --- /dev/null +++ b/apps/web/content/docs/dev/plugins/api/generate-route.mdx @@ -0,0 +1,179 @@ +--- +title: Generate an endpoint +description: Add typed VitNode API routes with vitnode generate route - a GET starter with sample data, a path parameter, a 501 write scaffold - and call them with the fetcher. +icon: Route +--- + +`vitnode g route` creates a typed route file and adds it to its module's `routes`. The route's name (`show.route.ts`) and its URL (`/{id}`) are separate: common names imply a path, any other name asks for one. In this guide you add three endpoints to the `notes` module of `site-notes` and call one from a page. + +## Before you begin + +- The `notes` module from [Generate a module](/docs/dev/plugins/api/generate-module), and `pnpm dev` running at the workspace root. +- A terminal in `plugins/site-notes`. + + + + + +### Add a list endpoint + +```bash +pnpm vitnode g route notes/list +``` + +`list` implies `GET /`, so no `--method` or `--path` is needed. Each route gets its own file in the module's `routes/` folder. The new `src/api/modules/notes/routes/list.route.ts` answers with sample data that its schema describes exactly, so nothing mistakes it for real notes: + +```ts title="plugins/site-notes/src/api/modules/notes/routes/list.route.ts" +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/builders"; + +/** + * GET /notes - a generated starter that answers with sample data. + * + * Replace the 200 schema and the handler with the real response. + */ +export const listNotesRoute = buildRoute({ + route: { + method: "get", + path: "/", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ sample: z.literal(true), message: z.string() }), + }, + }, + description: "Sample data from a generated route.", + }, + }, + }, + handler: c => + c.json( + { + sample: true as const, + message: + "Sample data from list.route.ts. Replace it with the real response.", + }, + 200, + ), +}); +``` + +`notes.module.ts` now imports it from `./routes/list.route` and lists it: `routes: [listNotesRoute]`. + + + + + +### Add a route with a path parameter + +```bash +pnpm vitnode g route notes/show --id-type number +``` + +`show` implies `GET /{id}`. For any other path, pass `--path`, in OpenAPI syntax and quoted so your shell leaves the braces alone: `--path '/{id}/archive'`. Parameters are strings by default; `--id-type number` validates them as positive integers instead (`z.coerce.number().int().positive()`), so `/notes/abc` is refused with `400` before the handler runs. + + + + + +### Add a write scaffold + +```bash +pnpm vitnode g route notes/create +``` + +`create` implies `POST /`. A write route validates its input but has no operation yet, so it answers `501 Not Implemented` instead of pretending to succeed: + +```ts title="plugins/site-notes/src/api/modules/notes/routes/create.route.ts" +handler: () => { + throw new HTTPException(501, { + message: "POST /notes is not implemented yet. Implement it in create.route.ts.", + }); +}, +``` + +Fill in the body schema (`z.object({})` to start with), add the success response to `responses`, and replace the `throw` with the real insert. The [Permissions](/docs/dev/working-with-users/staff-permissions#guard-the-create-route) lesson shows one. + + + + + +### Call it from a page + +The generator printed a `fetcher` call for each route. Use the one for `show` in a temporary component, `src/pages/check-notes.tsx`: + +```tsx title="plugins/site-notes/src/pages/check-notes.tsx" +import { Button } from "@vitnode/core/components/ui/button"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { SearchIcon } from "lucide-react"; +import { useState } from "react"; + +export const CheckNotes = () => { + const [result, setResult] = useState(); + const [isPending, setIsPending] = useState(false); + + const onClick = async () => { + setIsPending(true); + try { + const response = await fetcher({ + plugin: "site-notes", + method: "get", + module: "notes", + path: "/{id}", + args: { params: { id: 7 } }, + }); + setResult(JSON.stringify(await response.json())); + } finally { + setIsPending(false); + } + }; + + return ( +
+ + {result ? {result} : null} +
+ ); +}; +``` + +Render `` in `src/pages/home-page.tsx`. TypeScript checks the call against the route: `id` must be a number, and a path the module does not have, such as `/missing`, is a compile error. + +
+ +
+ +## Check the result + +Open `/site-notes` and press **Show note 7**: + +```json +{ "sample": true, "id": 7, "message": "Sample data from show.route.ts. Replace it with the real response." } +``` + +Then remove `CheckNotes`. Two safety checks are worth knowing: + +- Generating a route the plugin already answers, such as `pnpm vitnode g route notes/all --method get --path /`, stops with `✖ GET /notes already exists.`, names `listNotesRoute` in `routes/list.route.ts` as the route that answers it, and writes nothing. The check covers the whole plugin, so a route in a parent module and one in its child module that end up on the same URL collide too. +- A name without a default, such as `notes/archive`, needs `--method` and `--path` in CI. In an interactive terminal the generator asks for them. + +The plugin's own pages see new routes at once. An app sees them after the plugin's declarations rebuild, which `pnpm dev` does on save. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/plugins/api/meta.json b/apps/web/content/docs/dev/plugins/api/meta.json index 224dcc25a8..f8081ee733 100644 --- a/apps/web/content/docs/dev/plugins/api/meta.json +++ b/apps/web/content/docs/dev/plugins/api/meta.json @@ -1,6 +1,16 @@ { "title": "REST API", - "description": "Build typed Hono modules and routes inside your VitNode plugin", + "description": "Build typed Hono modules and routes inside your VitNode plugin, by hand or with vitnode generate", "icon": "Network", - "pages": ["modules", "routes"] + "pages": [ + "modules", + "routes", + "response-fields", + "generate-module", + "generate-route", + "admin-routes", + "services", + "resources", + "moved-imports" + ] } diff --git a/apps/web/content/docs/dev/plugins/api/modules.mdx b/apps/web/content/docs/dev/plugins/api/modules.mdx index 4bba4b7452..b053b395ff 100644 --- a/apps/web/content/docs/dev/plugins/api/modules.mdx +++ b/apps/web/content/docs/dev/plugins/api/modules.mdx @@ -6,6 +6,10 @@ icon: Box An API module groups a plugin's Hono routes under one URL segment. In this lesson you add a `notes` module to `site-notes` whose endpoint returns two sample notes. A later lesson swaps the sample data for a database table. + + `pnpm vitnode g module notes` creates and registers an empty module in one step. See [Generate a module](/docs/dev/plugins/api/generate-module). This lesson writes it by hand so you see every part. + + ## Before you begin - The `site-notes` plugin from [Create plugin](/docs/dev/plugins/create), with `pnpm dev` running at the workspace root. `dev` rebuilds the plugin every time you save. @@ -28,9 +32,9 @@ export const zodNote = z.object({ }); ``` -Then create the sample data the endpoint returns for now: +Then create the sample data the endpoint returns for now. Helpers that are not routes go in the module's `lib/` folder: -```ts title="plugins/site-notes/src/api/modules/notes/sample-notes.ts" +```ts title="plugins/site-notes/src/api/modules/notes/lib/sample-notes.ts" /** Placeholder notes until the Database lesson stores real ones. */ export const sampleNotes = [ { id: 1, title: "Welcome to site notes", body: "Short updates for everyone on the site." }, @@ -44,15 +48,15 @@ export const sampleNotes = [ ### Create the list route -Create `src/api/modules/notes/list.route.ts`. `buildRoute` comes from the plugin's own `src/api/builders.ts`, which the generator wrote, so the route is tied to `site-notes` without naming it: +Create `src/api/modules/notes/routes/list.route.ts`. Each endpoint gets its own file in the module's `routes/` folder. `buildRoute` comes from the plugin's own `src/api/builders.ts`, which the generator wrote, so the route is tied to `site-notes` without naming it: -```ts title="plugins/site-notes/src/api/modules/notes/list.route.ts" +```ts title="plugins/site-notes/src/api/modules/notes/routes/list.route.ts" import { z } from "@hono/zod-openapi"; import { buildRoute } from "@/api/builders"; -import { zodNote } from "./notes.schema"; -import { sampleNotes } from "./sample-notes"; +import { sampleNotes } from "../lib/sample-notes"; +import { zodNote } from "../notes.schema"; export const listNotesRoute = buildRoute({ route: { @@ -82,7 +86,7 @@ Create `src/api/modules/notes/notes.module.ts`. The module's `name` is the URL s ```ts title="plugins/site-notes/src/api/modules/notes/notes.module.ts" import { buildModule } from "@/api/builders"; -import { listNotesRoute } from "./list.route"; +import { listNotesRoute } from "./routes/list.route"; export const notesModule = buildModule({ name: "notes", diff --git a/apps/web/content/docs/dev/plugins/api/moved-imports.mdx b/apps/web/content/docs/dev/plugins/api/moved-imports.mdx new file mode 100644 index 0000000000..37cb274d9b --- /dev/null +++ b/apps/web/content/docs/dev/plugins/api/moved-imports.mdx @@ -0,0 +1,120 @@ +--- +title: Moved API imports +description: Where the VitNode API files moved when routes got one file each under routes/, and how the former deep import paths keep working until you update them. +icon: ArrowRightLeft +--- + +VitNode now keeps one endpoint per file in each module's `routes/` folder, names modules `.module.ts`, and puts schemas next to their module. Some files moved or were split while their endpoints stayed exactly the same: same methods, paths, permissions and `fetcher` calls. If your code imports one of the paths below, it keeps working, but the import is deprecated: switch to the new path before the compatibility entries are removed in a future major release. + +## How the old paths still resolve + +- **Moved as a whole:** the package's `exports` map the old path straight to the new file, so every export is still there. +- **Split or trimmed:** the old path now points at a small compatibility module under `dist/src/compat/` that re-exports each former export from its new module, marked `@deprecated`. It contains no code of its own and pulls in no route code the old file did not already contain, so a browser-safe schema import stays browser-safe. +- **Still at the same path:** a file that only lost some exports, such as `@vitnode/core/api/modules/ai/routes/stream.route`, keeps serving its own exports unchanged. Only the moved ones are deprecated. + +Nothing was removed without a replacement, so no import from the paths below breaks. + +## @vitnode/core + +| Former import | Import from | +| --- | --- | +| `@vitnode/core/api/lib/ai/run-schemas` | `@vitnode/core/api/lib/ai/run.schema` | +| `@vitnode/core/api/modules/admin/activity-logs/activity-logs.admin.module` | `@vitnode/core/api/modules/admin/activity-logs/activity-logs.module` | +| `@vitnode/core/api/modules/admin/activity-logs/routes/detail.route` | `@vitnode/core/api/modules/admin/activity-logs/routes/show.route` | +| `@vitnode/core/api/modules/admin/advanced/advanced.admin.module` | `@vitnode/core/api/modules/admin/advanced/advanced.module` | +| `@vitnode/core/api/modules/admin/advanced/cron/cron.admin.module` | `@vitnode/core/api/modules/admin/advanced/cron/cron.module` | +| `@vitnode/core/api/modules/admin/advanced/cron/routes/get.route` | `@vitnode/core/api/modules/admin/advanced/cron/routes/list.route` | +| `@vitnode/core/api/modules/admin/advanced/queue/queue.admin.module` | `@vitnode/core/api/modules/admin/advanced/queue/queue.module` | +| `@vitnode/core/api/modules/admin/advanced/queue/routes/get.route` | `@vitnode/core/api/modules/admin/advanced/queue/routes/list.route` | +| `@vitnode/core/api/modules/admin/ai/ai.admin.module` | `@vitnode/core/api/modules/admin/ai/ai.module` | +| `@vitnode/core/api/modules/admin/ai/routes/access.route` | `deleteAiUserOverrideAdminRoute`: `@vitnode/core/api/modules/admin/ai/routes/delete-user-override.route`
`getAiRoleAccessAdminRoute`: `@vitnode/core/api/modules/admin/ai/routes/show-role-access.route`
`getAiUserOverrideAdminRoute`: `@vitnode/core/api/modules/admin/ai/routes/show-user-override.route`
`updateAiRoleAccessAdminRoute`: `@vitnode/core/api/modules/admin/ai/routes/update-role-access.route`
`updateAiUserOverrideAdminRoute`: `@vitnode/core/api/modules/admin/ai/routes/update-user-override.route` | +| `@vitnode/core/api/modules/admin/ai/routes/assist-stream.route` | `estimateAiAdminRoute`: `@vitnode/core/api/modules/admin/ai/routes/assist-estimate.route`
everything else: unchanged | +| `@vitnode/core/api/modules/admin/ai/routes/history.route` | `listAiHistoryAdminRoute`: `@vitnode/core/api/modules/admin/ai/routes/list-history.route`
`showAiRunAdminRoute`: `@vitnode/core/api/modules/admin/ai/routes/show-run.route` | +| `@vitnode/core/api/modules/admin/ai/routes/translation-sources.route` | `getTranslationSourcesAdminRoute`: `@vitnode/core/api/modules/admin/ai/routes/list-translation-sources.route`
`saveTranslationSourcesAdminRoute`: `@vitnode/core/api/modules/admin/ai/routes/update-translation-sources.route` | +| `@vitnode/core/api/modules/admin/ai/schemas` | `@vitnode/core/api/modules/admin/ai/ai.schema` | +| `@vitnode/core/api/modules/admin/dashboard/dashboard.admin.module` | `@vitnode/core/api/modules/admin/dashboard/dashboard.module` | +| `@vitnode/core/api/modules/admin/debug/debug.admin.module` | `@vitnode/core/api/modules/admin/debug/debug.module` | +| `@vitnode/core/api/modules/admin/files/files.admin.module` | `@vitnode/core/api/modules/admin/files/files.module` | +| `@vitnode/core/api/modules/admin/files/routes/alt.route` | `deleteFileAltAdminRoute`: `@vitnode/core/api/modules/admin/files/routes/delete-alt.route`
`getFileAltAdminRoute`: `@vitnode/core/api/modules/admin/files/routes/show-alt.route`
`updateFileAltPolicyAdminRoute`: `@vitnode/core/api/modules/admin/files/routes/update-alt-policy.route`
`updateFileAltAdminRoute`: `@vitnode/core/api/modules/admin/files/routes/update-alt.route` | +| `@vitnode/core/api/modules/admin/notifications/notifications.admin.module` | `@vitnode/core/api/modules/admin/notifications/notifications.module` | +| `@vitnode/core/api/modules/admin/notifications/routes/danger.route` | `cancelQueuedNotificationEmailsRoute`: `@vitnode/core/api/modules/admin/notifications/routes/cancel-queued-emails.route`
`deleteAllNotificationsRoute`: `@vitnode/core/api/modules/admin/notifications/routes/delete-all.route`
`markEverythingReadRoute`: `@vitnode/core/api/modules/admin/notifications/routes/mark-all-read.route`
`pauseNotificationsRoute`: `@vitnode/core/api/modules/admin/notifications/routes/pause.route`
`resetMemberNotificationPreferencesRoute`: `@vitnode/core/api/modules/admin/notifications/routes/reset-member-preferences.route`
`resumeNotificationsRoute`: `@vitnode/core/api/modules/admin/notifications/routes/resume.route` | +| `@vitnode/core/api/modules/admin/notifications/routes/deliveries.route` | `listNotificationDeliveriesRoute`: `@vitnode/core/api/modules/admin/notifications/routes/list-deliveries.route`
`retryNotificationDeliveryRoute`: `@vitnode/core/api/modules/admin/notifications/routes/retry-delivery.route` | +| `@vitnode/core/api/modules/admin/notifications/routes/maintenance.route` | `reconcileNotificationCountsRoute`: `@vitnode/core/api/modules/admin/notifications/routes/reconcile-counts.route`
`sendNotificationTestEmailRoute`: `@vitnode/core/api/modules/admin/notifications/routes/send-test-email.route` | +| `@vitnode/core/api/modules/admin/notifications/routes/settings.route` | `@vitnode/core/api/modules/admin/notifications/routes/update-type-policy.route` | +| `@vitnode/core/api/modules/admin/plugins/plugins.admin.module` | `@vitnode/core/api/modules/admin/plugins/plugins.module` | +| `@vitnode/core/api/modules/admin/plugins/routes/updates.schema` | `iso`, `serializeComponentUpdate`: `@vitnode/core/api/modules/admin/plugins/lib/serialize-update`
`componentUpdateSchema`, `updatesOverviewSchema`, `updatesSummarySchema`: `@vitnode/core/api/modules/admin/plugins/updates.schema` | +| `@vitnode/core/api/modules/admin/report-reasons/report-reasons.admin.module` | `@vitnode/core/api/modules/admin/report-reasons/report-reasons.module` | +| `@vitnode/core/api/modules/admin/report-reasons/routes/report-reasons.routes` | `createReportReasonAdminRoute`: `@vitnode/core/api/modules/admin/report-reasons/routes/create.route`
`deleteReportReasonAdminRoute`: `@vitnode/core/api/modules/admin/report-reasons/routes/delete.route`
`listReportReasonsAdminRoute`: `@vitnode/core/api/modules/admin/report-reasons/routes/list.route`
`reorderReportReasonsAdminRoute`: `@vitnode/core/api/modules/admin/report-reasons/routes/reorder.route`
`updateReportReasonAdminRoute`: `@vitnode/core/api/modules/admin/report-reasons/routes/update.route` | +| `@vitnode/core/api/modules/admin/roles/roles.admin.module` | `@vitnode/core/api/modules/admin/roles/roles.module` | +| `@vitnode/core/api/modules/admin/roles/routes/create.route` | `ROLE_IMAGE_SIZE_MAX_KB`, `zodRoleEditOwnContentMinutesSchema`, `zodRoleImageSizeSchema`, `zodRoleNameSchema`, `zodRolePrefixSchema`, `zodRoleStorageSchema`: `@vitnode/core/api/modules/admin/roles/roles.schema`
everything else: unchanged | +| `@vitnode/core/api/modules/admin/roles/routes/list-mapping` | `@vitnode/core/api/modules/admin/roles/lib/list-mapping` | +| `@vitnode/core/api/modules/admin/routes/notifications.route` | `@vitnode/core/api/modules/admin/routes/send-notification.route` | +| `@vitnode/core/api/modules/admin/staff/lib/schema` | `@vitnode/core/api/modules/admin/staff/staff.schema` | +| `@vitnode/core/api/modules/admin/staff/staff.admin.module` | `@vitnode/core/api/modules/admin/staff/staff.module` | +| `@vitnode/core/api/modules/admin/users/users.admin.module` | `@vitnode/core/api/modules/admin/users/users.module` | +| `@vitnode/core/api/modules/admin/warnings/routes/warning-reasons.routes` | `createWarningReasonAdminRoute`: `@vitnode/core/api/modules/admin/warnings/routes/create-reason.route`
`listWarningReasonsAdminRoute`: `@vitnode/core/api/modules/admin/warnings/routes/list-reasons.route`
`reorderWarningReasonsAdminRoute`: `@vitnode/core/api/modules/admin/warnings/routes/reorder-reasons.route`
`updateWarningReasonAdminRoute`: `@vitnode/core/api/modules/admin/warnings/routes/update-reason.route` | +| `@vitnode/core/api/modules/admin/warnings/routes/warning-thresholds.routes` | `createWarningThresholdAdminRoute`: `@vitnode/core/api/modules/admin/warnings/routes/create-threshold.route`
`deleteWarningThresholdAdminRoute`: `@vitnode/core/api/modules/admin/warnings/routes/delete-threshold.route`
`listWarningThresholdsAdminRoute`: `@vitnode/core/api/modules/admin/warnings/routes/list-thresholds.route`
`updateWarningThresholdAdminRoute`: `@vitnode/core/api/modules/admin/warnings/routes/update-threshold.route` | +| `@vitnode/core/api/modules/admin/warnings/warnings.admin.module` | `@vitnode/core/api/modules/admin/warnings/warnings.module` | +| `@vitnode/core/api/modules/ai/routes/stream.route` | `estimateAiRoute`: `@vitnode/core/api/modules/ai/routes/estimate.route`
everything else: unchanged | +| `@vitnode/core/api/modules/content/helpers/execute-content-schedule` | `@vitnode/core/api/modules/content/lib/execute-content-schedule` | +| `@vitnode/core/api/modules/cron/helpers/load-cron-health` | `@vitnode/core/api/modules/cron/lib/load-cron-health` | +| `@vitnode/core/api/modules/cron/helpers/process-cron-jobs` | `@vitnode/core/api/modules/cron/lib/process-cron-jobs` | +| `@vitnode/core/api/modules/cron/helpers/register-cron-jobs` | `@vitnode/core/api/modules/cron/lib/register-cron-jobs` | +| `@vitnode/core/api/modules/cron/routes/cron.route` | `@vitnode/core/api/modules/cron/routes/run.route` | +| `@vitnode/core/api/modules/middleware/route` | `routeMiddlewareSchema`: `@vitnode/core/api/modules/middleware/middleware.schema`
`routeMiddleware`: `@vitnode/core/api/modules/middleware/routes/show-config.route` | +| `@vitnode/core/api/modules/moderation/lib/schema` | `@vitnode/core/api/modules/moderation/moderation.schema` | +| `@vitnode/core/api/modules/moderation/lib/warnings-service` | `@vitnode/core/api/modules/moderation/lib/warnings` | +| `@vitnode/core/api/modules/moderation/routes/account-status.route` | `accountStatusHistoryRoute`: `@vitnode/core/api/modules/moderation/routes/list-account-status-history.route`
`accountStatusRoute`: `@vitnode/core/api/modules/moderation/routes/show-account-status.route` | +| `@vitnode/core/api/modules/moderation/routes/approvals.routes` | `approveApprovalRoute`: `@vitnode/core/api/modules/moderation/routes/approve-approval.route`
`listApprovalsRoute`: `@vitnode/core/api/modules/moderation/routes/list-approvals.route`
`myApprovalsRoute`: `@vitnode/core/api/modules/moderation/routes/list-my-approvals.route`
`rejectApprovalRoute`: `@vitnode/core/api/modules/moderation/routes/reject-approval.route`
`approvalFiltersRoute`: `@vitnode/core/api/modules/moderation/routes/show-approval-filters.route`
`showApprovalRoute`: `@vitnode/core/api/modules/moderation/routes/show-approval.route` | +| `@vitnode/core/api/modules/moderation/routes/create.route` | `@vitnode/core/api/modules/moderation/routes/create-report.route` | +| `@vitnode/core/api/modules/moderation/routes/list.route` | `@vitnode/core/api/modules/moderation/routes/list-reports.route` | +| `@vitnode/core/api/modules/moderation/routes/notes.routes` | `deleteReportNoteRoute`: `@vitnode/core/api/modules/moderation/routes/delete-report-note.route`
`editReportNoteRoute`: `@vitnode/core/api/modules/moderation/routes/update-report-note.route` | +| `@vitnode/core/api/modules/moderation/routes/reasons.route` | `@vitnode/core/api/modules/moderation/routes/list-report-reasons.route` | +| `@vitnode/core/api/modules/moderation/routes/show.route` | `@vitnode/core/api/modules/moderation/routes/show-report.route` | +| `@vitnode/core/api/modules/moderation/routes/warnings.routes` | `issueWarningRoute`: `@vitnode/core/api/modules/moderation/routes/issue-warning.route`
`memberLogRoute`: `@vitnode/core/api/modules/moderation/routes/list-member-log.route`
`listWarningReasonsRoute`: `@vitnode/core/api/modules/moderation/routes/list-warning-reasons.route`
`listWarningsRoute`: `@vitnode/core/api/modules/moderation/routes/list-warnings.route`
`previewWarningRoute`: `@vitnode/core/api/modules/moderation/routes/preview-warning.route`
`revokeSanctionRoute`: `@vitnode/core/api/modules/moderation/routes/revoke-sanction.route`
`revokeWarningRoute`: `@vitnode/core/api/modules/moderation/routes/revoke-warning.route`
`searchMembersRoute`: `@vitnode/core/api/modules/moderation/routes/search-members.route`
`memberStandingRoute`: `@vitnode/core/api/modules/moderation/routes/show-member-standing.route` | +| `@vitnode/core/api/modules/moderation/routes/workflow.routes` | `assignReportRoute`: `@vitnode/core/api/modules/moderation/routes/assign-report.route`
`claimReportRoute`: `@vitnode/core/api/modules/moderation/routes/claim-report.route`
`noteReportRoute`: `@vitnode/core/api/modules/moderation/routes/create-report-note.route`
`reportAssigneesRoute`: `@vitnode/core/api/modules/moderation/routes/list-report-assignees.route`
`reopenReportRoute`: `@vitnode/core/api/modules/moderation/routes/reopen-report.route`
`resolveReportRoute`: `@vitnode/core/api/modules/moderation/routes/resolve-report.route`
`reportActionRoute`: `@vitnode/core/api/modules/moderation/routes/run-report-action.route` | +| `@vitnode/core/api/modules/notifications/routes/mark.route` | `archiveNotificationRoute`: `@vitnode/core/api/modules/notifications/routes/archive.route`
`markAllNotificationsReadRoute`: `@vitnode/core/api/modules/notifications/routes/mark-all-read.route`
`markNotificationReadRoute`: `@vitnode/core/api/modules/notifications/routes/mark-read.route`
`markNotificationUnreadRoute`: `@vitnode/core/api/modules/notifications/routes/mark-unread.route` | +| `@vitnode/core/api/modules/notifications/routes/preferences.route` | `zodNotificationPreferences`, `zodUpdateNotificationPreferences`: `@vitnode/core/api/modules/notifications/notifications.schema`
`getNotificationPreferencesRoute`: `@vitnode/core/api/modules/notifications/routes/show-preferences.route`
`updateNotificationPreferencesRoute`: `@vitnode/core/api/modules/notifications/routes/update-preferences.route` | +| `@vitnode/core/api/modules/notifications/schema` | `requireNotificationUser`: `@vitnode/core/api/modules/notifications/lib/require-notification-user`
`unauthorizedResponse`, `zodNotificationEmailMode`, `zodNotificationIdParams`, `zodNotificationItem`, `zodNotificationState`: `@vitnode/core/api/modules/notifications/notifications.schema` | +| `@vitnode/core/api/modules/notifications/tasks/notification-tasks` | `notificationsCleanupCron`, `notificationsScheduleCron`: `@vitnode/core/api/modules/notifications/cron/notifications.cron`
`notificationsCleanupTask`, `notificationsEmailTask`, `notificationsFanoutTask`: `@vitnode/core/api/modules/notifications/tasks/notifications.task` | +| `@vitnode/core/api/modules/pages/page-layout-routes` | `@vitnode/core/api/modules/pages/pages.module` | +| `@vitnode/core/api/modules/pages/page-layout-service` | `@vitnode/core/api/modules/pages/lib/page-layout` | +| `@vitnode/core/api/modules/queue/helpers/process-queue-tasks` | `@vitnode/core/api/modules/queue/lib/process-queue-tasks` | +| `@vitnode/core/api/modules/search/routes/search.route` | `dateOrUndefined`, `MAX_SEARCH_AUTHOR_IDS`, `positiveIntListOrUndefined`, `positiveIntOrUndefined`: `@vitnode/core/api/modules/search/lib/query-params`
`zodSearchHitAuthor`, `zodSearchHitSchema`, `zodSearchResultSchema`: `@vitnode/core/api/modules/search/search.schema`
everything else: unchanged | +| `@vitnode/core/api/modules/search/routes/suggest.route` | `searchSuggestionsLimit`: `@vitnode/core/api/modules/search/lib/query-params`
`zodSearchSuggestion`: `@vitnode/core/api/modules/search/search.schema`
everything else: unchanged | +| `@vitnode/core/api/modules/search/routes/timeline.route` | `searchTimeline`: `@vitnode/core/api/modules/search/lib/timeline`
`zodSearchTimelineQuery`, `zodSearchTimelineUserId`: `@vitnode/core/api/modules/search/search.schema`
everything else: unchanged | +| `@vitnode/core/api/modules/users/avatar-color` | `@vitnode/core/api/modules/users/lib/avatar-color` | +| `@vitnode/core/api/modules/users/credential-limits` | `@vitnode/core/api/modules/users/lib/credential-limits` | +| `@vitnode/core/api/modules/users/passkeys/failure` | `@vitnode/core/api/modules/users/passkeys/lib/failure` | +| `@vitnode/core/api/modules/users/passkeys/schema` | `@vitnode/core/api/modules/users/passkeys/passkeys.schema` | +| `@vitnode/core/api/modules/users/presence-schema` | `@vitnode/core/api/modules/users/presence.schema` | +| `@vitnode/core/api/modules/users/routes/privacy.route` | `@vitnode/core/api/modules/users/routes/update-privacy.route` | +| `@vitnode/core/api/modules/users/routes/reset-passowrd.route` | `@vitnode/core/api/modules/users/routes/reset-password.route` | +| `@vitnode/core/api/modules/users/routes/time-zone.route` | `@vitnode/core/api/modules/users/routes/update-time-zone.route` | +| `@vitnode/core/api/modules/users/sso/connections/failure` | `@vitnode/core/api/modules/users/sso/connections/lib/failure` | +| `@vitnode/core/api/modules/users/sso/connections/routes/import.route` | `ssoApplyImportRoute`, `zodSsoApplyImportSchema`: `@vitnode/core/api/modules/users/sso/connections/routes/apply-import.route`
`ssoDiscardImportRoute`: `@vitnode/core/api/modules/users/sso/connections/routes/discard-import.route`
`ssoImportPreviewRoute`: `@vitnode/core/api/modules/users/sso/connections/routes/preview-import.route` | +| `@vitnode/core/api/modules/users/sso/connections/routes/preferences.route` | `zodSsoPreferencesSchema`: `@vitnode/core/api/modules/users/sso/connections/connections.schema`
`ssoPreferencesRoute`: `@vitnode/core/api/modules/users/sso/connections/routes/update-preferences.route` | +| `@vitnode/core/api/modules/users/sso/connections/schema` | `@vitnode/core/api/modules/users/sso/connections/connections.schema` | +| `@vitnode/core/api/modules/users/sso/routes/pending.route` | `ssoPendingConfirmRoute`, `zodSsoPendingConfirmSchema`: `@vitnode/core/api/modules/users/sso/routes/pending-confirm.route`
`ssoPendingEmailRoute`, `zodSsoPendingEmailSchema`: `@vitnode/core/api/modules/users/sso/routes/pending-email.route` | + +## @vitnode/blog + +| Former import | Import from | +| --- | --- | +| `@vitnode/blog/api/modules/admin/ai/ai.admin.module` | `@vitnode/blog/api/modules/admin/ai/ai.module` | +| `@vitnode/blog/api/modules/categories/categories.module` | `zodBlogCategorySummary`: `@vitnode/blog/api/modules/categories/routes/list.route`
everything else: unchanged | + +## Related + + + + + diff --git a/apps/web/content/docs/dev/plugins/api/resources.mdx b/apps/web/content/docs/dev/plugins/api/resources.mdx new file mode 100644 index 0000000000..aaac2ade57 --- /dev/null +++ b/apps/web/content/docs/dev/plugins/api/resources.mdx @@ -0,0 +1,148 @@ +--- +title: Generate an API resource +description: Scaffold a VitNode API resource - module, Zod schemas, service stubs and list, show, create, update and delete routes - in one step with vitnode generate resource. +icon: Layers +--- + +`vitnode g resource --kind api` writes everything a CRUD endpoint set needs, wired together: a module, shared Zod schemas, a service with one typed function per operation, and five routes that call it. Nothing is stored yet: every route answers `501 Not Implemented` until you implement the service, so the scaffold never returns made-up data. + +## Before you begin + +- A plugin made with [Create plugin](/docs/dev/plugins/create), with a terminal in `plugins/site-notes`, and no `notes` module yet. A resource needs a new or empty module. + + + + + +### Generate the resource + +Describe the fields with `--fields name:type` (`string`, `number`, `int`, `boolean`; a `?` after the name makes one optional), and pick the id type: + +```bash +pnpm vitnode g resource notes --kind api --fields "title:string,body:string,pinned?:boolean" --id-type number +``` + +```txt + ✓ created src/api/modules/notes/notes.module.ts module "notes"; register the notes resource routes + ✓ created src/api/modules/notes/notes.schema.ts schemas for the notes resource + ✓ created src/api/modules/notes/notes.service.ts service stubs for the notes resource + ✓ created src/api/modules/notes/routes/list.route.ts GET /notes + ✓ created src/api/modules/notes/routes/show.route.ts GET /notes/{id} + ✓ created src/api/modules/notes/routes/create.route.ts POST /notes + ✓ created src/api/modules/notes/routes/update.route.ts PATCH /notes/{id} + ✓ created src/api/modules/notes/routes/delete.route.ts DELETE /notes/{id} + ✓ updated src/config.api.ts register notesModule in buildApiPlugin +``` + +The module, schemas and service sit together in `src/api/modules/notes/`, and each route has its own file in `routes/`, importing `../notes.schema` and `../notes.service`. Without `--fields`, an interactive terminal asks for them. In CI the schemas model only the `id`, and a comment in `notes.schema.ts` says to add the rest: the generator never guesses fields such as `title` from a name. The id is a string unless you pass `--id-type number`. + + + + + +### Read the contract + +`notes.schema.ts` is the single source of the resource's shape. The routes validate with it and the service's input types are inferred from it, so there are no parallel DTO interfaces: + +```ts title="plugins/site-notes/src/api/modules/notes/notes.schema.ts" +import { z } from "@hono/zod-openapi"; + +/** One notes entry, as the API returns it. */ +export const zodNotesItem = z.object({ + id: z.number().int().positive(), + title: z.string(), + body: z.string(), + pinned: z.boolean().optional(), +}); + +/** What creating a notes entry accepts. */ +export const zodNotesCreateBody = z.object({ + title: z.string(), + body: z.string(), + pinned: z.boolean().optional(), +}); + +/** What updating a notes entry accepts: any subset of the create fields. */ +export const zodNotesUpdateBody = zodNotesCreateBody.partial(); + +export type NotesItem = z.infer; +export type NotesCreateInput = z.infer; +export type NotesUpdateInput = z.infer; +``` + +| Route | Method and path | Success | Also answers | +| -------- | -------------------- | ------- | ------------------------------------ | +| `list` | `GET /notes` | `200` | `501` | +| `show` | `GET /notes/{id}` | `200` | `400` invalid id, `404`, `501` | +| `create` | `POST /notes` | `201` | `400` invalid body, `501` | +| `update` | `PATCH /notes/{id}` | `200` | `400`, `404`, `501` | +| `delete` | `DELETE /notes/{id}` | `204` | `400`, `404`, `501` | + + + + + +### Implement the service + +Each operation in `notes.service.ts` is a typed stub that rejects with `501`: + +```ts title="plugins/site-notes/src/api/modules/notes/notes.service.ts" +export const createNotesService = (deps: NotesServiceDeps) => ({ + deps, + list: async (): Promise => notImplemented("list"), + /** Resolves `null` when there is no entry with this id. */ + get: async (_id: number): Promise => notImplemented("get"), + // create, update, remove... +}); +``` + +Keep the signatures and replace the bodies, one operation at a time. The routes already turn `null` into `404` and `false` from `remove` into `404`. With a `site_notes` table from the [Database](/docs/dev/database) lesson, `list` becomes: + +```ts title="plugins/site-notes/src/api/modules/notes/notes.service.ts" +list: async (): Promise => + deps.db + .select({ id: site_notes.id, title: site_notes.title, body: site_notes.body }) + .from(site_notes), +``` + +The generator does not create the table or a migration. [Add a service](/docs/dev/plugins/api/services) explains where the service's dependencies come from. + + + + + +## Check the result + +Build the plugin and call `GET /notes` with the temporary `fetcher` button from [Generate an endpoint](/docs/dev/plugins/api/generate-route#call-it-from-a-page), using `module: "notes"` and `path: "/"`. Before you implement `list` it answers `501` with `notes: list is not implemented yet`; afterwards, the rows. Invalid input answers `400` either way, because validation runs before the service. Through `fetcher`, TypeScript already rejects a body such as `{ "title": 1 }` at compile time. + +## Admin resources + +`--admin` generates the resource under `admin/` and guards each operation with its own staff permission, so `can_create` never ends up guarding a delete: + +```bash +pnpm vitnode g resource notes --admin --kind api --permission-module notes --declare-permission --parents +``` + +| Operations | Permission | +| -------------- | ------------------ | +| `list`, `show` | `notes.can_view` | +| `create` | `notes.can_create` | +| `update` | `notes.can_edit` | +| `delete` | `notes.can_delete` | + +Without `--declare-permission` the command lists the permissions missing from `permissionStaff.admin` and writes nothing. With it, they are added to `config.api.ts` with their AdminCP labels. A staff permission decides who may call an operation, not which entries they may change: check ownership in the service or handler when it matters. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/plugins/api/response-fields.mdx b/apps/web/content/docs/dev/plugins/api/response-fields.mdx new file mode 100644 index 0000000000..ecdb58b8bb --- /dev/null +++ b/apps/web/content/docs/dev/plugins/api/response-fields.mdx @@ -0,0 +1,83 @@ +--- +title: Return only allowed fields +description: Send only the fields a VitNode route's response schema declares with respondJson, so a database row's extra columns never reach the client and the fetcher keeps its types. +icon: ListFilter +--- + +`c.json(row, 200)` sends every property of `row`, including columns the response schema never mentions. `respondJson` from `@vitnode/core/api/lib/respond-json` copies only the fields the schema declares, at every depth, then validates the result. In this guide the `site-notes` detail route reads whole rows and still answers with `id`, `title` and `body` only. + +## Before you begin + +- The `GET /notes/{id}` route reading the `site_notes` table, from [Database](/docs/dev/database). + + + + + +### Answer with `respondJson` + +In `src/api/modules/notes/routes/show.route.ts`, select the whole row and pass the response schema the route already declares for `200`: + +```ts title="plugins/site-notes/src/api/modules/notes/routes/show.route.ts" +import { respondJson } from "@vitnode/core/api/lib/respond-json"; // [!code ++] + + handler: async c => { + const { id } = c.req.valid("param"); + const [note] = await c + .get("db") + .select({ id: site_notes.id, title: site_notes.title, body: site_notes.body }) // [!code --] + .select() // [!code ++] + .from(site_notes) + .where(eq(site_notes.id, id)); + if (!note) return c.body(null, 404); + + return c.json(note, 200); // [!code --] + return respondJson(c, 200, zodNote, note); // [!code ++] + }, +``` + +The row now carries `createdAt`, which `zodNote` does not declare, and `respondJson` leaves it out. Use the same schema as the route's `responses[200]`: the handler's return type stays exactly what `c.json(note, 200)` gave, so pages calling this route with `fetcher` keep the same inferred body. Headers and cookies set on `c` are sent as usual. + + + + + +## Check the result + +```bash +curl -i http://localhost:3000/api/site-notes/notes/2 +``` + +The status is `200` and the body is `{"id":2,"title":"Office closed on Friday","body":"..."}`, without `createdAt`. Add a column to `site_notes` later and it still stays out of this response until you add it to `zodNote`. + +## How fields are chosen + +- **Objects** keep only the keys in their shape, even `z.looseObject()` or an object with `.catchall()`. Nested objects, arrays, records and tuples are projected the same way. An object schema accepts plain objects and `Object.create(null)` objects only: a class instance is refused rather than copied, so map it to a plain object first. +- **Optional and nullable** fields: a missing or `undefined` value stays missing and `null` stays `null`. Nothing is filled in. +- **Unions** use the first option that accepts the projected value, so a discriminated union keeps the fields of the matching branch only. When no option accepts its projected value, the response is refused; the original value is never sent. +- **Plain JSON schemas only.** A schema with `.transform()`, `.pipe()`, `.default()`, `.catch()`, `z.lazy()`, an intersection, or a `bigint`, `map` or `set` is refused, because its output is computed rather than copied. `z.date()` is accepted and sent as an ISO string, as with `c.json`. + +## When the value does not match + +After projecting, `respondJson` validates the result. A required field that is missing or has the wrong type throws `ResponseProjectionError`, which VitNode answers with `500`: the server broke its own contract, so the client is not told its request was wrong. The logged message names the path and the issue code, such as `response.title: invalid_type (expected string)`, never the value. + + + `respondJson` removes fields the schema does not declare. It does not decide + who may see the fields it does declare. Check access first, and give viewers + who may see less their own response schema. + + +## Reference + +All exports of `@vitnode/core/api/lib/respond-json`: + +| Export | Signature | Behavior | +| ------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------- | +| `respondJson` | `(c, status, schema, value) => TypedResponse` | `c.json(projectResponse(schema, value), status)`, typed like `c.json` for that status. | +| `projectResponse` | `(schema, value) => z.output` | Projects and validates without sending, for example inside a service. Universal. | +| `ResponseProjectionError` | `Error` with `reason` | Thrown for a mismatch or an unsupported schema. | + +## Related + +- [API routes](/docs/dev/plugins/api/routes): declaring a route's responses. +- [Fetcher](/docs/dev/fetcher): calling the route from a page. diff --git a/apps/web/content/docs/dev/plugins/api/routes.mdx b/apps/web/content/docs/dev/plugins/api/routes.mdx index cc2b171177..6d69dc9a64 100644 --- a/apps/web/content/docs/dev/plugins/api/routes.mdx +++ b/apps/web/content/docs/dev/plugins/api/routes.mdx @@ -6,9 +6,13 @@ icon: Route Each route declares its inputs and outputs with Zod, and VitNode validates the request before your handler runs. In this lesson you add `GET /api/site-notes/notes/{id}`, which returns one note, refuses an id that is not a positive number, and answers `404` when no note has that id. + + `pnpm vitnode g route notes/show --id-type number` scaffolds a starter for this route (sample data, no `404` handling yet) and registers it. See [Generate an endpoint](/docs/dev/plugins/api/generate-route). + + ## Before you begin -- The `notes` module from [API modules](/docs/dev/plugins/api/modules), still returning the sample notes in `sample-notes.ts`. +- The `notes` module from [API modules](/docs/dev/plugins/api/modules), still returning the sample notes in `lib/sample-notes.ts`. @@ -16,15 +20,15 @@ Each route declares its inputs and outputs with Zod, and VitNode validates the r ### Create the route -Create `src/api/modules/notes/show.route.ts`: +Create `src/api/modules/notes/routes/show.route.ts`, next to the list route: -```ts title="plugins/site-notes/src/api/modules/notes/show.route.ts" +```ts title="plugins/site-notes/src/api/modules/notes/routes/show.route.ts" import { z } from "@hono/zod-openapi"; import { buildRoute } from "@/api/builders"; -import { zodNote } from "./notes.schema"; -import { sampleNotes } from "./sample-notes"; +import { sampleNotes } from "../lib/sample-notes"; +import { zodNote } from "../notes.schema"; export const showNoteRoute = buildRoute({ route: { @@ -69,8 +73,8 @@ Open `src/api/modules/notes/notes.module.ts` and add the route after the list ro ```ts title="plugins/site-notes/src/api/modules/notes/notes.module.ts" import { buildModule } from "@/api/builders"; -import { listNotesRoute } from "./list.route"; -import { showNoteRoute } from "./show.route"; // [!code ++] +import { listNotesRoute } from "./routes/list.route"; +import { showNoteRoute } from "./routes/show.route"; // [!code ++] export const notesModule = buildModule({ name: "notes", diff --git a/apps/web/content/docs/dev/plugins/api/services.mdx b/apps/web/content/docs/dev/plugins/api/services.mdx new file mode 100644 index 0000000000..377c603693 --- /dev/null +++ b/apps/web/content/docs/dev/plugins/api/services.mdx @@ -0,0 +1,131 @@ +--- +title: Add a service +description: Generate a plain service factory for a VitNode API module, give it explicit dependencies, and call it from a route or a background job without the Hono context. +icon: Workflow +--- + +A service holds a module's business operations - list the notes, publish one - as plain functions with explicit inputs. Routes keep the HTTP work: validating the request, checking permissions, choosing a status code. `vitnode g service` creates the file; there is no base class, no decorator and no container to register it with. + +## Before you begin + +- The `notes` module with its `list` route from [Generate an endpoint](/docs/dev/plugins/api/generate-route), with a terminal in `plugins/site-notes`. +- The `site_notes` table and a few rows in it: create `src/database/notes.ts`, migrate and add rows as in the first steps of [Database](/docs/dev/database). + + + + + +### Generate the service + +```bash +pnpm vitnode g service notes +``` + +This creates `src/api/modules/notes/notes.service.ts` next to `notes.module.ts` and changes nothing else. Existing routes keep working as they are: + +```ts title="plugins/site-notes/src/api/modules/notes/notes.service.ts" +import type { EnvVariablesVitNode } from "@vitnode/core/api/middlewares/global.middleware"; + +/** What the notes service needs, passed in by whoever builds it. */ +export interface NotesServiceDeps { + /** In a route: `c.get("db")`. */ + db: Omit; +} + +/** + * Business operations for the `notes` module. + * + * Generated empty - it has no operations yet. ... + */ +export const createNotesService = (deps: NotesServiceDeps) => ({ deps }); +``` + +The scaffold has no operations, and says so. `db` leaves out the driver client (`$client`), so a caller can pass a transaction as well as `c.get("db")`. + + + + + +### Add an operation + +Replace the factory's body with real operations that take plain, typed arguments. When a route already validates an input with a Zod schema, type the argument with `z.infer` rather than writing a second interface. This one reads the `site_notes` table: + +```ts title="plugins/site-notes/src/api/modules/notes/notes.service.ts" +import type { EnvVariablesVitNode } from "@vitnode/core/api/middlewares/global.middleware"; + +import { desc } from "drizzle-orm"; + +import { site_notes } from "@/database/notes"; + +export interface NotesServiceDeps { + db: Omit; +} + +export const createNotesService = ({ db }: NotesServiceDeps) => ({ + list: async ({ limit }: { limit: number }) => + db + .select({ id: site_notes.id, title: site_notes.title, body: site_notes.body }) + .from(site_notes) + .orderBy(desc(site_notes.id)) + .limit(limit), +}); +``` + + + + + +### Call it from a route + +Build the service inside the handler of `routes/list.route.ts`, from what the request holds, and import it with `import { createNotesService } from "../notes.service";`: + +```ts title="plugins/site-notes/src/api/modules/notes/routes/list.route.ts" +handler: async c => { + const notes = await createNotesService({ db: c.get("db") }).list({ limit: 20 }); + + return c.json(notes, 200); +}, +``` + +Replace the route's sample `200` schema with `z.array(z.object({ id: z.number(), title: z.string(), body: z.string() }))`, which is what `list` returns. + + + + + +## Check the result + +Reuse the `CheckNotes` button from [Generate an endpoint](/docs/dev/plugins/api/generate-route#call-it-from-a-page), changed to `path: "/"` with no `args`. Pressing it now shows the rows from `site_notes` instead of sample data. TypeScript checks the route's schema against what the service returns. + +## Where dependencies come from + +Build a service where its dependencies are, and pass them in: + +| Dependency | Lifetime | Where it comes from | +| -------------------------------------------- | -------------------------------- | --------------------------------------------------------------------- | +| The database client | Shared by the whole app | `c.get("db")` in a route, or in a cron or queue handler, which gets a context too | +| The acting user | One request | Read in the route, passed as an argument such as `actor: { id }` | +| Permission decisions | One request | Checked in the route (`adminStaffPermission`, `checkStaffPermission`) | +| A transaction | One operation | Opened by the caller and passed in place of `db` | + +- **Pass values, not the context.** `createNotesService({ db })` works in a route, a [cron job](/docs/dev/cron), a queue task or a script alike, and a caller can hand it a transaction instead of `db`. A service that takes the whole context ties every caller to it. +- **Keep request state out of module scope.** A service built once at import time would share one user, or one transaction, between requests. Building it per call is cheap: it is an object of functions. +- **Pass the actor explicitly** when an operation depends on who does it, rather than reading a session inside the service. +- **Errors.** Throw `HTTPException` from `hono/http-exception` for an outcome the route should answer with a status, as VitNode's own services do. There is no separate error hierarchy to learn. + +The generator never rewrites existing handlers to use a service: which route should call which operation, with which arguments, is your decision. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/plugins/create.mdx b/apps/web/content/docs/dev/plugins/create.mdx index bb8a6ccb33..324526bbc2 100644 --- a/apps/web/content/docs/dev/plugins/create.mdx +++ b/apps/web/content/docs/dev/plugins/create.mdx @@ -67,8 +67,10 @@ The new package, with the files the next lessons change: + + + - diff --git a/apps/web/content/docs/dev/storage/index.mdx b/apps/web/content/docs/dev/storage/index.mdx index 110639c0d0..3a7c5d846c 100644 --- a/apps/web/content/docs/dev/storage/index.mdx +++ b/apps/web/content/docs/dev/storage/index.mdx @@ -17,9 +17,9 @@ icon: HardDrive ### Create the upload route -Create `src/api/modules/admin/notes/upload-image.route.ts`: +Create `src/api/modules/admin/notes/routes/upload-image.route.ts`: -```ts title="plugins/site-notes/src/api/modules/admin/notes/upload-image.route.ts" +```ts title="plugins/site-notes/src/api/modules/admin/notes/routes/upload-image.route.ts" import { z } from "@hono/zod-openapi"; import { buildRoute } from "@/api/builders"; @@ -90,9 +90,9 @@ export const uploadNoteImageRoute = buildRoute({ ### Create the delete route -Create `src/api/modules/admin/notes/delete-image.route.ts`. It deletes by the file's **id**, the number `upload()` returned: +Create `src/api/modules/admin/notes/routes/delete-image.route.ts`. It deletes by the file's **id**, the number `upload()` returned: -```ts title="plugins/site-notes/src/api/modules/admin/notes/delete-image.route.ts" +```ts title="plugins/site-notes/src/api/modules/admin/notes/routes/delete-image.route.ts" import { z } from "@hono/zod-openapi"; import { buildRoute } from "@/api/builders"; diff --git a/apps/web/content/docs/dev/swagger.mdx b/apps/web/content/docs/dev/swagger.mdx index c6b3974a41..a6568dda8c 100644 --- a/apps/web/content/docs/dev/swagger.mdx +++ b/apps/web/content/docs/dev/swagger.mdx @@ -50,12 +50,12 @@ route of the `site-notes` plugin. ### Describe the route -In `src/api/modules/notes/show.route.ts`, add a `description` and an `example` +In `src/api/modules/notes/routes/show.route.ts`, add a `description` and an `example` for the parameter. `description` becomes the operation's summary line, and every schema field can carry an `example`. This is the whole difference between a spec people use and a spec people ignore: -```ts title="plugins/site-notes/src/api/modules/notes/show.route.ts" +```ts title="plugins/site-notes/src/api/modules/notes/routes/show.route.ts" export const showNoteRoute = buildRoute({ route: { method: 'get', diff --git a/apps/web/content/docs/dev/working-with-users/meta.json b/apps/web/content/docs/dev/working-with-users/meta.json index 132a97703b..e230718c4b 100644 --- a/apps/web/content/docs/dev/working-with-users/meta.json +++ b/apps/web/content/docs/dev/working-with-users/meta.json @@ -10,6 +10,7 @@ "email-verification", "roles", "staff-permissions", + "staff-permission-refs", "presence", "presence-in-plugins", "..." diff --git a/apps/web/content/docs/dev/working-with-users/staff-permission-refs.mdx b/apps/web/content/docs/dev/working-with-users/staff-permission-refs.mdx new file mode 100644 index 0000000000..07327d1ded --- /dev/null +++ b/apps/web/content/docs/dev/working-with-users/staff-permission-refs.mdx @@ -0,0 +1,117 @@ +--- +title: Typed permission references +description: Declare a VitNode plugin's staff permissions once with defineStaffPermissions and reference them from routes and AdminCP navigation, so a typo is a compile error. +icon: KeyRound +--- + +`defineStaffPermissions` turns your plugin's permission declarations into typed references. Routes write `adminStaffPermission: staffPermissions.admin.notes.canCreate` instead of repeating `{ module: "notes", permission: "can_create" }`, and a permission that is not declared does not compile. It is optional: string declarations keep working, and you can switch one plugin at a time. + +## Before you begin + +- A plugin with declared staff permissions, such as `site-notes` from [Permissions](/docs/dev/working-with-users/staff-permissions). +- For the navigation step and the check, the AdminCP sidebar link in `src/admin/nav.tsx` from [AdminCP page](/docs/dev/plugins/admin). Without that file there is no navigation to reference, and `plugin validate` skips its navigation check. + + + + + +### Define the permissions + +Create `src/staff-permissions.ts` and move the declarations from `permissionStaff` into it, unchanged. With the Permissions lesson's admin and moderator examples, that is: + +```ts title="plugins/site-notes/src/staff-permissions.ts" +import { defineStaffPermissions } from "@vitnode/core/lib/staff-permissions"; + +export const staffPermissions = defineStaffPermissions({ + admin: { + notes: ["can_create"], + }, + moderator: { + notes: ["can_delete"], + }, +}); +``` + +Entries are the same strings and `{ permission, dependsOn, scope }` objects `permissionStaff` takes, so a permission with dependencies moves over as it is. The module imports nothing from the server, so the AdminCP navigation can use it too. Keep it out of `config.api.ts`: that file pulls in your routes and database code, and a route importing from it would be a circular import. + + + + + +### Register them + +Pass `config` to `buildApiPlugin`. This excerpt replaces the inline object: + +```ts title="plugins/site-notes/src/config.api.ts" +import { staffPermissions } from "@/staff-permissions"; // [!code ++] + +export const siteNotesApiPlugin = () => + buildApiPlugin({ + manifest, + modules: [helloModule, notesModule, adminModule], + permissionStaff: { admin: { ... }, moderator: { ... } }, // [!code --] + permissionStaff: staffPermissions.config, // [!code ++] + }); +``` + +`config` is exactly the declarations you wrote, so the AdminCP permission editor and its labels do not change. + + + + + +### Reference them + +Each permission key becomes camelCase under its module: `can_create` is `canCreate`. Two different permission names in one module that would share a reference, such as `can_edit` and `can-edit` (both `canEdit`), make `defineStaffPermissions` throw: `admin permissions in module "notes" collide on reference key "canEdit"`, followed by both names and what to rename. Listing the same name twice is fine. A reference is a plain `{ module, permission }` object, which is what routes and navigation already accept: + +```ts title="plugins/site-notes/src/api/modules/admin/notes/routes/create.route.ts" +import { staffPermissions } from "@/staff-permissions"; + +export const createNoteRoute = buildRoute({ + adminStaffPermission: staffPermissions.admin.notes.canCreate, + // ... +}); +``` + +```tsx title="plugins/site-notes/src/admin/nav.tsx" +import { staffPermissions } from "@/staff-permissions"; + +// ... + { + id: "notes", + href: "/admin/site-notes", + icon: , + permission: staffPermissions.admin.notes.canCreate, + }, +``` + +For moderator routes use `moderatorStaffPermission: staffPermissions.moderator.notes.canDelete`. + + + + + +## Check the result + +Misspell a reference, for example `staffPermissions.admin.notes.canCreat`. TypeScript reports `Property 'canCreat' does not exist`, before anything runs. Fix it, build the plugin and run `pnpm vitnode plugin validate`: the **AdminCP navigation** check passes, because the navigation names a declared permission. + +References name a permission; they do not choose the staff type. TypeScript accepts a moderator reference in `adminStaffPermission` just as it accepts any `{ module, permission }` object, so take admin routes' references from `staffPermissions.admin`. [`vitnode doctor`](/docs/dev/cli/doctor) reports a route whose permission is not declared for its type. + +## With the generator + +When `permissionStaff` is `staffPermissions.config`, `vitnode g route ... --permission notes.can_create` checks the permission against `src/staff-permissions.ts`, adds it there with `--declare-permission`, and writes the route with `adminStaffPermission: staffPermissions.admin.notes.canCreate`. See [Protect an admin endpoint](/docs/dev/plugins/api/admin-routes). + +## Related + + + + + diff --git a/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx b/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx index 7ad85ebba1..20ab5ebd31 100644 --- a/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx +++ b/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx @@ -73,15 +73,15 @@ The AdminCP permission editor shows these names: **Site Notes** in its plugin li ### Guard the create route -Admin endpoints live under an `admin` module, so their URLs contain `/admin/`. VitNode requires an AdminCP session for every such URL; `adminStaffPermission` adds the per-permission check. Create `src/api/modules/admin/notes/create.route.ts`: +Admin endpoints live under an `admin` module, so their URLs contain `/admin/`. VitNode requires an AdminCP session for every such URL; `adminStaffPermission` adds the per-permission check. Create `src/api/modules/admin/notes/routes/create.route.ts`: -```ts title="plugins/site-notes/src/api/modules/admin/notes/create.route.ts" +```ts title="plugins/site-notes/src/api/modules/admin/notes/routes/create.route.ts" import { z } from "@hono/zod-openapi"; import { buildRoute } from "@/api/builders"; import { site_notes } from "@/database/notes"; -import { zodNote } from "../../notes/notes.schema"; +import { zodNote } from "../../../notes/notes.schema"; export const createNoteRoute = buildRoute({ // [!code ++] @@ -134,7 +134,7 @@ Create the two modules that put the route at `admin/notes`: ```ts title="plugins/site-notes/src/api/modules/admin/notes/notes.module.ts" import { buildModule } from "@/api/builders"; -import { createNoteRoute } from "./create.route"; +import { createNoteRoute } from "./routes/create.route"; export const adminNotesModule = buildModule({ name: "notes", @@ -259,6 +259,10 @@ permissionStaff: { Each permission you add needs its own label and its own `adminStaffPermission` on the routes it protects. +## Reference permissions with types + +To stop repeating `{ module: "notes", permission: "can_create" }` in routes and navigation, declare the permissions once with `defineStaffPermissions` and use `staffPermissions.admin.notes.canCreate` instead. See [Typed permission references](/docs/dev/working-with-users/staff-permission-refs). + ## Check a permission in code `adminStaffPermission` and `moderatorStaffPermission` cover a whole route. For a decision inside a handler, such as letting staff edit any note but members only their own, call `checkStaffPermission(c, { type, plugin, module, permission })` from `@vitnode/core/api/lib/check-staff-permission`; it resolves to `true` or `false`. In AdminCP components, `useAdminPermission({ plugin, module, permission })` from `@vitnode/core/tanstack/admin` answers the same question for the signed-in administrator, for showing or hiding controls. @@ -297,7 +301,7 @@ All three check the signed-in member, and a signed-out visitor never passes. `moderatorStaffPermission` works like `adminStaffPermission`, but checks the member's site session. Anyone who is not a moderator with **Delete notes** gets `403` before the handler runs: -```ts title="plugins/site-notes/src/api/modules/notes/delete.route.ts" +```ts title="plugins/site-notes/src/api/modules/notes/routes/delete.route.ts" import { z } from "@hono/zod-openapi"; import { eq } from "drizzle-orm"; @@ -351,7 +355,7 @@ Signed in as the moderator, `response.status` is `204`; for any other member it The public note route answers everyone, and adds when the note was written for moderators with **See note details**. `checkStaffPermission` resolves to `true` or `false` instead of refusing: -```ts title="plugins/site-notes/src/api/modules/notes/show.route.ts" +```ts title="plugins/site-notes/src/api/modules/notes/routes/show.route.ts" import { z } from "@hono/zod-openapi"; import { checkStaffPermission } from "@vitnode/core/api/lib/check-staff-permission"; import { eq } from "drizzle-orm"; @@ -360,7 +364,7 @@ import { buildRoute } from "@/api/builders"; import { site_notes } from "@/database/notes"; import { manifest } from "@/manifest.gen"; -import { zodNote } from "./notes.schema"; +import { zodNote } from "../notes.schema"; export const showNoteRoute = buildRoute({ route: { @@ -414,7 +418,7 @@ export const showNoteRoute = buildRoute({ `assertStaffPermission` takes the same arguments and throws `403` instead of returning `false`. Use it when a member asks for something only moderators may have, here the details behind `?details=1`, while the plain request stays public: -```ts title="plugins/site-notes/src/api/modules/notes/show.route.ts" +```ts title="plugins/site-notes/src/api/modules/notes/routes/show.route.ts" import { assertStaffPermission } from "@vitnode/core/api/lib/check-staff-permission"; // request: diff --git a/apps/web/content/docs/dev/working-with-users/users.mdx b/apps/web/content/docs/dev/working-with-users/users.mdx index 9a928fcb53..6fee0788c6 100644 --- a/apps/web/content/docs/dev/working-with-users/users.mdx +++ b/apps/web/content/docs/dev/working-with-users/users.mdx @@ -10,7 +10,7 @@ VitNode resolves the session cookie before your code runs, so the signed-in user `c.get("user")` returns the signed-in user, or `null` for a guest. This route answers `401` for guests and returns a few fields for everybody else: -```ts title="plugins/site-notes/src/api/modules/hello/me.route.ts" +```ts title="plugins/site-notes/src/api/modules/hello/routes/me.route.ts" import { z } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; diff --git a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts index 5ee3458ef0..44e7d1a970 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts @@ -271,7 +271,9 @@ describe("the generated API module", () => { it("registers the route's own export, not a second copy", () => { const module = pluginApiModuleTemplate(); - expect(module).toContain('import { helloRoute } from "./hello.route";'); + expect(module).toContain( + 'import { helloRoute } from "./routes/hello.route";', + ); expect(module).toContain("routes: [helloRoute],"); }); }); @@ -418,7 +420,7 @@ describe("the scaffold as a whole", () => { "src/api/modules/hello/hello.module.ts", ); expect(Object.keys(files)).toContain( - "src/api/modules/hello/hello.route.ts", + "src/api/modules/hello/routes/hello.route.ts", ); expect(Object.keys(files)).toContain("src/config.api.ts"); expect(Object.keys(files)).toContain("src/api/builders.ts"); diff --git a/packages/create-vitnode-app/src/plugin/create/route-templates.ts b/packages/create-vitnode-app/src/plugin/create/route-templates.ts index 862217fa85..2187245f63 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.ts @@ -235,7 +235,7 @@ export const helloRoute = buildRoute({ export const pluginApiModuleTemplate = (): string => `import { buildModule } from "@/api/builders"; -import { helloRoute } from "./hello.route"; +import { helloRoute } from "./routes/hello.route"; export const helloModule = buildModule({ name: "hello", @@ -363,7 +363,7 @@ export const pluginRouteScaffold = ( "src/api/modules/hello/hello.module.test.ts": pluginApiModuleTestTemplate(pluginName), "src/api/modules/hello/hello.module.ts": pluginApiModuleTemplate(), - "src/api/modules/hello/hello.route.ts": pluginApiRouteTemplate(), + "src/api/modules/hello/routes/hello.route.ts": pluginApiRouteTemplate(), "src/config.api.ts": pluginApiConfigTemplate(pluginName), "src/config.tsx": pluginConfigTemplate(pluginName), "src/locales/en.json": pluginMessagesTemplate(pluginName), diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 5553a8eb3b..bf7e0f6a6e 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -217,6 +217,396 @@ "types": "./dist/src/tanstack/*/server.d.ts", "default": "./dist/src/tanstack/*/server.js" }, + "./api/lib/ai/run-schemas": { + "import": "./dist/src/api/lib/ai/run.schema.js", + "types": "./dist/src/api/lib/ai/run.schema.d.ts", + "default": "./dist/src/api/lib/ai/run.schema.js" + }, + "./api/modules/admin/activity-logs/activity-logs.admin.module": { + "import": "./dist/src/api/modules/admin/activity-logs/activity-logs.module.js", + "types": "./dist/src/api/modules/admin/activity-logs/activity-logs.module.d.ts", + "default": "./dist/src/api/modules/admin/activity-logs/activity-logs.module.js" + }, + "./api/modules/admin/activity-logs/routes/detail.route": { + "import": "./dist/src/api/modules/admin/activity-logs/routes/show.route.js", + "types": "./dist/src/api/modules/admin/activity-logs/routes/show.route.d.ts", + "default": "./dist/src/api/modules/admin/activity-logs/routes/show.route.js" + }, + "./api/modules/admin/advanced/advanced.admin.module": { + "import": "./dist/src/api/modules/admin/advanced/advanced.module.js", + "types": "./dist/src/api/modules/admin/advanced/advanced.module.d.ts", + "default": "./dist/src/api/modules/admin/advanced/advanced.module.js" + }, + "./api/modules/admin/advanced/cron/cron.admin.module": { + "import": "./dist/src/api/modules/admin/advanced/cron/cron.module.js", + "types": "./dist/src/api/modules/admin/advanced/cron/cron.module.d.ts", + "default": "./dist/src/api/modules/admin/advanced/cron/cron.module.js" + }, + "./api/modules/admin/advanced/cron/routes/get.route": { + "import": "./dist/src/api/modules/admin/advanced/cron/routes/list.route.js", + "types": "./dist/src/api/modules/admin/advanced/cron/routes/list.route.d.ts", + "default": "./dist/src/api/modules/admin/advanced/cron/routes/list.route.js" + }, + "./api/modules/admin/advanced/queue/queue.admin.module": { + "import": "./dist/src/api/modules/admin/advanced/queue/queue.module.js", + "types": "./dist/src/api/modules/admin/advanced/queue/queue.module.d.ts", + "default": "./dist/src/api/modules/admin/advanced/queue/queue.module.js" + }, + "./api/modules/admin/advanced/queue/routes/get.route": { + "import": "./dist/src/api/modules/admin/advanced/queue/routes/list.route.js", + "types": "./dist/src/api/modules/admin/advanced/queue/routes/list.route.d.ts", + "default": "./dist/src/api/modules/admin/advanced/queue/routes/list.route.js" + }, + "./api/modules/admin/ai/ai.admin.module": { + "import": "./dist/src/api/modules/admin/ai/ai.module.js", + "types": "./dist/src/api/modules/admin/ai/ai.module.d.ts", + "default": "./dist/src/api/modules/admin/ai/ai.module.js" + }, + "./api/modules/admin/ai/routes/access.route": { + "import": "./dist/src/compat/api/modules/admin/ai/routes/access.route.js", + "types": "./dist/src/compat/api/modules/admin/ai/routes/access.route.d.ts", + "default": "./dist/src/compat/api/modules/admin/ai/routes/access.route.js" + }, + "./api/modules/admin/ai/routes/assist-stream.route": { + "import": "./dist/src/compat/api/modules/admin/ai/routes/assist-stream.route.js", + "types": "./dist/src/compat/api/modules/admin/ai/routes/assist-stream.route.d.ts", + "default": "./dist/src/compat/api/modules/admin/ai/routes/assist-stream.route.js" + }, + "./api/modules/admin/ai/routes/history.route": { + "import": "./dist/src/compat/api/modules/admin/ai/routes/history.route.js", + "types": "./dist/src/compat/api/modules/admin/ai/routes/history.route.d.ts", + "default": "./dist/src/compat/api/modules/admin/ai/routes/history.route.js" + }, + "./api/modules/admin/ai/routes/translation-sources.route": { + "import": "./dist/src/compat/api/modules/admin/ai/routes/translation-sources.route.js", + "types": "./dist/src/compat/api/modules/admin/ai/routes/translation-sources.route.d.ts", + "default": "./dist/src/compat/api/modules/admin/ai/routes/translation-sources.route.js" + }, + "./api/modules/admin/ai/schemas": { + "import": "./dist/src/api/modules/admin/ai/ai.schema.js", + "types": "./dist/src/api/modules/admin/ai/ai.schema.d.ts", + "default": "./dist/src/api/modules/admin/ai/ai.schema.js" + }, + "./api/modules/admin/dashboard/dashboard.admin.module": { + "import": "./dist/src/api/modules/admin/dashboard/dashboard.module.js", + "types": "./dist/src/api/modules/admin/dashboard/dashboard.module.d.ts", + "default": "./dist/src/api/modules/admin/dashboard/dashboard.module.js" + }, + "./api/modules/admin/debug/debug.admin.module": { + "import": "./dist/src/api/modules/admin/debug/debug.module.js", + "types": "./dist/src/api/modules/admin/debug/debug.module.d.ts", + "default": "./dist/src/api/modules/admin/debug/debug.module.js" + }, + "./api/modules/admin/files/files.admin.module": { + "import": "./dist/src/api/modules/admin/files/files.module.js", + "types": "./dist/src/api/modules/admin/files/files.module.d.ts", + "default": "./dist/src/api/modules/admin/files/files.module.js" + }, + "./api/modules/admin/files/routes/alt.route": { + "import": "./dist/src/compat/api/modules/admin/files/routes/alt.route.js", + "types": "./dist/src/compat/api/modules/admin/files/routes/alt.route.d.ts", + "default": "./dist/src/compat/api/modules/admin/files/routes/alt.route.js" + }, + "./api/modules/admin/notifications/notifications.admin.module": { + "import": "./dist/src/api/modules/admin/notifications/notifications.module.js", + "types": "./dist/src/api/modules/admin/notifications/notifications.module.d.ts", + "default": "./dist/src/api/modules/admin/notifications/notifications.module.js" + }, + "./api/modules/admin/notifications/routes/danger.route": { + "import": "./dist/src/compat/api/modules/admin/notifications/routes/danger.route.js", + "types": "./dist/src/compat/api/modules/admin/notifications/routes/danger.route.d.ts", + "default": "./dist/src/compat/api/modules/admin/notifications/routes/danger.route.js" + }, + "./api/modules/admin/notifications/routes/deliveries.route": { + "import": "./dist/src/compat/api/modules/admin/notifications/routes/deliveries.route.js", + "types": "./dist/src/compat/api/modules/admin/notifications/routes/deliveries.route.d.ts", + "default": "./dist/src/compat/api/modules/admin/notifications/routes/deliveries.route.js" + }, + "./api/modules/admin/notifications/routes/maintenance.route": { + "import": "./dist/src/compat/api/modules/admin/notifications/routes/maintenance.route.js", + "types": "./dist/src/compat/api/modules/admin/notifications/routes/maintenance.route.d.ts", + "default": "./dist/src/compat/api/modules/admin/notifications/routes/maintenance.route.js" + }, + "./api/modules/admin/notifications/routes/settings.route": { + "import": "./dist/src/api/modules/admin/notifications/routes/update-type-policy.route.js", + "types": "./dist/src/api/modules/admin/notifications/routes/update-type-policy.route.d.ts", + "default": "./dist/src/api/modules/admin/notifications/routes/update-type-policy.route.js" + }, + "./api/modules/admin/plugins/plugins.admin.module": { + "import": "./dist/src/api/modules/admin/plugins/plugins.module.js", + "types": "./dist/src/api/modules/admin/plugins/plugins.module.d.ts", + "default": "./dist/src/api/modules/admin/plugins/plugins.module.js" + }, + "./api/modules/admin/plugins/routes/updates.schema": { + "import": "./dist/src/compat/api/modules/admin/plugins/routes/updates.schema.js", + "types": "./dist/src/compat/api/modules/admin/plugins/routes/updates.schema.d.ts", + "default": "./dist/src/compat/api/modules/admin/plugins/routes/updates.schema.js" + }, + "./api/modules/admin/report-reasons/report-reasons.admin.module": { + "import": "./dist/src/api/modules/admin/report-reasons/report-reasons.module.js", + "types": "./dist/src/api/modules/admin/report-reasons/report-reasons.module.d.ts", + "default": "./dist/src/api/modules/admin/report-reasons/report-reasons.module.js" + }, + "./api/modules/admin/report-reasons/routes/report-reasons.routes": { + "import": "./dist/src/compat/api/modules/admin/report-reasons/routes/report-reasons.routes.js", + "types": "./dist/src/compat/api/modules/admin/report-reasons/routes/report-reasons.routes.d.ts", + "default": "./dist/src/compat/api/modules/admin/report-reasons/routes/report-reasons.routes.js" + }, + "./api/modules/admin/roles/roles.admin.module": { + "import": "./dist/src/api/modules/admin/roles/roles.module.js", + "types": "./dist/src/api/modules/admin/roles/roles.module.d.ts", + "default": "./dist/src/api/modules/admin/roles/roles.module.js" + }, + "./api/modules/admin/roles/routes/create.route": { + "import": "./dist/src/compat/api/modules/admin/roles/routes/create.route.js", + "types": "./dist/src/compat/api/modules/admin/roles/routes/create.route.d.ts", + "default": "./dist/src/compat/api/modules/admin/roles/routes/create.route.js" + }, + "./api/modules/admin/roles/routes/list-mapping": { + "import": "./dist/src/api/modules/admin/roles/lib/list-mapping.js", + "types": "./dist/src/api/modules/admin/roles/lib/list-mapping.d.ts", + "default": "./dist/src/api/modules/admin/roles/lib/list-mapping.js" + }, + "./api/modules/admin/routes/notifications.route": { + "import": "./dist/src/api/modules/admin/routes/send-notification.route.js", + "types": "./dist/src/api/modules/admin/routes/send-notification.route.d.ts", + "default": "./dist/src/api/modules/admin/routes/send-notification.route.js" + }, + "./api/modules/admin/staff/lib/schema": { + "import": "./dist/src/api/modules/admin/staff/staff.schema.js", + "types": "./dist/src/api/modules/admin/staff/staff.schema.d.ts", + "default": "./dist/src/api/modules/admin/staff/staff.schema.js" + }, + "./api/modules/admin/staff/staff.admin.module": { + "import": "./dist/src/api/modules/admin/staff/staff.module.js", + "types": "./dist/src/api/modules/admin/staff/staff.module.d.ts", + "default": "./dist/src/api/modules/admin/staff/staff.module.js" + }, + "./api/modules/admin/users/users.admin.module": { + "import": "./dist/src/api/modules/admin/users/users.module.js", + "types": "./dist/src/api/modules/admin/users/users.module.d.ts", + "default": "./dist/src/api/modules/admin/users/users.module.js" + }, + "./api/modules/admin/warnings/routes/warning-reasons.routes": { + "import": "./dist/src/compat/api/modules/admin/warnings/routes/warning-reasons.routes.js", + "types": "./dist/src/compat/api/modules/admin/warnings/routes/warning-reasons.routes.d.ts", + "default": "./dist/src/compat/api/modules/admin/warnings/routes/warning-reasons.routes.js" + }, + "./api/modules/admin/warnings/routes/warning-thresholds.routes": { + "import": "./dist/src/compat/api/modules/admin/warnings/routes/warning-thresholds.routes.js", + "types": "./dist/src/compat/api/modules/admin/warnings/routes/warning-thresholds.routes.d.ts", + "default": "./dist/src/compat/api/modules/admin/warnings/routes/warning-thresholds.routes.js" + }, + "./api/modules/admin/warnings/warnings.admin.module": { + "import": "./dist/src/api/modules/admin/warnings/warnings.module.js", + "types": "./dist/src/api/modules/admin/warnings/warnings.module.d.ts", + "default": "./dist/src/api/modules/admin/warnings/warnings.module.js" + }, + "./api/modules/ai/routes/stream.route": { + "import": "./dist/src/compat/api/modules/ai/routes/stream.route.js", + "types": "./dist/src/compat/api/modules/ai/routes/stream.route.d.ts", + "default": "./dist/src/compat/api/modules/ai/routes/stream.route.js" + }, + "./api/modules/content/helpers/execute-content-schedule": { + "import": "./dist/src/api/modules/content/lib/execute-content-schedule.js", + "types": "./dist/src/api/modules/content/lib/execute-content-schedule.d.ts", + "default": "./dist/src/api/modules/content/lib/execute-content-schedule.js" + }, + "./api/modules/cron/helpers/load-cron-health": { + "import": "./dist/src/api/modules/cron/lib/load-cron-health.js", + "types": "./dist/src/api/modules/cron/lib/load-cron-health.d.ts", + "default": "./dist/src/api/modules/cron/lib/load-cron-health.js" + }, + "./api/modules/cron/helpers/process-cron-jobs": { + "import": "./dist/src/api/modules/cron/lib/process-cron-jobs.js", + "types": "./dist/src/api/modules/cron/lib/process-cron-jobs.d.ts", + "default": "./dist/src/api/modules/cron/lib/process-cron-jobs.js" + }, + "./api/modules/cron/helpers/register-cron-jobs": { + "import": "./dist/src/api/modules/cron/lib/register-cron-jobs.js", + "types": "./dist/src/api/modules/cron/lib/register-cron-jobs.d.ts", + "default": "./dist/src/api/modules/cron/lib/register-cron-jobs.js" + }, + "./api/modules/cron/routes/cron.route": { + "import": "./dist/src/api/modules/cron/routes/run.route.js", + "types": "./dist/src/api/modules/cron/routes/run.route.d.ts", + "default": "./dist/src/api/modules/cron/routes/run.route.js" + }, + "./api/modules/middleware/route": { + "import": "./dist/src/compat/api/modules/middleware/route.js", + "types": "./dist/src/compat/api/modules/middleware/route.d.ts", + "default": "./dist/src/compat/api/modules/middleware/route.js" + }, + "./api/modules/moderation/lib/schema": { + "import": "./dist/src/api/modules/moderation/moderation.schema.js", + "types": "./dist/src/api/modules/moderation/moderation.schema.d.ts", + "default": "./dist/src/api/modules/moderation/moderation.schema.js" + }, + "./api/modules/moderation/lib/warnings-service": { + "import": "./dist/src/api/modules/moderation/lib/warnings.js", + "types": "./dist/src/api/modules/moderation/lib/warnings.d.ts", + "default": "./dist/src/api/modules/moderation/lib/warnings.js" + }, + "./api/modules/moderation/routes/account-status.route": { + "import": "./dist/src/compat/api/modules/moderation/routes/account-status.route.js", + "types": "./dist/src/compat/api/modules/moderation/routes/account-status.route.d.ts", + "default": "./dist/src/compat/api/modules/moderation/routes/account-status.route.js" + }, + "./api/modules/moderation/routes/approvals.routes": { + "import": "./dist/src/compat/api/modules/moderation/routes/approvals.routes.js", + "types": "./dist/src/compat/api/modules/moderation/routes/approvals.routes.d.ts", + "default": "./dist/src/compat/api/modules/moderation/routes/approvals.routes.js" + }, + "./api/modules/moderation/routes/create.route": { + "import": "./dist/src/api/modules/moderation/routes/create-report.route.js", + "types": "./dist/src/api/modules/moderation/routes/create-report.route.d.ts", + "default": "./dist/src/api/modules/moderation/routes/create-report.route.js" + }, + "./api/modules/moderation/routes/list.route": { + "import": "./dist/src/api/modules/moderation/routes/list-reports.route.js", + "types": "./dist/src/api/modules/moderation/routes/list-reports.route.d.ts", + "default": "./dist/src/api/modules/moderation/routes/list-reports.route.js" + }, + "./api/modules/moderation/routes/notes.routes": { + "import": "./dist/src/compat/api/modules/moderation/routes/notes.routes.js", + "types": "./dist/src/compat/api/modules/moderation/routes/notes.routes.d.ts", + "default": "./dist/src/compat/api/modules/moderation/routes/notes.routes.js" + }, + "./api/modules/moderation/routes/reasons.route": { + "import": "./dist/src/api/modules/moderation/routes/list-report-reasons.route.js", + "types": "./dist/src/api/modules/moderation/routes/list-report-reasons.route.d.ts", + "default": "./dist/src/api/modules/moderation/routes/list-report-reasons.route.js" + }, + "./api/modules/moderation/routes/show.route": { + "import": "./dist/src/api/modules/moderation/routes/show-report.route.js", + "types": "./dist/src/api/modules/moderation/routes/show-report.route.d.ts", + "default": "./dist/src/api/modules/moderation/routes/show-report.route.js" + }, + "./api/modules/moderation/routes/warnings.routes": { + "import": "./dist/src/compat/api/modules/moderation/routes/warnings.routes.js", + "types": "./dist/src/compat/api/modules/moderation/routes/warnings.routes.d.ts", + "default": "./dist/src/compat/api/modules/moderation/routes/warnings.routes.js" + }, + "./api/modules/moderation/routes/workflow.routes": { + "import": "./dist/src/compat/api/modules/moderation/routes/workflow.routes.js", + "types": "./dist/src/compat/api/modules/moderation/routes/workflow.routes.d.ts", + "default": "./dist/src/compat/api/modules/moderation/routes/workflow.routes.js" + }, + "./api/modules/notifications/routes/mark.route": { + "import": "./dist/src/compat/api/modules/notifications/routes/mark.route.js", + "types": "./dist/src/compat/api/modules/notifications/routes/mark.route.d.ts", + "default": "./dist/src/compat/api/modules/notifications/routes/mark.route.js" + }, + "./api/modules/notifications/routes/preferences.route": { + "import": "./dist/src/compat/api/modules/notifications/routes/preferences.route.js", + "types": "./dist/src/compat/api/modules/notifications/routes/preferences.route.d.ts", + "default": "./dist/src/compat/api/modules/notifications/routes/preferences.route.js" + }, + "./api/modules/notifications/schema": { + "import": "./dist/src/compat/api/modules/notifications/schema.js", + "types": "./dist/src/compat/api/modules/notifications/schema.d.ts", + "default": "./dist/src/compat/api/modules/notifications/schema.js" + }, + "./api/modules/notifications/tasks/notification-tasks": { + "import": "./dist/src/compat/api/modules/notifications/tasks/notification-tasks.js", + "types": "./dist/src/compat/api/modules/notifications/tasks/notification-tasks.d.ts", + "default": "./dist/src/compat/api/modules/notifications/tasks/notification-tasks.js" + }, + "./api/modules/pages/page-layout-routes": { + "import": "./dist/src/api/modules/pages/pages.module.js", + "types": "./dist/src/api/modules/pages/pages.module.d.ts", + "default": "./dist/src/api/modules/pages/pages.module.js" + }, + "./api/modules/pages/page-layout-service": { + "import": "./dist/src/api/modules/pages/lib/page-layout.js", + "types": "./dist/src/api/modules/pages/lib/page-layout.d.ts", + "default": "./dist/src/api/modules/pages/lib/page-layout.js" + }, + "./api/modules/queue/helpers/process-queue-tasks": { + "import": "./dist/src/api/modules/queue/lib/process-queue-tasks.js", + "types": "./dist/src/api/modules/queue/lib/process-queue-tasks.d.ts", + "default": "./dist/src/api/modules/queue/lib/process-queue-tasks.js" + }, + "./api/modules/search/routes/search.route": { + "import": "./dist/src/compat/api/modules/search/routes/search.route.js", + "types": "./dist/src/compat/api/modules/search/routes/search.route.d.ts", + "default": "./dist/src/compat/api/modules/search/routes/search.route.js" + }, + "./api/modules/search/routes/suggest.route": { + "import": "./dist/src/compat/api/modules/search/routes/suggest.route.js", + "types": "./dist/src/compat/api/modules/search/routes/suggest.route.d.ts", + "default": "./dist/src/compat/api/modules/search/routes/suggest.route.js" + }, + "./api/modules/search/routes/timeline.route": { + "import": "./dist/src/compat/api/modules/search/routes/timeline.route.js", + "types": "./dist/src/compat/api/modules/search/routes/timeline.route.d.ts", + "default": "./dist/src/compat/api/modules/search/routes/timeline.route.js" + }, + "./api/modules/users/avatar-color": { + "import": "./dist/src/api/modules/users/lib/avatar-color.js", + "types": "./dist/src/api/modules/users/lib/avatar-color.d.ts", + "default": "./dist/src/api/modules/users/lib/avatar-color.js" + }, + "./api/modules/users/credential-limits": { + "import": "./dist/src/api/modules/users/lib/credential-limits.js", + "types": "./dist/src/api/modules/users/lib/credential-limits.d.ts", + "default": "./dist/src/api/modules/users/lib/credential-limits.js" + }, + "./api/modules/users/passkeys/failure": { + "import": "./dist/src/api/modules/users/passkeys/lib/failure.js", + "types": "./dist/src/api/modules/users/passkeys/lib/failure.d.ts", + "default": "./dist/src/api/modules/users/passkeys/lib/failure.js" + }, + "./api/modules/users/passkeys/schema": { + "import": "./dist/src/api/modules/users/passkeys/passkeys.schema.js", + "types": "./dist/src/api/modules/users/passkeys/passkeys.schema.d.ts", + "default": "./dist/src/api/modules/users/passkeys/passkeys.schema.js" + }, + "./api/modules/users/presence-schema": { + "import": "./dist/src/api/modules/users/presence.schema.js", + "types": "./dist/src/api/modules/users/presence.schema.d.ts", + "default": "./dist/src/api/modules/users/presence.schema.js" + }, + "./api/modules/users/routes/privacy.route": { + "import": "./dist/src/api/modules/users/routes/update-privacy.route.js", + "types": "./dist/src/api/modules/users/routes/update-privacy.route.d.ts", + "default": "./dist/src/api/modules/users/routes/update-privacy.route.js" + }, + "./api/modules/users/routes/reset-passowrd.route": { + "import": "./dist/src/api/modules/users/routes/reset-password.route.js", + "types": "./dist/src/api/modules/users/routes/reset-password.route.d.ts", + "default": "./dist/src/api/modules/users/routes/reset-password.route.js" + }, + "./api/modules/users/routes/time-zone.route": { + "import": "./dist/src/api/modules/users/routes/update-time-zone.route.js", + "types": "./dist/src/api/modules/users/routes/update-time-zone.route.d.ts", + "default": "./dist/src/api/modules/users/routes/update-time-zone.route.js" + }, + "./api/modules/users/sso/connections/failure": { + "import": "./dist/src/api/modules/users/sso/connections/lib/failure.js", + "types": "./dist/src/api/modules/users/sso/connections/lib/failure.d.ts", + "default": "./dist/src/api/modules/users/sso/connections/lib/failure.js" + }, + "./api/modules/users/sso/connections/routes/import.route": { + "import": "./dist/src/compat/api/modules/users/sso/connections/routes/import.route.js", + "types": "./dist/src/compat/api/modules/users/sso/connections/routes/import.route.d.ts", + "default": "./dist/src/compat/api/modules/users/sso/connections/routes/import.route.js" + }, + "./api/modules/users/sso/connections/routes/preferences.route": { + "import": "./dist/src/compat/api/modules/users/sso/connections/routes/preferences.route.js", + "types": "./dist/src/compat/api/modules/users/sso/connections/routes/preferences.route.d.ts", + "default": "./dist/src/compat/api/modules/users/sso/connections/routes/preferences.route.js" + }, + "./api/modules/users/sso/connections/schema": { + "import": "./dist/src/api/modules/users/sso/connections/connections.schema.js", + "types": "./dist/src/api/modules/users/sso/connections/connections.schema.d.ts", + "default": "./dist/src/api/modules/users/sso/connections/connections.schema.js" + }, + "./api/modules/users/sso/routes/pending.route": { + "import": "./dist/src/compat/api/modules/users/sso/routes/pending.route.js", + "types": "./dist/src/compat/api/modules/users/sso/routes/pending.route.d.ts", + "default": "./dist/src/compat/api/modules/users/sso/routes/pending.route.js" + }, "./*": { "import": "./dist/src/*.js", "types": "./dist/src/*.d.ts", @@ -288,6 +678,7 @@ "jose": "^6.2.15", "motion": "^14.10.1", "ocache": "^0.3.1", + "oxc-parser": "^0.148.0", "picocolors": "^1.1.1", "postgres": "^4.1.0", "rate-limiter-flexible": "^11.3.0", diff --git a/packages/vitnode/scripts/cli/commands/doctor.ts b/packages/vitnode/scripts/cli/commands/doctor.ts new file mode 100644 index 0000000000..c37c6714da --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/doctor.ts @@ -0,0 +1,273 @@ +import { relative } from "node:path"; + +import type { CommandContext, OutputOptions } from "../context"; +import type { PluginApiLoad } from "../inspect/api-metadata"; +import type { LoadConfiguredPluginIds } from "../plugins/discover"; +import type { + ModuleImporter, + PluginCheck, + PluginValidation, +} from "../plugins/validate"; + +import { describeNestedRegistration } from "../../../src/api/lib/nested-registrations"; +import { isAdminApiPath } from "../../../src/api/lib/route-metadata"; +import { EXIT_CODE, UserError, ValidationError } from "../errors"; +import { + diagnosticTargets, + loadPluginApi, + pluginApiFromDefinition, +} from "../inspect/api-metadata"; +import { + appRegistrationCheck, + registrationCheck, + registryCheck, +} from "../inspect/static-checks"; +import { findAppRoot } from "../plugins/discover"; +import { validatePlugin } from "../plugins/validate"; +import { findWorkspaceRoot } from "../plugins/workspace"; +import { plural, toDisplayPath } from "../ui/format"; +import { renderCheck } from "./plugin-validate"; + +export interface DoctorOptions extends OutputOptions { + name?: string; +} + +const PERMISSIONS_CHECK = "Staff permissions"; +const ENDPOINTS_CHECK = "Endpoints"; +const BACKGROUND_CHECK = "Background registrations"; +const METADATA_CHECKS = [PERMISSIONS_CHECK, ENDPOINTS_CHECK, BACKGROUND_CHECK]; + +const metadataChecks = ( + id: string, + load: PluginApiLoad | { status: "failed" }, +): PluginCheck[] => { + if (load.status === "failed") { + return METADATA_CHECKS.map(name => ({ + details: ["API definition failed - see above."], + name, + status: "skipped", + summary: "API definition failed", + })); + } + + if (load.status !== "loaded") { + return METADATA_CHECKS.map(name => + load.status === "error" && name === PERMISSIONS_CHECK + ? { details: load.reason.split("\n"), name, status: "error" } + : { + details: [], + name, + status: "skipped", + summary: + load.status === "skipped" + ? load.reason + : `needs the API definition, which did not load (see ${PERMISSIONS_CHECK})`, + }, + ); + } + + const { ignored, permissionStaff, routes } = load.metadata; + const declared = { + admin: new Map( + Object.entries(permissionStaff.admin).map(([module, list]) => [ + module, + new Set(list), + ]), + ), + moderator: new Map( + Object.entries(permissionStaff.moderator).map(([module, list]) => [ + module, + new Set(list), + ]), + ), + }; + + const undeclared: string[] = []; + const unguarded: string[] = []; + for (const route of routes) { + const label = `${route.method} /${id}${route.path}`; + for (const [type, ref] of [ + ["admin", route.admin], + ["moderator", route.moderator], + ] as const) { + if (ref === undefined || (ref.plugin !== undefined && ref.plugin !== id)) + continue; + if (declared[type].get(ref.module)?.has(ref.permission) !== true) { + undeclared.push( + `${label} requires the ${type} permission ${ref.module}.${ref.permission}, which the plugin does not declare in permissionStaff.${type}.`, + ); + } + } + if (isAdminApiPath(route.modulePath) && route.admin === undefined) { + unguarded.push( + `${label} is under admin/ but has no adminStaffPermission.`, + ); + } + } + + const seen = new Map(); + const duplicates: string[] = []; + for (const route of routes) { + const key = `${route.method} ${route.path.replace(/\{[^}]*\}/g, "{}")}`; + const label = `${route.method} /${id}${route.path}`; + const previous = seen.get(key); + if (previous === undefined) seen.set(key, label); + else + duplicates.push( + `${label} is declared twice (also ${previous}); only the first one answers.`, + ); + } + + return [ + undeclared.length > 0 + ? { + details: [...undeclared, ...unguarded], + name: PERMISSIONS_CHECK, + status: "error", + } + : unguarded.length > 0 + ? { details: unguarded, name: PERMISSIONS_CHECK, status: "warning" } + : { + details: [], + name: PERMISSIONS_CHECK, + status: "ok", + summary: + "declared on buildRoute; checks inside handlers are not visible", + }, + duplicates.length > 0 + ? { details: duplicates, name: ENDPOINTS_CHECK, status: "error" } + : { + details: [], + name: ENDPOINTS_CHECK, + status: "ok", + summary: plural(routes.length, "route"), + }, + ignored.length > 0 + ? { + details: ignored.map(describeNestedRegistration), + name: BACKGROUND_CHECK, + status: "error", + } + : { details: [], name: BACKGROUND_CHECK, status: "ok" }, + ]; +}; + +const reuseValidatedPluginApi = async ( + id: string, + root: string, + validated: PluginValidation["api"], + importModule: ModuleImporter | undefined, +): Promise => { + if (validated === undefined) return loadPluginApi(id, root, importModule); + if (validated.status === "loaded") { + return pluginApiFromDefinition(id, root, validated.definition); + } + + return validated; +}; + +export const runDoctorCommand = async ( + { cwd, ui }: CommandContext, + { name }: DoctorOptions, + deps: { + importModule?: ModuleImporter; + loadIds?: LoadConfiguredPluginIds; + } = {}, +): Promise => { + ui.header("Doctor"); + + const targets = await diagnosticTargets(cwd, name, deps.loadIds); + if (name !== undefined && targets.length === 0) { + throw new UserError(`No plugin named "${name}" was found.`); + } + if (targets.length === 0) { + ui.note("No plugins to check."); + ui.line(); + + return EXIT_CODE.ok; + } + + const workspaceRoot = findWorkspaceRoot(cwd); + const appRoot = findAppRoot(cwd, workspaceRoot); + const totals = { error: 0, ok: 0, skipped: 0, warning: 0 }; + const failing: string[] = []; + + for (const [index, target] of targets.entries()) { + if (index > 0) ui.line(); + const where = + target.root === null + ? "" + : toDisplayPath(relative(cwd, target.root)) || "."; + ui.line( + ui.mode === "plain" + ? `${target.id}${where ? ` (${where})` : ""}` + : ` ${ui.colors.bold(target.id)} ${ui.colors.muted(where)}`, + ); + + const checks: PluginCheck[] = []; + if (target.root === null) { + checks.push({ + details: [ + `${target.id} is configured but not installed - run your package manager's install.`, + ], + name: "Package", + status: "error", + }); + } else { + const validation = await validatePlugin(target.root, { + importModule: deps.importModule, + }); + checks.push(...validation.checks); + checks.push(registrationCheck(target.root)); + checks.push(registryCheck(target.root)); + checks.push( + appRegistrationCheck({ + appRoot, + configured: target.configured, + cwd, + id: target.id, + root: target.root, + workspaceRoot, + }), + ); + checks.push( + ...metadataChecks( + target.id, + await reuseValidatedPluginApi( + target.id, + target.root, + validation.api, + deps.importModule, + ), + ), + ); + } + + checks.forEach(check => { + totals[check.status] += 1; + renderCheck(ui, check); + }); + if (checks.some(check => check.status === "error")) failing.push(target.id); + } + + ui.line(); + ui.note( + `${plural(totals.ok, "check")} passed, ${plural(totals.warning, "warning")}, ${String(totals.error)} failed, ${String(totals.skipped)} skipped.`, + ); + ui.line(); + + if (failing.length > 0) { + throw new ValidationError( + `${plural(failing.length, "plugin")} with problems: ${failing.join(", ")}`, + ); + } + + ui.success( + totals.warning > 0 + ? "No errors - see the warnings above" + : "Everything checked is fine", + ); + ui.line(); + + return EXIT_CODE.ok; +}; diff --git a/packages/vitnode/scripts/cli/commands/generate.ts b/packages/vitnode/scripts/cli/commands/generate.ts new file mode 100644 index 0000000000..fecf8c00ce --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/generate.ts @@ -0,0 +1,915 @@ +import type { CommandContext, OutputOptions } from "../context"; +import type { GenerateContext } from "../generate/generators"; +import type { PermissionRef } from "../generate/names"; +import type { FileChange } from "../generate/plan"; +import type { GeneratorProject } from "../generate/project"; +import type { HttpMethod, ParamType } from "../generate/templates"; +import type { Ui } from "../ui/ui"; + +import { isAdminApiPath } from "../../../src/api/lib/route-metadata"; +import { errorMessage, EXIT_CODE, RuntimeError, UserError } from "../errors"; +import { lookupModule, UnsupportedStructureError } from "../generate/api-tree"; +import { parseSource } from "../generate/ast"; +import { + parseContentFields, + planContentResource, +} from "../generate/content-resource"; +import { unifiedDiff } from "../generate/diff"; +import { formatPlan } from "../generate/format"; +import { + endpointPath, + ensureBuilders, + hasContentModules, + pathParams, + planModule, + planPermission, + planRoute, + planService, + readAdminPermissions, +} from "../generate/generators"; +import { + moduleIdentifier, + parseLogicalPath, + parsePermission, + routeIdentifier, +} from "../generate/names"; +import { applyPlan, GenerationPlan } from "../generate/plan"; +import { resolveGeneratorProject } from "../generate/project"; +import { planApiResource } from "../generate/resource"; +import { parseFields } from "../generate/resource-templates"; +import { plural } from "../ui/format"; + +export interface GenerateCommonOptions extends OutputOptions { + dryRun?: boolean; + project?: string; +} + +export interface GenerateModuleOptions extends GenerateCommonOptions { + admin?: boolean; + name?: string; + parents?: boolean; +} + +export interface GenerateRouteOptions extends GenerateCommonOptions { + admin?: boolean; + declarePermission?: boolean; + idType?: string; + method?: string; + parents?: boolean; + path?: string; + permission?: string; + target?: string; +} + +export interface GenerateResourceOptions extends GenerateCommonOptions { + admin?: boolean; + contentId?: string; + declarePermission?: boolean; + fields?: string; + idType?: string; + kind?: string; + name?: string; + parents?: boolean; + permissionModule?: string; + singular?: string; + table?: string; +} + +export interface GenerateServiceOptions extends GenerateCommonOptions { + admin?: boolean; + name?: string; +} + +const METHODS: readonly HttpMethod[] = [ + "get", + "post", + "put", + "patch", + "delete", +]; + +export const ROUTE_DEFAULTS: Record< + string, + { method: HttpMethod; path: string } +> = { + create: { method: "post", path: "/" }, + delete: { method: "delete", path: "/{id}" }, + list: { method: "get", path: "/" }, + show: { method: "get", path: "/{id}" }, + update: { method: "patch", path: "/{id}" }, +}; + +const RESERVED_PARAMS = new Set([ + "await", + "break", + "c", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "function", + "if", + "implements", + "import", + "in", + "instanceof", + "interface", + "let", + "new", + "null", + "package", + "private", + "protected", + "public", + "return", + "static", + "super", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", +]); + +const PATH_SEGMENT = /^(?:[A-Za-z0-9._~-]+|\{[A-Za-z_][A-Za-z0-9_]*\})$/; + +export const assertRoutePath = (path: string): string => { + const problem = (reason: string) => + new UserError(`"${path}" is not a supported route path: ${reason}.`, { + details: [ + "Use OpenAPI parameter syntax relative to the module: /, /{id}, /{id}/archive.", + "Each segment is plain text or one {param}; :id, *, {id?} and a{b} are not supported.", + ], + }); + + if (!path.startsWith("/")) throw problem("it must start with /"); + if (path === "/") return path; + if (path.endsWith("/")) throw problem("it must not end with /"); + + for (const segment of path.slice(1).split("/")) { + if (!PATH_SEGMENT.test(segment)) + throw problem(`"${segment}" is not a plain segment or a {param}`); + } + + const params = pathParams(path); + if (new Set(params).size !== params.length) + throw problem("a parameter appears twice"); + if (params.some(param => param === "sample" || param === "message")) { + throw problem( + "sample and message are reserved by the generated starter response", + ); + } + const reserved = params.find(param => RESERVED_PARAMS.has(param)); + if (reserved !== undefined) { + throw problem( + `{${reserved}} cannot be a variable name in the generated handler; rename the parameter`, + ); + } + + return path; +}; + +const withAdmin = (path: string[], admin: boolean | undefined): string[] => { + if (!admin) return path; + if (path[0] === "admin") { + throw new UserError( + `"${path.join("/")}" is already in the admin module tree.`, + { + hint: "Drop --admin, or drop the admin/ prefix.", + }, + ); + } + + return ["admin", ...path]; +}; + +const resolveParents = async ( + context: CommandContext, + ctx: GenerateContext, + modulePath: readonly string[], + requiredDepth: number, + parents: boolean, +): Promise => { + if (parents || !context.ui.interactive) return parents; + + const lookup = lookupModule(ctx.plan, ctx.project, modulePath); + if (lookup.status === "found" || lookup.depth >= requiredDepth) return false; + + const missing = modulePath + .slice(lookup.depth, requiredDepth) + .map((_, index) => modulePath.slice(0, lookup.depth + index + 1).join("/")); + + return context.prompter.confirm( + `Create the missing ${plural(missing.length, "module")} ${missing.join(", ")}?`, + { default: true }, + ); +}; + +const renderDiff = (ui: Ui, change: FileChange) => { + for (const line of unifiedDiff(change.path, change.before, change.after)) { + if (ui.mode === "plain") ui.line(line); + else if (line.startsWith("+++") || line.startsWith("---")) + ui.line(ui.colors.bold(line)); + else if (line.startsWith("@@")) ui.line(ui.colors.command(line)); + else if (line.startsWith("+")) ui.line(ui.colors.success(line)); + else if (line.startsWith("-")) ui.line(ui.colors.error(line)); + else ui.line(line); + } +}; + +const renderChanges = ( + ui: Ui, + changes: readonly FileChange[], + dryRun: boolean, +) => { + for (const change of changes) { + const verb = + change.kind === "create" + ? dryRun + ? "would create" + : "created" + : dryRun + ? "would update" + : "updated"; + const text = `${verb.padEnd(12)} ${change.path}`; + const detail = change.reasons.join("; "); + + if (dryRun) ui.info(`${text} ${ui.colors.muted(detail)}`); + else ui.success(text, detail); + } +}; + +const finish = async ( + context: CommandContext, + ctx: GenerateContext, + { dryRun }: GenerateCommonOptions, + next: (ui: Ui) => void, +): Promise => { + const { ui } = context; + const formatting = await formatPlan(ctx.plan, ctx.plan.changes()); + const changes = ctx.plan.changes(); + + for (const change of changes) { + if (!/\.(?:ts|tsx|mts|cts)$/.test(change.path)) continue; + try { + parseSource(change.path, change.after); + } catch (error) { + throw new RuntimeError( + `The generator produced invalid code for ${change.path}. Nothing was written.`, + { + cause: error, + details: [errorMessage(error)], + hint: "Please report this with the file's original contents. Run with --dry-run to see the planned diff.", + }, + ); + } + } + + if (formatting.skipped !== undefined) { + ctx.notes.push(`Generated code was not formatted: ${formatting.skipped}.`); + } + + ui.keyValue([ + ["Plugin", `${ctx.project.id} ${ui.colors.muted(ctx.project.display)}`], + ]); + ui.line(); + + if (dryRun) { + for (const change of changes) { + renderDiff(ui, change); + ui.line(); + } + renderChanges(ui, changes, true); + } else { + applyPlan(ctx.plan.root, changes); + renderChanges(ui, changes, false); + } + + ui.line(); + ctx.warnings.forEach(warning => { + ui.warning(warning); + }); + ctx.notes.forEach(note => { + ui.note(note); + }); + if (ctx.warnings.length + ctx.notes.length > 0) ui.line(); + + if (dryRun) { + ui.note( + `Dry run: ${plural(changes.filter(change => change.kind === "create").length, "file")} to create, ${String(changes.filter(change => change.kind === "update").length)} to update. Nothing was written.`, + ); + ui.line(); + + return EXIT_CODE.ok; + } + + next(ui); + ui.line(); + + return EXIT_CODE.ok; +}; + +const start = async ( + context: CommandContext, + title: string, + options: GenerateCommonOptions, +): Promise => { + context.ui.header(options.dryRun ? `${title} (dry run)` : title); + + const project: GeneratorProject = await resolveGeneratorProject({ + cwd: context.cwd, + project: options.project, + prompter: context.prompter, + ui: context.ui, + }); + const ctx: GenerateContext = { + notes: [], + plan: new GenerationPlan(project.root), + project, + warnings: [], + }; + ensureBuilders(ctx); + + return ctx; +}; + +const withManualSteps = async ( + action: () => Promise | T, + manual: string[], +): Promise => { + try { + return await action(); + } catch (error) { + if ( + error instanceof UnsupportedStructureError && + error.manual.length === 0 + ) { + throw error.withManual(manual); + } + throw error; + } +}; + +const mountPath = (project: GeneratorProject, path: string) => + `/${project.id}${path}`; + +const typesNote = (ui: Ui) => { + ui.note( + "Types: the plugin's own code sees the change at once. An app sees it once the plugin's declarations are rebuilt - vitnode dev does that on save, otherwise run vitnode build in the plugin.", + ); +}; + +export const runGenerateModuleCommand = async ( + context: CommandContext, + options: GenerateModuleOptions, +): Promise => { + if (options.name === undefined) + throw new UserError("Name the module, e.g. vitnode g module notes."); + const path = withAdmin( + parseLogicalPath(options.name, "module name"), + options.admin, + ); + const ctx = await start(context, "Generate a module", options); + const leaf = path[path.length - 1] ?? ""; + const identifier = moduleIdentifier(path); + await withManualSteps(async () => { + const parents = await resolveParents( + context, + ctx, + path, + path.length - 1, + options.parents ?? false, + ); + planModule(ctx, path, { parents }); + }, [ + `Create ${leaf}/${leaf}.module.ts next to the parent module (src/api/modules/ for a top-level one):`, + ` export const ${identifier} = buildModule({ name: ${JSON.stringify(leaf)}, routes: [] });`, + path.length === 1 + ? `Import ${identifier} in src/config.api.ts and add it to buildApiPlugin({ modules }).` + : `Import ${identifier} in the "${path.slice(0, -1).join("/")}" module and add it to its modules.`, + ]); + + return finish(context, ctx, options, ui => { + ui.note( + `Module ${path.join("/")} is mounted at ${mountPath(ctx.project, `/${path.join("/")}`)} under the app's API base path (/api in the starter apps).`, + ); + ui.note("It has no routes yet, so there is nothing to call. Add one with:"); + ui.line(` ${ui.colors.command(`vitnode g route ${path.join("/")}/list`)}`); + }); +}; + +const pickMethod = async ( + context: CommandContext, + name: string, + method: string | undefined, +): Promise => { + if (method !== undefined) { + const lower = method.toLowerCase(); + const known = METHODS.find(item => item === lower); + if (known === undefined) { + throw new UserError(`"${method}" is not a supported HTTP method.`, { + hint: `Use one of: ${METHODS.join(", ")}.`, + }); + } + + return known; + } + + const fallback = ROUTE_DEFAULTS[name]?.method; + if (fallback !== undefined) return fallback; + + if (!context.ui.interactive) { + throw new UserError(`The route "${name}" needs --method.`, { + details: [ + `Only ${Object.keys(ROUTE_DEFAULTS).join(", ")} imply a method.`, + ], + }); + } + + return context.prompter.select( + "HTTP method", + METHODS.map(item => ({ name: item.toUpperCase(), value: item })), + ); +}; + +const pickPath = async ( + context: CommandContext, + name: string, + path: string | undefined, +): Promise => { + if (path !== undefined) return assertRoutePath(path); + + const fallback = ROUTE_DEFAULTS[name]?.path; + if (fallback !== undefined) return fallback; + + if (!context.ui.interactive) { + throw new UserError(`The route "${name}" needs --path.`, { + details: [ + `Only ${Object.keys(ROUTE_DEFAULTS).join(", ")} imply a path; a file name is not a URL.`, + "Pass it relative to the module, e.g. --path / or --path '/{id}/archive'.", + ], + }); + } + + return assertRoutePath( + await context.prompter.text( + "Path, relative to the module (e.g. / or /{id})", + { + validate: value => { + try { + assertRoutePath(value); + + return true; + } catch (error) { + return errorMessage(error); + } + }, + }, + ), + ); +}; + +const pickPermission = async ( + context: CommandContext, + ctx: GenerateContext, + permission: string | undefined, +): Promise => { + if (permission !== undefined) return parsePermission(permission); + + const declarations = readAdminPermissions(ctx); + const declared = + declarations.status === "resolved" + ? [...declarations.modules].flatMap(([module, names]) => + names.map(name => `${module}.${name}`), + ) + : []; + + if (!context.ui.interactive || declared.length === 0) { + throw new UserError( + "Admin routes need a staff permission: pass --permission .", + { + details: [ + "Every route under admin/ is checked against one, e.g. --permission notes.can_create.", + declared.length === 0 + ? "This plugin declares none yet - add --declare-permission to declare it as well." + : `Declared: ${declared.join(", ")}`, + ], + }, + ); + } + + return parsePermission( + await context.prompter.select( + "Staff permission required to call it", + declared.map(value => ({ name: value, value })), + ), + ); +}; + +const fetcherExample = ( + project: GeneratorProject, + modulePath: readonly string[], + method: HttpMethod, + path: string, + paramType: ParamType, +): string[] => { + const params = pathParams(path); + const sample = paramType === "number" ? "1" : '"1"'; + const args = [ + params.length === 0 + ? null + : `params: { ${params.map(param => `${param}: ${sample}`).join(", ")} }`, + method === "post" || method === "put" || method === "patch" + ? "body: {}" + : null, + ].filter(part => part !== null); + + return [ + 'import { fetcher } from "@vitnode/core/tanstack/fetcher";', + "", + "const response = await fetcher({", + ` plugin: ${JSON.stringify(project.id)},`, + ` method: ${JSON.stringify(method)},`, + ` module: ${JSON.stringify(modulePath.join("/"))},`, + ` path: ${JSON.stringify(path)},`, + ...(args.length === 0 ? [] : [` args: { ${args.join(", ")} },`]), + "});", + ]; +}; + +export const runGenerateRouteCommand = async ( + context: CommandContext, + options: GenerateRouteOptions, +): Promise => { + if (options.target === undefined) { + throw new UserError( + "Name the route as /, e.g. vitnode g route notes/list.", + ); + } + + const segments = parseLogicalPath(options.target, "route"); + if (segments.length < 2) { + throw new UserError(`"${options.target}" names no module.`, { + details: [ + "Write the route as /, e.g. notes/list or admin/notes/create.", + ], + }); + } + + const name = segments[segments.length - 1] ?? ""; + const modulePath = withAdmin(segments.slice(0, -1), options.admin); + const idType = options.idType ?? "string"; + if (idType !== "string" && idType !== "number") { + throw new UserError(`--id-type must be string or number, not "${idType}".`); + } + + const ctx = await start(context, "Generate a route", options); + const method = await pickMethod(context, name, options.method); + const path = await pickPath(context, name, options.path); + const isAdmin = isAdminApiPath(`${modulePath.join("/")}${path}`); + + if ( + !isAdmin && + (options.permission !== undefined || options.declarePermission) + ) { + throw new UserError("--permission only applies to admin routes.", { + details: [ + "An admin staff permission guards routes in the admin module tree, which also requires an AdminCP session.", + "Generate the route under admin/ (or with --admin) instead of guarding a public module.", + ], + }); + } + + const permission = isAdmin + ? await pickPermission(context, ctx, options.permission) + : undefined; + const parents = await resolveParents( + context, + ctx, + modulePath, + modulePath.length, + options.parents ?? false, + ); + + if (permission !== undefined) { + await withManualSteps(() => { + planPermission(ctx, permission, { + contentModules: hasContentModules(ctx), + declare: options.declarePermission ?? false, + }); + }, [ + `Declare ${JSON.stringify(permission.permission)} in permissionStaff.admin[${JSON.stringify(permission.module)}] (src/config.api.ts, or the defineStaffPermissions object it uses),`, + `then create routes/${name}.route.ts in the folder of the "${modulePath.join("/")}" module file with adminStaffPermission: { module: ${JSON.stringify(permission.module)}, permission: ${JSON.stringify(permission.permission)} },`, + `import it in the module file from "./routes/${name}.route" and add it to the end of buildModule({ routes }).`, + ]); + } + + const { file } = await withManualSteps( + () => + planRoute(ctx, modulePath, { + method, + name, + paramType: idType, + parents, + path, + permission, + }), + [ + `Create routes/${name}.route.ts in the folder of the "${modulePath.join("/")}" module file, exporting`, + ` ${routeIdentifier(name, modulePath)} = buildRoute({ route: { method: ${JSON.stringify(method)}, path: ${JSON.stringify(path)}, ... }, handler })`, + `Import it in the module file from "./routes/${name}.route" and add it to the end of buildModule({ routes }).`, + ], + ); + + return finish(context, ctx, options, ui => { + const endpoint = endpointPath(modulePath, path); + ui.note( + `${method.toUpperCase()} ${mountPath(ctx.project, endpoint)} under the app's API base path (/api in the starter apps).`, + ); + ui.note( + method === "get" + ? `It answers with sample data. Replace the schema and handler in ${file}.` + : `It validates the request and answers 501 Not Implemented until you implement it in ${file}.`, + ); + if (permission !== undefined) { + ui.note( + `Guarded by the ${permission.module}.${permission.permission} staff permission. That decides who may call it, not which records they may touch.`, + ); + } + ui.line(); + ui.note("Call it from a page:"); + fetcherExample(ctx.project, modulePath, method, path, idType).forEach( + line => { + ui.line(line === "" ? "" : ` ${ui.colors.command(line)}`); + }, + ); + ui.line(); + typesNote(ui); + }); +}; + +export const runGenerateServiceCommand = async ( + context: CommandContext, + options: GenerateServiceOptions, +): Promise => { + if (options.name === undefined) + throw new UserError("Name the module, e.g. vitnode g service notes."); + const path = withAdmin( + parseLogicalPath(options.name, "module name"), + options.admin, + ); + const ctx = await start(context, "Generate a service", options); + const { factory, file } = planService(ctx, path); + + return finish(context, ctx, options, ui => { + ui.note( + `${factory} has no operations yet. Add them in ${file}, then build it in a route:`, + ); + ui.line( + ` ${ui.colors.command(`const service = ${factory}({ db: c.get("db") });`)}`, + ); + ui.note("Existing routes were not changed."); + }); +}; + +export const runGenerateResourceCommand = async ( + context: CommandContext, + options: GenerateResourceOptions, +): Promise => { + if (options.name === undefined) { + throw new UserError( + "Name the resource, e.g. vitnode g resource notes --kind api.", + ); + } + if (options.kind === "content") return runContentResource(context, options); + if (options.kind !== "api") { + throw new UserError( + options.kind === undefined + ? "Pass --kind api or --kind content." + : `--kind ${options.kind} is not supported.`, + { + details: [ + "--kind api scaffolds a module, schemas, a service and CRUD routes.", + "--kind content defines a Content Engine content type with generated staff routes and AdminCP screens.", + ], + }, + ); + } + + const path = withAdmin( + parseLogicalPath(options.name, "resource name"), + options.admin, + ); + const isAdmin = isAdminApiPath(path.join("/")); + const idType = options.idType ?? "string"; + if (idType !== "string" && idType !== "number") { + throw new UserError(`--id-type must be string or number, not "${idType}".`); + } + if ( + !isAdmin && + (options.permissionModule !== undefined || options.declarePermission) + ) { + throw new UserError( + "--permission-module only applies to admin resources.", + { + details: ["Generate the resource under admin/ (or with --admin)."], + }, + ); + } + + const ctx = await start(context, "Generate an API resource", options); + + let fieldsInput = options.fields; + if (fieldsInput === undefined && context.ui.interactive) { + fieldsInput = await context.prompter.text( + "Fields as name:type, comma-separated (string, number, int, boolean; name?: for optional). Empty for an id-only scaffold", + { + default: "", + validate: value => { + try { + parseFields(value); + + return true; + } catch (error) { + return errorMessage(error); + } + }, + }, + ); + } + const fields = parseFields(fieldsInput ?? ""); + + let permissionModule = options.permissionModule; + if (isAdmin && permissionModule === undefined && context.ui.interactive) { + permissionModule = await context.prompter.text( + "Staff permission module (gets can_view, can_create, can_edit, can_delete)", + { default: path[path.length - 1] }, + ); + } + if (permissionModule !== undefined) + parsePermission(`${permissionModule}.can_view`); + + const parents = await resolveParents( + context, + ctx, + path, + path.length - 1, + options.parents ?? false, + ); + const { files, names } = await withManualSteps( + () => + planApiResource(ctx, path, { + fields, + idType, + parents, + permissions: + permissionModule === undefined + ? undefined + : { + declare: options.declarePermission ?? false, + module: permissionModule, + }, + }), + [ + `Give the "${path.join("/")}" module a plain routes: [...] array literal with no spread or computed key after it, and run the command again;`, + `or write its routes/list|show|create|update|delete.route.ts files by hand, import them in the module file from "./routes/.route" and add them to buildModule({ routes }).`, + ], + ); + + if (fields.length === 0) { + ctx.notes.push( + `No --fields given: ${files.schema} models only the id. Add your fields there.`, + ); + } + + return finish(context, ctx, options, ui => { + ui.note( + `The ${names.label} resource is mounted at ${mountPath(ctx.project, `/${path.join("/")}`)} under the app's API base path (/api in the starter apps).`, + ); + ui.note( + `Every route answers 501 Not Implemented until you implement ${names.factory} in ${files.service}. No table or migration was created.`, + ); + if (permissionModule !== undefined) { + ui.note( + `Guards: list and show need ${permissionModule}.can_view, create ${permissionModule}.can_create, update ${permissionModule}.can_edit, delete ${permissionModule}.can_delete.`, + ); + } + ui.line(); + typesNote(ui); + }); +}; + +const runContentResource = async ( + context: CommandContext, + options: GenerateResourceOptions, +): Promise => { + const segments = parseLogicalPath(options.name ?? "", "resource name"); + const name = segments[0] ?? ""; + if ( + segments.length !== 1 || + options.admin || + options.permissionModule !== undefined + ) { + throw new UserError( + "A content resource is named by one segment, e.g. articles.", + { + details: [ + "The Content Engine mounts its staff routes under admin/content/ and adds can_view, can_create, can_edit and can_delete itself,", + "so --admin, --permission-module and nested names do not apply.", + ], + }, + ); + } + + const idStrategy = options.idType ?? "serial"; + if ( + idStrategy !== "serial" && + idStrategy !== "uuid" && + idStrategy !== "bigint" + ) { + throw new UserError( + `--id-type for a content resource is serial, uuid or bigint, not "${idStrategy}".`, + ); + } + + const ctx = await start(context, "Generate a content resource", options); + + let fieldsInput = options.fields; + if (fieldsInput === undefined && context.ui.interactive) { + fieldsInput = await context.prompter.text( + "Fields as name:type, comma-separated (text, richText, boolean, int, number; name?: for optional)", + { + validate: value => { + try { + parseContentFields(value); + + return true; + } catch (error) { + return errorMessage(error); + } + }, + }, + ); + } + if (fieldsInput === undefined) { + throw new UserError("A content resource needs --fields.", { + details: [ + 'For example: --fields "title:text,body:richText". The first required text field is the title.', + ], + }); + } + + const fields = parseContentFields(fieldsInput); + const result = await withManualSteps( + () => + planContentResource(ctx, name, { + fields, + id: options.contentId, + idStrategy, + singular: options.singular, + tableName: options.table, + }), + [ + "Mount the content type in the plugin's admin module by hand: buildContentAdminModule({ contentTypes: [] }) in its modules,", + "with the definition in src/content/ and the model from createContentModel in src/database/ (see the Content Engine docs).", + ], + ); + + return finish(context, ctx, options, ui => { + ui.note( + `${result.contentTypeId} is administrative only: staff manage it at /admin/content/${result.adminPath}. It has no public API, member writes or publication workflow.`, + ); + ui.note( + `The Content Engine serves its staff routes at ${mountPath(ctx.project, "/admin/content/...")} and guards them with ${result.permissionModule}.can_view, can_create, can_edit and can_delete.`, + ); + ui.line(); + ui.note("Then, in this order - nothing was migrated:"); + ui.line( + ` 1. ${ui.colors.command("vitnode build")} in the plugin (or keep ${ui.colors.command("vitnode dev")} running), so migrations can read the table ${result.tableName}.`, + ); + ui.line( + ` 2. ${ui.colors.command("vitnode db generate")} in the app that owns the database, and review the migration.`, + ); + ui.line( + ` 3. ${ui.colors.command("vitnode db migrate")} there to apply it.`, + ); + }); +}; diff --git a/packages/vitnode/scripts/cli/commands/inspect.ts b/packages/vitnode/scripts/cli/commands/inspect.ts new file mode 100644 index 0000000000..c8a20d4a18 --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/inspect.ts @@ -0,0 +1,217 @@ +import { relative } from "node:path"; + +import type { CommandContext, OutputOptions } from "../context"; +import type { PluginApiLoad, PluginApiMetadata } from "../inspect/api-metadata"; +import type { LoadConfiguredPluginIds } from "../plugins/discover"; +import type { ModuleImporter } from "../plugins/validate"; +import type { Ui } from "../ui/ui"; + +import { describeNestedRegistration } from "../../../src/api/lib/nested-registrations"; +import { EXIT_CODE, UserError } from "../errors"; +import { diagnosticTargets, loadPluginApi } from "../inspect/api-metadata"; +import { plural, toDisplayPath } from "../ui/format"; + +export interface InspectOptions extends OutputOptions { + name?: string; +} + +export interface InspectDeps { + importModule?: ModuleImporter; + loadIds?: LoadConfiguredPluginIds; +} + +type Section = "events" | "jobs" | "routes"; + +const TITLES: Record = { + events: "Inspect event listeners", + jobs: "Inspect background jobs", + routes: "Inspect routes", +}; + +const permissionLabel = ( + pluginId: string, + type: string, + ref?: { module: string; permission: string; plugin?: string }, +) => + ref === undefined + ? null + : `${type} ${ref.plugin !== undefined && ref.plugin !== pluginId ? `${ref.plugin}:` : ""}${ref.module}.${ref.permission}`; + +const renderRoutes = (ui: Ui, id: string, metadata: PluginApiMetadata) => { + if (metadata.routes.length === 0) { + ui.note("No routes."); + + return; + } + + ui.table({ + columns: [ + { header: "Method" }, + { header: "Path" }, + { header: "Staff permission" }, + ], + rows: metadata.routes.map(route => [ + route.method, + `/${id}${route.path}`, + [ + permissionLabel(id, "admin", route.admin), + permissionLabel(id, "moderator", route.moderator), + ] + .filter(label => label !== null) + .join(", ") || ui.colors.muted("-"), + ]), + }); +}; + +const renderJobs = (ui: Ui, metadata: PluginApiMetadata) => { + if (metadata.jobs.length === 0) ui.note("No cron jobs or queue tasks."); + else { + ui.table({ + columns: [ + { header: "Kind" }, + { header: "Name" }, + { header: "Schedule" }, + { header: "Payload" }, + { header: "Module" }, + ], + rows: metadata.jobs.map(job => [ + job.kind, + job.name, + job.schedule ?? ui.colors.muted("-"), + job.payload ?? ui.colors.muted("-"), + job.module, + ]), + }); + } +}; + +const renderListeners = (ui: Ui, metadata: PluginApiMetadata) => { + if (metadata.listeners.length === 0) + ui.note("No event listeners or WebSockets."); + else { + ui.table({ + columns: [ + { header: "Kind" }, + { header: "Event or id" }, + { header: "Payload" }, + { header: "Module" }, + ], + rows: metadata.listeners.map(listener => [ + listener.kind, + listener.event, + listener.payload ?? ui.colors.muted("-"), + listener.module, + ]), + }); + } +}; + +const renderIgnored = ( + ui: Ui, + metadata: PluginApiMetadata, + section: Section, +) => { + const kinds = new Set( + section === "jobs" + ? ["cronJobs", "queueTasks"] + : section === "events" + ? ["events", "webSockets"] + : [], + ); + for (const entry of metadata.ignored.filter(item => kinds.has(item.kind))) { + ui.warning(describeNestedRegistration(entry)); + } +}; + +const run = async ( + section: Section, + { cwd, ui }: CommandContext, + { name }: InspectOptions, + deps: InspectDeps = {}, +): Promise => { + ui.header(TITLES[section]); + + const targets = await diagnosticTargets(cwd, name, deps.loadIds); + if (name !== undefined && targets.length === 0) { + throw new UserError(`No plugin named "${name}" was found.`, { + hint: "Pass the plugin's package name, or run the command inside the plugin.", + }); + } + if (targets.length === 0) { + ui.note("No plugins to inspect."); + ui.line(); + + return EXIT_CODE.ok; + } + + let failed = 0; + for (const [index, target] of targets.entries()) { + if (index > 0) ui.line(); + const load: PluginApiLoad = await loadPluginApi( + target.id, + target.root, + deps.importModule, + ); + const where = + target.root === null + ? "" + : toDisplayPath(relative(cwd, target.root)) || "."; + ui.line( + ui.mode === "plain" + ? `${target.id}${where ? ` (${where})` : ""}` + : ` ${ui.colors.bold(target.id)} ${ui.colors.muted(where)}`, + ); + + if (load.status !== "loaded") { + if (load.status === "error") { + failed += 1; + ui.error(load.reason); + } else ui.note(`Skipped: ${load.reason}.`); + continue; + } + + if (section === "routes") renderRoutes(ui, target.id, load.metadata); + if (section === "jobs") renderJobs(ui, load.metadata); + if (section === "events") renderListeners(ui, load.metadata); + renderIgnored(ui, load.metadata, section); + } + + ui.line(); + if (section === "routes") { + ui.note( + "Paths are under the app's API base path (/api in the starter apps). Staff permissions are the ones declared on buildRoute; checks inside handlers or custom middleware are not listed.", + ); + } else { + ui.note( + "Read from each plugin's build output. Nothing was scheduled, run or sent.", + ); + } + ui.line(); + + if (failed > 0) { + ui.error(`${plural(failed, "plugin")} could not be loaded.`); + ui.line(); + + return EXIT_CODE.failure; + } + + return EXIT_CODE.ok; +}; + +export const runInspectRoutesCommand = async ( + context: CommandContext, + options: InspectOptions, + deps?: InspectDeps, +) => run("routes", context, options, deps); + +export const runInspectJobsCommand = async ( + context: CommandContext, + options: InspectOptions, + deps?: InspectDeps, +) => run("jobs", context, options, deps); + +export const runInspectEventsCommand = async ( + context: CommandContext, + options: InspectOptions, + deps?: InspectDeps, +) => run("events", context, options, deps); diff --git a/packages/vitnode/scripts/cli/commands/plugin-validate.ts b/packages/vitnode/scripts/cli/commands/plugin-validate.ts index 9624481044..1b9173c3eb 100644 --- a/packages/vitnode/scripts/cli/commands/plugin-validate.ts +++ b/packages/vitnode/scripts/cli/commands/plugin-validate.ts @@ -30,7 +30,7 @@ export const matchesPlugin = (plugin: DiscoveredPlugin, query: string) => shortNameOf(plugin.id) === query || (plugin.root !== null && basename(plugin.root) === query); -const renderCheck = (ui: Ui, check: PluginCheck) => { +export const renderCheck = (ui: Ui, check: PluginCheck) => { const summary = check.summary ?? ""; switch (check.status) { diff --git a/packages/vitnode/scripts/cli/generate/api-tree.ts b/packages/vitnode/scripts/cli/generate/api-tree.ts new file mode 100644 index 0000000000..aba69097a2 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/api-tree.ts @@ -0,0 +1,462 @@ +import type { + ArrayExpression, + CallExpression, + Expression, + ObjectExpression, + ObjectProperty, +} from "oxc-parser"; + +import { posix } from "node:path"; + +import type { ParsedSource } from "./ast"; +import type { GenerationPlan } from "./plan"; +import type { GeneratorProject } from "./project"; + +import { ConfigError } from "../errors"; +import { + arrayValue, + findCalls, + findConstInit, + listImports, + objectArgument, + parseSource, + stringValue, + unwrap, +} from "./ast"; +import { effectiveProperty } from "./effective-property"; +import { API_CONFIG } from "./project"; + +const KNOWN_MODULE_BUILDERS: Record = { + buildContentAdminModule: "content", + buildContentPublicModule: "content", +}; + +export interface ModuleSource { + call: CallExpression; + dir: string; + exportName: string; + file: string; + name: string; + options: ObjectExpression; + parsed: ParsedSource; +} + +export type ModuleEntry = + | { kind: "fixed"; label: string; name: string } + | { kind: "module"; source: ModuleSource } + | { kind: "unresolved"; label: string; reason: string }; + +export interface ModuleContainer { + array: ArrayExpression | null; + entries: ModuleEntry[]; + file: string; + options: ObjectExpression; + parsed: ParsedSource; +} + +export class UnsupportedStructureError extends ConfigError { + constructor(message: string, details: string[], manual: string[] = []) { + super(message, { + details: [ + ...details, + "Nothing was written.", + ...(manual.length === 0 + ? ["Wire it by hand, or simplify the structure to a direct call."] + : ["To wire it by hand:", ...manual.map(line => ` ${line}`)]), + ], + hint: 'Supported: buildApiPlugin({ modules: [...] }) and buildModule({ name: "...", routes: [...], modules: [...] }) with imported or local const identifiers.', + }); + this.manual = manual; + this.reason = details; + this.summary = message; + } + readonly manual: string[]; + readonly reason: string[]; + readonly summary: string; + + withManual(manual: string[]): UnsupportedStructureError { + return new UnsupportedStructureError(this.summary, this.reason, manual); + } +} + +export const withManual = (manual: string[], read: () => T): T => { + try { + return read(); + } catch (error) { + if (error instanceof UnsupportedStructureError) { + throw error.withManual(manual); + } + throw error; + } +}; + +export const readParsed = ( + plan: GenerationPlan, + file: string, +): ParsedSource => { + const source = plan.read(file); + if (source === null) { + throw new ConfigError(`${file} does not exist.`); + } + + return parseSource(file, source); +}; + +export const locate = (parsed: ParsedSource, offset: number): string => + `${parsed.path}:${String(parsed.source.slice(0, offset).split("\n").length)}`; + +export const editableProperty = ( + parsed: ParsedSource, + object: ObjectExpression, + key: string, + where: string, +): null | ObjectProperty => { + const result = effectiveProperty(object, key); + + if (result.kind === "unsafe") { + throw new UnsupportedStructureError( + `The ${key} of ${where} cannot be read or edited safely.`, + [`At ${locate(parsed, result.at)}: ${result.reason}.`], + ); + } + + return result.kind === "property" ? result.property : null; +}; + +const label = (parsed: ParsedSource, node: { end: number; start: number }) => { + const text = parsed.source.slice(node.start, node.end).replace(/\s+/g, " "); + + return text.length > 60 ? `${text.slice(0, 57)}...` : text; +}; + +const EXTENSIONS = [".ts", ".tsx", "/index.ts", "/index.tsx"]; + +export const resolveSpecifier = ( + plan: GenerationPlan, + project: GeneratorProject, + fromFile: string, + specifier: string, +): null | string => { + let base: string; + if (specifier.startsWith("@/") && project.alias) { + base = posix.join("src", specifier.slice(2)); + } else if (specifier.startsWith(".")) { + base = posix.join(posix.dirname(fromFile), specifier); + } else { + return null; + } + + base = base.replace(/\.(?:js|ts)$/, ""); + + return ( + EXTENSIONS.map(ext => `${base}${ext}`).find(file => plan.exists(file)) ?? + null + ); +}; + +export const specifierFor = ( + project: GeneratorProject, + fromFile: string, + toFile: string, + preferAlias: boolean, +): string => { + const target = toFile.replace(/\.tsx?$/, ""); + if (preferAlias && project.alias && target.startsWith("src/")) { + return `@/${target.slice(4)}`; + } + + const rel = posix.relative(posix.dirname(fromFile), target); + + return rel.startsWith(".") ? rel : `./${rel}`; +}; + +export const readModuleSource = ( + plan: GenerationPlan, + file: string, + exportName: string, +): ModuleSource | string => { + const parsed = readParsed(plan, file); + const init = findConstInit(parsed.program, exportName); + + if (init === null) return `${file} does not declare const ${exportName}`; + + const call = unwrap(init); + if ( + call.type !== "CallExpression" || + call.callee.type !== "Identifier" || + call.callee.name !== "buildModule" + ) { + return `${exportName} in ${file} is not a direct buildModule({...}) call`; + } + + const options = objectArgument(call); + if (options === null) + return `buildModule in ${file} is not called with an object literal`; + + const nameProperty = effectiveProperty(options, "name"); + if (nameProperty.kind === "unsafe") { + return `the name of ${exportName} cannot be read (${locate(parsed, nameProperty.at)}: ${nameProperty.reason})`; + } + const name = + nameProperty.kind === "property" + ? stringValue(nameProperty.property) + : null; + if (name === null) + return `the name of ${exportName} in ${file} is not a string literal`; + + return { + call, + dir: posix.dirname(file), + exportName, + file, + name, + options, + parsed, + }; +}; + +export const resolveBinding = ( + plan: GenerationPlan, + project: GeneratorProject, + parsed: ParsedSource, + name: string, +): string | { exportName: string; file: string } => { + if (findConstInit(parsed.program, name) !== null) { + return { exportName: name, file: parsed.path }; + } + + const imported = listImports(parsed.program) + .flatMap(info => + info.locals.map(local => ({ ...local, source: info.source })), + ) + .find(local => local.local === name); + if (imported === undefined || imported.imported === null) { + return "its declaration was not found"; + } + + const file = resolveSpecifier(plan, project, parsed.path, imported.source); + + return file === null + ? `"${imported.source}" does not resolve to a file in this plugin` + : { exportName: imported.imported, file }; +}; + +const entryOf = ( + plan: GenerationPlan, + project: GeneratorProject, + parsed: ParsedSource, + element: Expression, +): ModuleEntry => { + const value = unwrap(element); + + if ( + value.type === "CallExpression" && + value.callee.type === "Identifier" && + value.callee.name in KNOWN_MODULE_BUILDERS + ) { + return { + kind: "fixed", + label: label(parsed, value), + name: KNOWN_MODULE_BUILDERS[value.callee.name] ?? "", + }; + } + + if (value.type !== "Identifier") { + return { + kind: "unresolved", + label: label(parsed, value), + reason: "not a plain identifier", + }; + } + + const local = findConstInit(parsed.program, value.name); + if (local !== null) { + const source = readModuleSource(plan, parsed.path, value.name); + + return typeof source === "string" + ? { kind: "unresolved", label: value.name, reason: source } + : { kind: "module", source }; + } + + const imported = listImports(parsed.program) + .flatMap(info => + info.locals.map(local => ({ ...local, source: info.source })), + ) + .find(local => local.local === value.name); + + if (imported === undefined || imported.imported === null) { + return { + kind: "unresolved", + label: value.name, + reason: "its declaration was not found", + }; + } + + const file = resolveSpecifier(plan, project, parsed.path, imported.source); + if (file === null) { + return { + kind: "unresolved", + label: value.name, + reason: `"${imported.source}" does not resolve to a file in this plugin`, + }; + } + + const source = readModuleSource(plan, file, imported.imported); + + return typeof source === "string" + ? { kind: "unresolved", label: value.name, reason: source } + : { kind: "module", source }; +}; + +const containerOf = ( + plan: GenerationPlan, + project: GeneratorProject, + parsed: ParsedSource, + options: ObjectExpression, + where: string, +): ModuleContainer => { + const property = editableProperty(parsed, options, "modules", where); + + if (property === null) { + return { array: null, entries: [], file: parsed.path, options, parsed }; + } + + const array = arrayValue(property); + if (array === null) { + throw new UnsupportedStructureError( + `The modules of ${where} are not an array literal.`, + [`File: ${parsed.path}`, `Found: ${label(parsed, property.value)}`], + ); + } + + const entries = array.elements.flatMap(element => { + if (element === null) return []; + if (element.type === "SpreadElement") { + return [ + { + kind: "unresolved" as const, + label: label(parsed, element), + reason: "a spread element", + }, + ]; + } + + return [entryOf(plan, project, parsed, element)]; + }); + + return { array, entries, file: parsed.path, options, parsed }; +}; + +export const readRootContainer = ( + plan: GenerationPlan, + project: GeneratorProject, +): ModuleContainer => { + const parsed = readParsed(plan, API_CONFIG); + const calls = findCalls(parsed.program, "buildApiPlugin"); + + if (calls.length !== 1 || calls[0] === undefined) { + throw new UnsupportedStructureError( + calls.length === 0 + ? `${API_CONFIG} has no buildApiPlugin({...}) call.` + : `${API_CONFIG} calls buildApiPlugin more than once.`, + [ + "The generator registers top-level modules in that one call's modules array.", + ], + ); + } + + const options = objectArgument(calls[0]); + if (options === null) { + throw new UnsupportedStructureError( + `buildApiPlugin in ${API_CONFIG} is not called with an object literal.`, + [], + ); + } + + return containerOf(plan, project, parsed, options, "buildApiPlugin"); +}; + +export const readModuleContainer = ( + plan: GenerationPlan, + project: GeneratorProject, + module: ModuleSource, +): ModuleContainer => + containerOf( + plan, + project, + module.parsed, + module.options, + `module "${module.name}"`, + ); + +export type ModuleLookup = + | { + container: ModuleContainer; + depth: number; + found: ModuleSource[]; + status: "missing"; + } + | { container: ModuleContainer; module: ModuleSource; status: "found" }; + +export const lookupModule = ( + plan: GenerationPlan, + project: GeneratorProject, + path: readonly string[], +): ModuleLookup => { + let container = readRootContainer(plan, project); + const found: ModuleSource[] = []; + + for (const [index, segment] of path.entries()) { + const matches = container.entries.filter( + (entry): entry is Exclude => + (entry.kind === "module" && entry.source.name === segment) || + (entry.kind === "fixed" && entry.name === segment), + ); + const where = + index === 0 ? API_CONFIG : `module "${path.slice(0, index).join("/")}"`; + + if (matches.length > 1) { + throw new UnsupportedStructureError( + `"${segment}" is registered more than once in ${where}.`, + [`File: ${container.file}`], + ); + } + + const match = matches[0]; + + if (match?.kind === "fixed") { + throw new UnsupportedStructureError( + `"${path.slice(0, index + 1).join("/")}" is generated by ${match.label}.`, + [ + "Generated Content Engine modules cannot take hand-written routes or children.", + ], + ); + } + + if (match === undefined) { + const unresolved = container.entries.filter( + entry => entry.kind === "unresolved", + ); + if (unresolved.length > 0) { + throw new UnsupportedStructureError( + `Could not tell whether "${segment}" already exists in ${where}.`, + unresolved.map(entry => + entry.kind === "unresolved" + ? `${entry.label}: ${entry.reason}` + : "", + ), + ); + } + + return { container, depth: index, found, status: "missing" }; + } + + found.push(match.source); + if (index === path.length - 1) { + return { container, module: match.source, status: "found" }; + } + container = readModuleContainer(plan, project, match.source); + } + + return { container, depth: path.length, found, status: "missing" }; +}; diff --git a/packages/vitnode/scripts/cli/generate/ast.ts b/packages/vitnode/scripts/cli/generate/ast.ts new file mode 100644 index 0000000000..4599152f1b --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/ast.ts @@ -0,0 +1,487 @@ +import type { + ArrayExpression, + CallExpression, + Expression, + ImportDeclaration, + ObjectExpression, + ObjectProperty, + Program, +} from "oxc-parser"; + +import { parseSync, Visitor } from "oxc-parser"; + +import { ConfigError } from "../errors"; + +export interface Edit { + end: number; + start: number; + text: string; +} + +export interface ParsedSource { + path: string; + program: Program; + source: string; +} + +export const parseSource = (path: string, source: string): ParsedSource => { + const result = parseSync(path, source, { + lang: path.endsWith(".tsx") ? "tsx" : "ts", + sourceType: "module", + }); + + if (result.errors.length > 0) { + throw new ConfigError(`${path} could not be parsed.`, { + details: result.errors.slice(0, 3).map(error => error.message), + hint: "Fix the syntax error first - the generator only edits files it can read.", + }); + } + + return { path, program: result.program, source }; +}; + +export const applyEdits = (source: string, edits: readonly Edit[]): string => + edits + .map((edit, index) => ({ ...edit, index })) + .sort((a, b) => b.start - a.start || b.end - a.end || b.index - a.index) + .reduce( + (text, edit) => + text.slice(0, edit.start) + edit.text + text.slice(edit.end), + source, + ); + +export const findCalls = ( + program: Program, + callee: string, +): CallExpression[] => { + const calls: CallExpression[] = []; + new Visitor({ + CallExpression(call) { + if (call.callee.type === "Identifier" && call.callee.name === callee) { + calls.push(call); + } + }, + }).visit(program); + + return calls; +}; + +export const unwrap = (node: Expression): Expression => { + let current: Expression = node; + for (;;) { + if ( + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "ParenthesizedExpression" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } else { + return current; + } + } +}; + +export const objectArgument = ( + call: CallExpression, +): null | ObjectExpression => { + const [first] = call.arguments; + if (first === undefined || first.type === "SpreadElement") return null; + const value = unwrap(first); + + return value.type === "ObjectExpression" ? value : null; +}; + +export const getProperty = ( + object: ObjectExpression, + key: string, +): null | ObjectProperty => { + for (const property of object.properties) { + if (property.type !== "Property" || property.computed) continue; + const name = + property.key.type === "Identifier" + ? property.key.name + : property.key.type === "Literal" + ? String(property.key.value) + : null; + if (name === key) return property; + } + + return null; +}; + +export const stringValue = (property: null | ObjectProperty): null | string => { + if (property === null) return null; + const value = unwrap(property.value); + if (value.type === "Literal" && typeof value.value === "string") { + return value.value; + } + if ( + value.type === "TemplateLiteral" && + value.expressions.length === 0 && + value.quasis.length === 1 + ) { + return value.quasis[0]?.value.cooked ?? null; + } + + return null; +}; + +export const arrayValue = ( + property: null | ObjectProperty, +): ArrayExpression | null => { + if (property === null) return null; + const value = unwrap(property.value); + + return value.type === "ArrayExpression" ? value : null; +}; + +export const objectValue = ( + property: null | ObjectProperty, +): null | ObjectExpression => { + if (property === null) return null; + const value = unwrap(property.value); + + return value.type === "ObjectExpression" ? value : null; +}; + +const lineStart = (source: string, index: number) => + source.lastIndexOf("\n", index - 1) + 1; + +const startOfLineCommentsAbove = (source: string, lineIndex: number) => { + let at = lineIndex; + while (at > 0) { + const previous = lineStart(source, at - 1); + if (!source.slice(previous, at).trim().startsWith("//")) break; + at = previous; + } + + return at; +}; + +const indentAt = (source: string, index: number): string => + /^[ \t]*/.exec(source.slice(lineStart(source, index)))?.[0] ?? ""; + +const commaAfter = (source: string, index: number): null | number => { + const match = /^(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\n]*)*,/.exec( + source.slice(index), + ); + + return match === null ? null : index + match[0].length; +}; + +export const appendToArray = ( + source: string, + array: ArrayExpression, + text: string, +): Edit[] => { + const elements = array.elements.filter( + (element): element is NonNullable => element !== null, + ); + const last = elements[elements.length - 1]; + + if (last === undefined) { + return insertIntoEmpty(source, array, text); + } + + const inner = source.slice(array.start, array.end); + const comma = commaAfter(source, last.end); + + if (!inner.includes("\n")) { + return comma === null + ? [{ end: last.end, start: last.end, text: `, ${text}` }] + : [{ end: comma, start: comma, text: ` ${text}` }]; + } + + const closing = array.end - 1; + const closingLineStart = lineStart(source, closing); + const closingOnOwnLine = + source.slice(closingLineStart, closing).trim() === ""; + const indent = indentAt(source, last.start); + const edits: Edit[] = + comma === null ? [{ end: last.end, start: last.end, text: "," }] : []; + + edits.push( + closingOnOwnLine + ? { + end: closingLineStart, + start: closingLineStart, + text: `${indent}${text},\n`, + } + : { end: closing, start: closing, text: `\n${indent}${text}` }, + ); + + return edits; +}; + +const insertIntoEmpty = ( + source: string, + node: { end: number; start: number }, + text: string, +): Edit[] => { + const inner = source.slice(node.start + 1, node.end - 1); + if (inner.trim() === "") { + return [{ end: node.end - 1, start: node.start + 1, text }]; + } + + const closing = node.end - 1; + const closingLineStart = lineStart(source, closing); + if (source.slice(closingLineStart, closing).trim() === "") { + return [ + { + end: closingLineStart, + start: closingLineStart, + text: `${indentAt(source, closing)} ${text},\n`, + }, + ]; + } + + return [{ end: closing, start: closing, text: ` ${text} ` }]; +}; + +export const addProperty = ( + source: string, + object: ObjectExpression, + text: string, + after?: string, +): Edit[] => { + const properties = object.properties; + const anchor = + (after === undefined ? null : getProperty(object, after)) ?? + properties[properties.length - 1]; + + if (anchor === undefined) { + const inner = source.slice(object.start + 1, object.end - 1); + if (inner.trim() !== "") return insertIntoEmpty(source, object, text); + + return [ + { + end: object.end - 1, + start: object.start + 1, + text: inner.includes("\n") + ? `\n${indentAt(source, object.start)} ${text},\n${indentAt(source, object.start)}` + : ` ${text} `, + }, + ]; + } + + const multiline = source.slice(object.start, object.end).includes("\n"); + const comma = commaAfter(source, anchor.end); + + if (!multiline) { + return comma === null + ? [{ end: anchor.end, start: anchor.end, text: `, ${text}` }] + : [{ end: comma, start: comma, text: ` ${text},` }]; + } + + const indent = indentAt(source, anchor.start); + const from = comma ?? anchor.end; + const newline = source.indexOf("\n", from); + const lineEnd = + newline === -1 || newline > object.end ? object.end - 1 : newline; + const rest = source.slice(from, lineEnd); + const at = /^\s*(?:\/\/.*)?$/.test(rest) ? lineEnd : from; + const edits: Edit[] = + comma === null ? [{ end: anchor.end, start: anchor.end, text: "," }] : []; + edits.push({ end: at, start: at, text: `\n${indent}${text},` }); + + return edits; +}; + +export interface ImportInfo { + declaration: ImportDeclaration; + isType: boolean; + locals: { imported: null | string; isType: boolean; local: string }[]; + source: string; +} + +export const listImports = (program: Program): ImportInfo[] => + program.body.flatMap(statement => { + if (statement.type !== "ImportDeclaration") return []; + + return [ + { + declaration: statement, + isType: statement.importKind === "type", + locals: statement.specifiers.map(specifier => ({ + imported: + specifier.type === "ImportSpecifier" + ? specifier.imported.type === "Identifier" + ? specifier.imported.name + : specifier.imported.value + : null, + isType: + statement.importKind === "type" || + (specifier.type === "ImportSpecifier" && + specifier.importKind === "type"), + local: specifier.local.name, + })), + source: statement.source.value, + }, + ]; + }); + +export const topLevelNames = (program: Program): Set => { + const names = new Set(); + + for (const info of listImports(program)) { + for (const { local } of info.locals) names.add(local); + } + + for (const statement of program.body) { + const declaration = + statement.type === "ExportNamedDeclaration" + ? statement.declaration + : statement; + if (declaration === null || declaration === undefined) continue; + + if (declaration.type === "VariableDeclaration") { + for (const declarator of declaration.declarations) { + if (declarator.id.type === "Identifier") names.add(declarator.id.name); + } + } else if ( + (declaration.type === "FunctionDeclaration" || + declaration.type === "ClassDeclaration" || + declaration.type === "TSInterfaceDeclaration" || + declaration.type === "TSTypeAliasDeclaration" || + declaration.type === "TSEnumDeclaration") && + declaration.id + ) { + names.add(declaration.id.name); + } + } + + return names; +}; + +export const findConstInit = ( + program: Program, + name: string, +): Expression | null => { + for (const statement of program.body) { + const declaration = + statement.type === "ExportNamedDeclaration" + ? statement.declaration + : statement; + if (declaration?.type !== "VariableDeclaration") continue; + + for (const declarator of declaration.declarations) { + if ( + declarator.id.type === "Identifier" && + declarator.id.name === name && + declarator.init + ) { + return declarator.init; + } + } + } + + return null; +}; + +export const isExported = (program: Program, name: string): boolean => + program.body.some(statement => { + if (statement.type !== "ExportNamedDeclaration") return false; + if (statement.declaration?.type === "VariableDeclaration") { + return statement.declaration.declarations.some( + declarator => + declarator.id.type === "Identifier" && declarator.id.name === name, + ); + } + + return statement.specifiers.some( + specifier => + specifier.exported.type === "Identifier" && + specifier.exported.name === name, + ); + }); + +const compareSpecifiers = (a: string, b: string): number => + a.localeCompare(b, "en", { numeric: true, sensitivity: "base" }); + +const importKind = (specifier: string): number => + specifier.startsWith(".") ? 2 : specifier.startsWith("@/") ? 1 : 0; + +export const addNamedImport = ( + parsed: ParsedSource, + specifier: string, + name: string, + { type = false }: { type?: boolean } = {}, +): Edit[] => { + const { program, source } = parsed; + const imports = listImports(program); + + const existing = imports.find( + info => + info.source === specifier && + info.isType === type && + info.declaration.specifiers.every(s => s.type === "ImportSpecifier") && + info.declaration.specifiers.length > 0, + ); + + if (existing !== undefined) { + if (existing.locals.some(local => local.local === name)) return []; + const specifiers = existing.declaration.specifiers; + + const next = specifiers.find( + specifier => compareSpecifiers(specifier.local.name, name) > 0, + ); + if (next !== undefined) { + return [{ end: next.start, start: next.start, text: `${name}, ` }]; + } + + const last = specifiers[specifiers.length - 1]; + if (last === undefined) return []; + + const comma = commaAfter(source, last.end); + + return [ + comma === null + ? { end: last.end, start: last.end, text: `, ${name}` } + : { end: comma, start: comma, text: ` ${name},` }, + ]; + } + + const statement = `import ${type ? "type " : ""}{ ${name} } from ${JSON.stringify(specifier)};`; + const kind = importKind(specifier); + const sameKind = imports.filter( + info => info.isType === type && importKind(info.source) === kind, + ); + + const before = sameKind.find( + info => compareSpecifiers(info.source, specifier) > 0, + ); + if (before !== undefined) { + const at = startOfLineCommentsAbove( + source, + lineStart(source, before.declaration.start), + ); + + return [{ end: at, start: at, text: `${statement}\n` }]; + } + + const anchor = sameKind[sameKind.length - 1] ?? imports[imports.length - 1]; + + if (anchor === undefined) { + const directives = program.body.filter( + statement => + statement.type === "ExpressionStatement" && + typeof (statement as { directive?: unknown }).directive === "string", + ); + const last = directives[directives.length - 1]; + + return last === undefined + ? [{ end: 0, start: 0, text: `${statement}\n\n` }] + : [{ end: last.end, start: last.end, text: `\n\n${statement}` }]; + } + + const separator = sameKind.length > 0 ? "\n" : "\n\n"; + + return [ + { + end: anchor.declaration.end, + start: anchor.declaration.end, + text: `${separator}${statement}`, + }, + ]; +}; diff --git a/packages/vitnode/scripts/cli/generate/content-resource.ts b/packages/vitnode/scripts/cli/generate/content-resource.ts new file mode 100644 index 0000000000..17462b2b8a --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/content-resource.ts @@ -0,0 +1,661 @@ +import type { CallExpression, ObjectExpression } from "oxc-parser"; + +import type { Edit, ParsedSource } from "./ast"; +import type { GenerateContext } from "./generators"; + +import { ConfigError, UserError } from "../errors"; +import { + editableProperty, + locate, + lookupModule, + readParsed, + specifierFor, + UnsupportedStructureError, + withManual, +} from "./api-tree"; +import { + addNamedImport, + addProperty, + appendToArray, + applyEdits, + arrayValue, + findCalls, + findConstInit, + isExported, + objectArgument, + parseSource, + topLevelNames, + unwrap, +} from "./ast"; +import { + ensureBuilders, + locatePermissionStaff, + planModule, + propertyKey, + readAdminPermissions, +} from "./generators"; +import { camelCase, humanize } from "./names"; +import { API_BUILDERS } from "./project"; + +export interface ContentField { + name: string; + optional: boolean; + type: ContentFieldType; +} + +export type ContentFieldType = + | "boolean" + | "int" + | "number" + | "richText" + | "text"; + +const CONTENT_FIELD_TYPES: Record< + ContentFieldType, + (optional: boolean) => string +> = { + boolean: () => "field.boolean({ defaultValue: false })", + int: optional => + `field.number({ integer: true${optional ? ", nullable: true" : ", required: true"} })`, + number: optional => + `field.number({ integer: false${optional ? ", nullable: true" : ", required: true"} })`, + richText: optional => + `field.richText({${optional ? " nullable: true" : " required: true"} })`, + text: optional => + `field.text({ maxLength: 200${optional ? ", nullable: true" : ", required: true"} })`, +}; + +const CONTENT_ID = /^[a-z0-9]+(?:\.[a-z0-9-]+)+$/; +const TABLE_NAME = /^[a-z][a-z0-9_]*$/; + +export const parseContentFields = (input: string): ContentField[] => { + const fields = input + .split(",") + .map(part => part.trim()) + .filter(part => part !== "") + .map(part => { + const [, name, optional, type] = + /^([^:?\s]+)(\?)?\s*:\s*(\S+)$/.exec(part) ?? []; + if (name === undefined || type === undefined) { + throw new UserError(`"${part}" is not a field definition.`, { + details: [ + "Write fields as name:type, e.g. title:text,body:richText,featured?:boolean.", + ], + }); + } + if ( + !/^[a-z][A-Za-z0-9]*$/.test(name) || + ["createdAt", "id", "updatedAt"].includes(name) + ) { + throw new UserError(`"${name}" is not a supported field name.`, { + details: [ + "Use camelCase. id, createdAt and updatedAt are added by the Content Engine.", + ], + }); + } + if (!Object.hasOwn(CONTENT_FIELD_TYPES, type)) { + throw new UserError( + `"${type}" is not a supported content field type here.`, + { + hint: `Use one of: ${Object.keys(CONTENT_FIELD_TYPES).join(", ")}. Add other field helpers by hand afterwards.`, + }, + ); + } + + return { + name, + optional: optional === "?", + type: type as ContentFieldType, + }; + }); + + if (!fields.some(field => field.type === "text" && !field.optional)) { + throw new UserError( + "A content type needs a required text field for its title.", + { + details: [ + "The first required text field becomes admin.titleField, which the AdminCP list and pickers show.", + 'For example: --fields "title:text,body:richText".', + ], + }, + ); + } + + const names = fields.map(field => field.name); + const duplicate = names.find((name, index) => names.indexOf(name) !== index); + if (duplicate !== undefined) + throw new UserError(`The field "${duplicate}" is listed twice.`); + + return fields; +}; + +export interface ContentResourceOptions { + fields: readonly ContentField[]; + id?: string; + idStrategy: "bigint" | "serial" | "uuid"; + singular?: string; + tableName?: string; +} + +export interface ContentResourceResult { + adminPath: string; + contentTypeId: string; + permissionModule: string; + tableName: string; +} + +const shortName = (pluginId: string) => + pluginId.includes("/") ? pluginId.slice(pluginId.indexOf("/") + 1) : pluginId; + +const NAV = "src/admin/nav.tsx"; +const CONTENT = "src/admin/content.tsx"; +const LOCALE = "src/locales/en.json"; +const RE_EXPORT = + /^\s*export\s*\{\s*adminNav\s+as\s+adminContent\s*\}\s*from\s*["']\.\/nav["'];?\s*$/; + +const CONTENT_ADMIN_PERMISSIONS = [ + "can_view", + "can_create", + "can_edit", + "can_delete", +] as const; + +const assertContentPermissions = ( + ctx: GenerateContext, + permissionModule: string, +): void => { + const declarations = readAdminPermissions(ctx); + const lines = CONTENT_ADMIN_PERMISSIONS.map(permission => + permission === "can_view" + ? ` "can_view",` + : ` { permission: ${JSON.stringify(permission)}, dependsOn: ["can_view"] },`, + ); + + if (declarations.status === "unresolved") { + throw new UnsupportedStructureError( + `Cannot tell whether the plugin declares the "${permissionModule}" permission module itself: ${declarations.reason}.`, + [ + `The Content Engine adds ${CONTENT_ADMIN_PERMISSIONS.join(", ")} to "${permissionModule}" only when the plugin does not declare that module.`, + ], + [ + `Check permissionStaff.admin by hand: it must either have no ${JSON.stringify(permissionModule)} key, or list all four:`, + ` ${propertyKey(permissionModule)}: [`, + ...lines, + " ],", + "Then make permissionStaff an object literal in buildApiPlugin (or staffPermissions.config from defineStaffPermissions({ ... })) and run the command again.", + ], + ); + } + + const declared = declarations.modules.get(permissionModule); + if (declared === undefined) return; + + const have = new Set(declared); + const missing = CONTENT_ADMIN_PERMISSIONS.filter( + permission => !have.has(permission), + ); + if (missing.length === 0) return; + + const location = locatePermissionStaff(ctx); + const file = + location.kind === "unresolved" ? "src/config.api.ts" : location.file; + throw new ConfigError( + `The plugin declares the permission module "${permissionModule}" without ${missing.join(", ")}.`, + { + details: [ + `The Content Engine adds ${CONTENT_ADMIN_PERMISSIONS.join(", ")} to "${permissionModule}" only when the plugin does not declare it, and ${file} declares it with ${declared.length === 0 ? "no permissions" : declared.join(", ")}.`, + "The generated staff routes would require permissions nobody can be granted. Nothing was written.", + `Add the missing ones to permissionStaff.admin[${JSON.stringify(permissionModule)}] in ${file}:`, + ...lines.filter((_, index) => + missing.includes(CONTENT_ADMIN_PERMISSIONS[index] ?? "can_view"), + ), + `Or remove the ${JSON.stringify(permissionModule)} entry, so the Content Engine declares all four itself.`, + ], + hint: "Then run the command again.", + }, + ); +}; + +const navEntry = (definition: string) => + `{ definition: ${definition}, icon: }`; + +const planAdminNav = ( + ctx: GenerateContext, + definition: string, + definitionFile: string, +): void => { + const { plan, project } = ctx; + const manual = [ + `Import ${definition} and FileTextIcon (lucide-react) in ${NAV}, and add`, + ` ${navEntry(definition)}`, + 'to the contentTypes of its adminNav; src/admin/content.tsx must list it too (export { adminNav as adminContent } from "./nav" does).', + ]; + + const contentSource = plan.read(CONTENT); + const navSource = plan.read(NAV); + + if ( + contentSource !== null && + !RE_EXPORT.test( + contentSource.replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, "").trim(), + ) + ) { + throw new UnsupportedStructureError( + `${CONTENT} builds its own adminContent, so the generator cannot add the screen safely.`, + [ + `Only a missing ${CONTENT} or \`export { adminNav as adminContent } from "./nav"\` is edited automatically.`, + ], + manual, + ); + } + + if (navSource === null) { + plan.create( + NAV, + `import type { AdminNavPluginSource } from "@vitnode/core/lib/plugin"; + +import { FileTextIcon } from "lucide-react"; + +import { ${definition} } from ${JSON.stringify(specifierFor(project, NAV, definitionFile, true))}; +import { manifest } from ${JSON.stringify(specifierFor(project, NAV, "src/manifest.gen.ts", true))}; + +/** The plugin's AdminCP sidebar entries. */ +export const adminNav = { + pluginId: manifest.id, + contentTypes: [${navEntry(definition)}], +} satisfies AdminNavPluginSource; +`, + "AdminCP sidebar entry", + ); + } else { + let parsed: ParsedSource = parseSource(NAV, navSource); + const init = findConstInit(parsed.program, "adminNav"); + const object = init === null ? null : unwrap(init); + if (object?.type !== "ObjectExpression") { + throw new UnsupportedStructureError( + `${NAV} has no plain \`export const adminNav = { ... }\` object.`, + [], + manual, + ); + } + withManual(manual, () => + editableProperty(parsed, object, "contentTypes", `adminNav in ${NAV}`), + ); + + let source = navSource; + for (const [specifier, name] of [ + ["lucide-react", "FileTextIcon"], + [specifierFor(project, NAV, definitionFile, true), definition], + ] as const) { + if (topLevelNames(parsed.program).has(name)) { + if (name === "FileTextIcon") continue; + throw new UserError(`${NAV} already declares ${name}.`); + } + source = applyEdits(source, addNamedImport(parsed, specifier, name)); + parsed = parseSource(NAV, source); + } + + const reread = unwrap( + findConstInit(parsed.program, "adminNav") ?? object, + ) as ObjectExpression; + const property = withManual(manual, () => + editableProperty(parsed, reread, "contentTypes", `adminNav in ${NAV}`), + ); + const list = arrayValue(property); + if (property !== null && list === null) { + throw new UnsupportedStructureError( + `adminNav.contentTypes in ${NAV} is not an array literal.`, + [], + manual, + ); + } + source = applyEdits( + source, + list === null + ? addProperty( + source, + reread, + `contentTypes: [${navEntry(definition)}]`, + "pluginId", + ) + : appendToArray(source, list, navEntry(definition)), + ); + plan.update(NAV, source, `AdminCP sidebar entry for ${definition}`); + } + + if (contentSource === null) { + plan.create( + CONTENT, + `// The AdminCP screens: the same content types as the sidebar. +export { adminNav as adminContent } from "./nav"; +`, + "AdminCP screens, re-using the sidebar list", + ); + } +}; + +const ensureContentBuilders = (ctx: GenerateContext): void => { + const parsed = readParsed(ctx.plan, API_BUILDERS); + if (isExported(parsed.program, "buildContentAdminModule")) return; + + if ( + !topLevelNames(parsed.program).has("manifest") || + topLevelNames(parsed.program).has("contentBuilders") + ) { + throw new UnsupportedStructureError( + `${API_BUILDERS} does not export buildContentAdminModule, and cannot be extended safely.`, + [], + [ + 'import { bindContentPlugin } from "@vitnode/core/content/server";', + "const contentBuilders = bindContentPlugin(manifest);", + "export const buildContentAdminModule = contentBuilders.buildContentAdminModule;", + ], + ); + } + + let source = applyEdits( + parsed.source, + addNamedImport(parsed, "@vitnode/core/content/server", "bindContentPlugin"), + ); + source = `${source.trimEnd()} + +const contentBuilders = bindContentPlugin(manifest); + +export const buildContentAdminModule = contentBuilders.buildContentAdminModule; +`; + ctx.plan.update( + API_BUILDERS, + source, + "bind the Content Engine admin builder", + ); +}; + +const planAdminContentModule = ( + ctx: GenerateContext, + model: string, + modelFile: string, +): void => { + const { plan, project } = ctx; + const lookup = lookupModule(plan, project, ["admin"]); + const admin = + lookup.status === "found" + ? lookup.module + : planModule(ctx, ["admin"], { parents: false }); + const manual = [ + `Import ${model} from ${JSON.stringify(specifierFor(project, admin.file, modelFile, true))} in ${admin.file}, and either add it to the contentTypes of its buildContentAdminModule({ contentTypes: [...] })`, + `or add buildContentAdminModule({ contentTypes: [${model}] }) to the admin module's modules (buildContentAdminModule comes from ${specifierFor(project, admin.file, API_BUILDERS, true)}).`, + ]; + withManual(manual, () => + editableProperty( + admin.parsed, + admin.options, + "modules", + "the admin module", + ), + ); + + let parsed = readParsed(plan, admin.file); + let source = parsed.source; + for (const [specifier, name] of [ + [ + specifierFor(project, admin.file, API_BUILDERS, true), + "buildContentAdminModule", + ], + [specifierFor(project, admin.file, modelFile, true), model], + ] as const) { + if (name === model && topLevelNames(parsed.program).has(name)) { + throw new UserError(`${admin.file} already declares ${name}.`); + } + source = applyEdits(source, addNamedImport(parsed, specifier, name)); + parsed = parseSource(admin.file, source); + } + + const init = findConstInit(parsed.program, admin.exportName); + const call = init === null ? null : unwrap(init); + const options = call?.type === "CallExpression" ? objectArgument(call) : null; + if (options === null) { + throw new UnsupportedStructureError( + `The admin module in ${admin.file} could not be read.`, + [], + manual, + ); + } + + const modulesProperty = withManual(manual, () => + editableProperty(parsed, options, "modules", "the admin module"), + ); + const modules = arrayValue(modulesProperty); + if (modulesProperty !== null && modules === null) { + throw new UnsupportedStructureError( + "The modules of the admin module are not an array literal.", + [`At ${locate(parsed, modulesProperty.start)}.`], + manual, + ); + } + const existing: CallExpression[] = + modules === null + ? [] + : findCalls(parsed.program, "buildContentAdminModule").filter( + node => node.start >= modules.start && node.end <= modules.end, + ); + + if (existing.length > 1) { + throw new UnsupportedStructureError( + `The admin module in ${admin.file} mounts buildContentAdminModule more than once.`, + [], + [`Add ${model} to the contentTypes of one of them.`], + ); + } + + let edits: Edit[]; + const first = existing[0]; + if (first !== undefined) { + const argument = objectArgument(first); + const contentTypes = + argument === null + ? null + : arrayValue( + withManual([`Add ${model} to its contentTypes.`], () => + editableProperty( + parsed, + argument, + "contentTypes", + `buildContentAdminModule in ${admin.file}`, + ), + ), + ); + if (contentTypes === null) { + throw new UnsupportedStructureError( + `buildContentAdminModule in ${admin.file} has no contentTypes array literal.`, + [], + [`Add ${model} to its contentTypes.`], + ); + } + edits = appendToArray(source, contentTypes, model); + } else { + const entry = `buildContentAdminModule({ contentTypes: [${model}] })`; + edits = + modules === null + ? addProperty(source, options, `modules: [${entry}]`, "routes") + : appendToArray(source, modules, entry); + } + + plan.update( + admin.file, + applyEdits(source, edits), + `mount ${model} in the admin module`, + ); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const mergeMissing = ( + target: Record, + extra: Record, +) => { + for (const [key, value] of Object.entries(extra)) { + const current = target[key]; + if (current === undefined) target[key] = value; + else if (isRecord(current) && isRecord(value)) mergeMissing(current, value); + } +}; + +const planLabels = ( + ctx: GenerateContext, + labels: Record, +): void => { + const source = ctx.plan.read(LOCALE); + let messages: unknown = null; + try { + messages = source === null ? null : JSON.parse(source); + } catch { + messages = null; + } + + if ( + source === null || + !isRecord(messages) || + `${JSON.stringify(messages, null, 2)}\n` !== source + ) { + ctx.notes.push( + `Add these labels to ${LOCALE} by hand (it is missing or not plain two-space JSON):`, + ); + ctx.notes.push(JSON.stringify(labels, null, 2)); + + return; + } + + const merged = structuredClone(messages); + mergeMissing(merged, labels); + const next = `${JSON.stringify(merged, null, 2)}\n`; + if (next !== source) + ctx.plan.update(LOCALE, next, "AdminCP labels for the content type"); +}; + +export const planContentResource = ( + ctx: GenerateContext, + name: string, + options: ContentResourceOptions, +): ContentResourceResult => { + const { plan, project } = ctx; + const plugin = shortName(project.id); + const prefix = plugin.replace(/[^a-z0-9]/g, ""); + const contentTypeId = options.id ?? `${prefix}.${name}`; + const tableName = + options.tableName ?? + `${plugin.replace(/[^a-z0-9]+/g, "_")}_${name.replace(/-/g, "_")}`; + + if (!CONTENT_ID.test(contentTypeId)) { + throw new UserError(`"${contentTypeId}" is not a valid content type id.`, { + details: [ + 'It must look like "plugin.entity": lower-case, dot separated, no dash in the first part.', + ], + hint: "Pass one with --content-id, e.g. --content-id sitenotes.articles.", + }); + } + if (!TABLE_NAME.test(tableName)) { + throw new UserError(`"${tableName}" is not a valid table name.`, { + hint: "Pass one with --table, in snake_case starting with a letter.", + }); + } + + const entity = contentTypeId.split(".").slice(1).join("_"); + const permissionModule = entity + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + const camel = camelCase(name); + const definition = `${camel}ContentType`; + const model = `${camel}Content`; + const definitionFile = `src/content/${name}.ts`; + const modelFile = `src/database/${name}.ts`; + const adminPath = `${plugin.replace(/[^a-z0-9-]/g, "")}/${name}`; + const title = + options.fields.find(field => field.type === "text" && !field.optional) + ?.name ?? ""; + const plural = humanize(name); + const singular = options.singular ?? plural; + + assertContentPermissions(ctx, permissionModule); + ensureBuilders(ctx); + + plan.create( + definitionFile, + `import { defineContentType, field } from "@vitnode/core/content"; + +/** + * ${plural}, managed by staff in the AdminCP. + * + * Generated as an administrative content type: no public API, no member + * writes, no publication workflow. Enable those here when you decide who may + * read and write ${plural.toLowerCase()} - each one changes access to the records. + * + * Browser-safe: the server model is \`${model}\` in ${modelFile.replace(/^src\//, "@/").replace(/\.ts$/, "")}. + */ +export const ${definition} = defineContentType({ + id: ${JSON.stringify(contentTypeId)}, + tableName: ${JSON.stringify(tableName)}, +${options.idStrategy === "serial" ? "" : ` idStrategy: ${JSON.stringify(options.idStrategy)},\n`} fields: { +${options.fields.map(field => ` ${field.name}: ${CONTENT_FIELD_TYPES[field.type](field.optional)},`).join("\n")} + }, + admin: { + path: ${JSON.stringify(adminPath)}, + titleField: ${JSON.stringify(title)}, + list: { + columns: [${JSON.stringify(title)}, "updatedAt"], + }, + }, +}); +`, + `content type ${contentTypeId}`, + ); + + plan.create( + modelFile, + `import { createContentModel } from "@vitnode/core/content/server"; + +import { ${definition} } from ${JSON.stringify(specifierFor(project, modelFile, definitionFile, true))}; + +/** The server-side model of {@link ${definition}}. Server-only. */ +export const ${model} = createContentModel(${definition}); + +// Migrations find tables through the exports of the built src/database files. +export const ${tableName} = ${model}.table; +`, + `model and table ${tableName}`, + ); + + ensureContentBuilders(ctx); + planAdminContentModule(ctx, model, modelFile); + planAdminNav(ctx, definition, definitionFile); + + const fieldLabels = Object.fromEntries([ + ...options.fields.map(field => [ + field.name, + humanize(field.name.replace(/([A-Z])/g, " $1").toLowerCase()), + ]), + ["updatedAt", "Updated"], + ]); + planLabels(ctx, { + [project.id]: { + content: { + [entity]: { + desc: `Manage ${plural.toLowerCase()}.`, + fields: fieldLabels, + label: `{count, plural, one {${singular}} other {${plural}}}`, + title: plural, + }, + }, + title: humanize(plugin), + }, + [`${project.id}:${permissionModule}`]: plural, + [`${project.id}:${permissionModule}:can_view`]: `View ${plural.toLowerCase()}`, + [`${project.id}:${permissionModule}:can_create`]: `Create ${plural.toLowerCase()}`, + [`${project.id}:${permissionModule}:can_edit`]: `Edit ${plural.toLowerCase()}`, + [`${project.id}:${permissionModule}:can_delete`]: `Delete ${plural.toLowerCase()}`, + }); + + return { adminPath, contentTypeId, permissionModule, tableName }; +}; diff --git a/packages/vitnode/scripts/cli/generate/diff.ts b/packages/vitnode/scripts/cli/generate/diff.ts new file mode 100644 index 0000000000..7743fa8978 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/diff.ts @@ -0,0 +1,120 @@ +interface Op { + kind: " " | "+" | "-"; + line: string; +} + +const MAX_CELLS = 4_000_000; + +const splitLines = (text: string): string[] => { + const lines = text.split("\n"); + if (lines[lines.length - 1] === "") lines.pop(); + + return lines; +}; + +const diffLines = (a: readonly string[], b: readonly string[]): Op[] => { + let head = 0; + while (head < a.length && head < b.length && a[head] === b[head]) head += 1; + let tail = 0; + while ( + tail < a.length - head && + tail < b.length - head && + a[a.length - 1 - tail] === b[b.length - 1 - tail] + ) { + tail += 1; + } + + const midA = a.slice(head, a.length - tail); + const midB = b.slice(head, b.length - tail); + const ops: Op[] = a.slice(0, head).map(line => ({ kind: " ", line })); + + if ((midA.length + 1) * (midB.length + 1) > MAX_CELLS) { + ops.push(...midA.map(line => ({ kind: "-" as const, line }))); + ops.push(...midB.map(line => ({ kind: "+" as const, line }))); + } else { + const width = midB.length + 1; + const table = new Uint32Array((midA.length + 1) * width); + for (let i = midA.length - 1; i >= 0; i -= 1) { + for (let j = midB.length - 1; j >= 0; j -= 1) { + table[i * width + j] = + midA[i] === midB[j] + ? (table[(i + 1) * width + j + 1] ?? 0) + 1 + : Math.max( + table[(i + 1) * width + j] ?? 0, + table[i * width + j + 1] ?? 0, + ); + } + } + + let i = 0; + let j = 0; + while (i < midA.length && j < midB.length) { + if (midA[i] === midB[j]) { + ops.push({ kind: " ", line: midA[i] ?? "" }); + i += 1; + j += 1; + } else if ( + (table[(i + 1) * width + j] ?? 0) >= (table[i * width + j + 1] ?? 0) + ) { + ops.push({ kind: "-", line: midA[i] ?? "" }); + i += 1; + } else { + ops.push({ kind: "+", line: midB[j] ?? "" }); + j += 1; + } + } + while (i < midA.length) ops.push({ kind: "-", line: midA[i++] ?? "" }); + while (j < midB.length) ops.push({ kind: "+", line: midB[j++] ?? "" }); + } + + ops.push( + ...a.slice(a.length - tail).map(line => ({ kind: " " as const, line })), + ); + + return ops; +}; + +export const unifiedDiff = ( + path: string, + before: null | string, + after: string, + context = 3, +): string[] => { + const ops = diffLines(splitLines(before ?? ""), splitLines(after)); + const out = [ + `--- ${before === null ? "/dev/null" : `a/${path}`}`, + `+++ b/${path}`, + ]; + + const changed = ops.flatMap((op, index) => (op.kind === " " ? [] : [index])); + if (changed.length === 0) return out; + + const hunks: [number, number][] = []; + for (const index of changed) { + const start = Math.max(0, index - context); + const end = Math.min(ops.length, index + context + 1); + const last = hunks[hunks.length - 1]; + if (last !== undefined && start <= last[1]) + last[1] = Math.max(last[1], end); + else hunks.push([start, end]); + } + + for (const [start, end] of hunks) { + let oldLine = 1; + let newLine = 1; + for (const op of ops.slice(0, start)) { + if (op.kind !== "+") oldLine += 1; + if (op.kind !== "-") newLine += 1; + } + const slice = ops.slice(start, end); + const oldCount = slice.filter(op => op.kind !== "+").length; + const newCount = slice.filter(op => op.kind !== "-").length; + + out.push( + `@@ -${String(oldCount === 0 ? oldLine - 1 : oldLine)},${String(oldCount)} +${String(newCount === 0 ? newLine - 1 : newLine)},${String(newCount)} @@`, + ); + out.push(...slice.map(op => `${op.kind}${op.line}`)); + } + + return out; +}; diff --git a/packages/vitnode/scripts/cli/generate/effective-property.ts b/packages/vitnode/scripts/cli/generate/effective-property.ts new file mode 100644 index 0000000000..2825043a61 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/effective-property.ts @@ -0,0 +1,99 @@ +import type { + Expression, + ObjectExpression, + ObjectProperty, + ObjectPropertyKind, +} from "oxc-parser"; + +import { unwrap } from "./ast"; + +export type EffectiveProperty = + | { at: number; kind: "unsafe"; reason: string } + | { kind: "absent" } + | { kind: "property"; property: ObjectProperty }; + +const staticKey = (property: ObjectProperty): null | string => { + const { key } = property; + + if (!property.computed) { + if (key.type === "Identifier") return key.name; + if (key.type === "Literal") return String(key.value); + + return null; + } + + const value = unwrap(key as Expression); + if (value.type === "Literal" && typeof value.value === "string") { + return value.value; + } + if ( + value.type === "TemplateLiteral" && + value.expressions.length === 0 && + value.quasis.length === 1 + ) { + return value.quasis[0]?.value.cooked ?? null; + } + + return null; +}; + +const spreadMaySet = ( + element: Extract, + key: string, +): boolean => { + const argument = unwrap(element.argument); + if (argument.type !== "ObjectExpression") return true; + + return effectiveProperty(argument, key).kind !== "absent"; +}; + +export const effectiveProperty = ( + object: ObjectExpression, + key: string, +): EffectiveProperty => { + let selected: null | ObjectProperty = null; + let override: null | { at: number; what: string } = null; + + for (const entry of object.properties) { + if (entry.type === "SpreadElement") { + if (spreadMaySet(entry, key)) { + override ??= { at: entry.start, what: "a spread" }; + } + continue; + } + + const name = staticKey(entry); + if (name === null) { + override ??= { at: entry.start, what: "a computed key" }; + continue; + } + if (name !== key) continue; + + if (entry.kind !== "init" || entry.method) { + selected = null; + override = { + at: entry.start, + what: `a ${entry.method ? "method" : entry.kind === "get" ? "getter" : "setter"} named ${key}`, + }; + continue; + } + + selected = entry; + override = null; + } + + if (override !== null) { + return { + at: override.at, + kind: "unsafe", + reason: + selected === null + ? `${key} is not a plain property here, and ${override.what} may supply it - adding one before that could be overridden, adding one after it would drop what it supplies` + : `${override.what} after ${key} may replace it`, + }; + } + + return selected === null + ? { kind: "absent" } + : { kind: "property", property: selected }; +}; diff --git a/packages/vitnode/scripts/cli/generate/endpoints.ts b/packages/vitnode/scripts/cli/generate/endpoints.ts new file mode 100644 index 0000000000..0b3b83e29e --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/endpoints.ts @@ -0,0 +1,382 @@ +import type { ArrayExpression, Expression } from "oxc-parser"; + +import type { ParsedSource } from "./ast"; +import type { GenerateContext } from "./generators"; +import type { GenerationPlan } from "./plan"; +import type { GeneratorProject } from "./project"; + +import { UserError } from "../errors"; +import { + editableProperty, + locate, + readModuleContainer, + readParsed, + readRootContainer, + resolveBinding, + UnsupportedStructureError, +} from "./api-tree"; +import { + arrayValue, + findConstInit, + objectArgument, + stringValue, + unwrap, +} from "./ast"; +import { effectiveProperty } from "./effective-property"; + +const mergePath = (base: string, sub: string): string => + `${base.startsWith("/") ? "" : "/"}${base}${ + sub === "/" + ? "" + : `${base.endsWith("/") ? "" : "/"}${sub.startsWith("/") ? sub.slice(1) : sub}` + }`; + +export const joinEndpointPath = ( + modulePath: readonly string[], + routePath: string, +): string => + modulePath.reduceRight( + (path, name) => mergePath(mergePath("/", `/${name}`), path), + mergePath("/", routePath), + ); + +const pathKey = (path: string): string => + path.replace(/\{[^}]*\}/g, "{}").replace(/\/:[^/]+/g, "/{}"); + +const endpointKey = (method: string, path: string): string => + `${method.toLowerCase()} ${pathKey(path)}`; + +export interface DeclaredEndpoint { + file: string; + identifier: string; + method: string; + modulePath: string[]; + path: string; +} + +export interface UnverifiedRegion { + prefix: string; + reason: string; +} + +export interface EndpointInventory { + endpoints: DeclaredEndpoint[]; + unverified: UnverifiedRegion[]; +} + +const MAX_SPREAD_DEPTH = 8; + +const MAX_MOUNTS = 2000; + +export const collectEndpoints = ( + plan: GenerationPlan, + project: GeneratorProject, +): EndpointInventory => { + const endpoints: DeclaredEndpoint[] = []; + const unverified: UnverifiedRegion[] = []; + const mounted = new Set(); + let mounts = 0; + const prefixOf = (modulePath: readonly string[]) => + joinEndpointPath(modulePath, "/"); + + const unreadable = ( + modulePath: readonly string[], + where: string, + reason: string, + ) => { + unverified.push({ + prefix: prefixOf(modulePath), + reason: `${where}: ${reason.replace(/\.$/, "")}`, + }); + }; + + const readRoute = ( + parsed: ParsedSource, + element: Expression, + modulePath: readonly string[], + depth: number, + ): void => { + const value = unwrap(element); + let call: Expression | null = null; + let identifier = parsed.source.slice(value.start, value.end); + let file = parsed.path; + const site = locate(parsed, value.start); + + if (value.type === "CallExpression") { + call = value; + } else if (value.type === "Identifier") { + const binding = resolveBinding(plan, project, parsed, value.name); + if (typeof binding === "string") { + unreadable(modulePath, `${value.name} in ${parsed.path}`, binding); + + return; + } + const declared = readParsed(plan, binding.file); + const init = findConstInit(declared.program, binding.exportName); + if (init === null) { + unreadable( + modulePath, + `${value.name} in ${parsed.path}`, + `${binding.file} does not declare const ${binding.exportName}`, + ); + + return; + } + identifier = binding.exportName; + file = binding.file; + call = unwrap(init); + if (call.type === "ArrayExpression" && depth < MAX_SPREAD_DEPTH) { + readRoutes(declared, call, modulePath, depth + 1); + + return; + } + } + + const options = + call?.type === "CallExpression" && + call.callee.type === "Identifier" && + call.callee.name === "buildRoute" + ? objectArgument(call) + : null; + const routeProperty = + options === null ? null : effectiveProperty(options, "route"); + let route = + routeProperty?.kind === "property" + ? unwrap(routeProperty.property.value) + : null; + if ( + route?.type === "CallExpression" && + route.callee.type === "Identifier" && + route.callee.name === "createRoute" + ) { + route = objectArgument(route); + } + const method = + route?.type === "ObjectExpression" + ? effectiveProperty(route, "method") + : null; + const path = + route?.type === "ObjectExpression" + ? effectiveProperty(route, "path") + : null; + const methodValue = + method?.kind === "property" ? stringValue(method.property) : null; + const pathValue = + path?.kind === "property" ? stringValue(path.property) : null; + + if (methodValue === null || pathValue === null) { + unreadable( + modulePath, + `${identifier} at ${site}`, + "its method and path are not literals in a buildRoute({ route: { method, path } }) call", + ); + + return; + } + + endpoints.push({ + file, + identifier, + method: methodValue.toLowerCase(), + modulePath: [...modulePath], + path: joinEndpointPath(modulePath, pathValue), + }); + }; + + const readRoutes = ( + parsed: ParsedSource, + array: ArrayExpression, + modulePath: readonly string[], + depth: number, + ): void => { + for (const element of array.elements) { + if (element === null) continue; + if (element.type === "SpreadElement") { + const argument = unwrap(element.argument); + if ( + argument.type === "Identifier" || + argument.type === "ArrayExpression" + ) { + if (argument.type === "ArrayExpression") { + readRoutes(parsed, argument, modulePath, depth + 1); + } else { + readRoute(parsed, argument, modulePath, depth); + } + } else { + unreadable( + modulePath, + locate(parsed, element.start), + "a spread the generator cannot follow", + ); + } + continue; + } + readRoute(parsed, element, modulePath, depth); + } + }; + + const visitContainer = ( + container: ReturnType, + modulePath: readonly string[], + ancestry: readonly string[], + ): void => { + for (const entry of container.entries) { + if (entry.kind === "unresolved") { + unreadable( + modulePath, + `${entry.label} in ${container.file}`, + entry.reason, + ); + } else if (entry.kind === "fixed") { + unreadable( + [...modulePath, entry.name], + entry.label, + "generated by the Content Engine at runtime", + ); + } else { + const { source } = entry; + const path = [...modulePath, source.name]; + const id = `${source.file}#${source.exportName}`; + + if (ancestry.includes(id)) { + unreadable( + path, + `${source.exportName} in ${source.file}`, + `it mounts itself through ${[ + ...ancestry.slice(ancestry.indexOf(id)), + id, + ] + .map(item => item.slice(item.indexOf("#") + 1)) + .join(" > ")}, so its routes cannot be listed`, + ); + continue; + } + + const mount = `${id}@${prefixOf(path)}`; + if (mounted.has(mount)) continue; + mounted.add(mount); + + mounts += 1; + if (mounts > MAX_MOUNTS) { + unreadable( + path, + `${source.exportName} in ${source.file}`, + `the module tree has more than ${String(MAX_MOUNTS)} mounts, so the rest was not read`, + ); + continue; + } + + try { + const routes = editableProperty( + source.parsed, + source.options, + "routes", + `module "${path.join("/")}"`, + ); + const array = arrayValue(routes); + if (routes !== null && array === null) { + unreadable( + path, + locate(source.parsed, routes.start), + "routes is not an array literal", + ); + } else if (array !== null) { + readRoutes(source.parsed, array, path, 0); + } + visitContainer(readModuleContainer(plan, project, source), path, [ + ...ancestry, + id, + ]); + } catch (error) { + if (!(error instanceof UnsupportedStructureError)) throw error; + unreadable(path, source.file, error.summary); + } + } + } + }; + + try { + visitContainer(readRootContainer(plan, project), [], []); + } catch (error) { + if (!(error instanceof UnsupportedStructureError)) throw error; + unverified.push({ prefix: "/", reason: error.summary.replace(/\.$/, "") }); + } + + return { endpoints, unverified }; +}; + +export interface ProposedEndpoint { + file: string; + identifier: string; + method: string; + modulePath: readonly string[]; + routePath: string; +} + +const isUnder = (path: string, prefix: string) => + prefix === "/" || path === prefix || path.startsWith(`${prefix}/`); + +export const assertNoEndpointCollisions = ( + ctx: GenerateContext, + proposed: readonly ProposedEndpoint[], +): void => { + const { endpoints, unverified } = collectEndpoints(ctx.plan, ctx.project); + const known = new Map(); + for (const endpoint of endpoints) { + const key = endpointKey(endpoint.method, endpoint.path); + if (!known.has(key)) known.set(key, { ...endpoint, planned: false }); + } + + const paths: string[] = []; + for (const endpoint of proposed) { + const path = joinEndpointPath(endpoint.modulePath, endpoint.routePath); + const label = `${endpoint.method.toUpperCase()} ${path}`; + const destination = `Destination: ${endpoint.identifier} in ${endpoint.file} (module "${endpoint.modulePath.join("/")}", path ${endpoint.routePath}).`; + const existing = known.get(endpointKey(endpoint.method, path)); + + if (existing !== undefined) { + throw new UserError(`${label} already exists.`, { + details: [ + existing.planned + ? `This command would declare it twice: ${existing.identifier} in ${existing.file} answers ${existing.method.toUpperCase()} ${existing.path}.` + : `${existing.identifier} in ${existing.file} (module "${existing.modulePath.join("/")}") answers ${existing.method.toUpperCase()} ${existing.path}.`, + destination, + ...(existing.path === path + ? [] + : [ + "Path parameters match whatever their names: /{id} and /{noteId} are the same route.", + ]), + "Nothing was written.", + ], + hint: "Pick another --path, --method or module.", + }); + } + + known.set(endpointKey(endpoint.method, path), { + file: endpoint.file, + identifier: endpoint.identifier, + method: endpoint.method, + modulePath: [...endpoint.modulePath], + path, + planned: true, + }); + paths.push(path); + } + + const reported = new Set(); + for (const region of unverified) { + const affected = proposed.filter((_, index) => + isUnder(paths[index] ?? "", region.prefix), + ); + if (affected.length === 0) continue; + const message = `Could not verify that nothing under ${region.prefix} already answers ${affected + .map( + endpoint => + `${endpoint.method.toUpperCase()} ${joinEndpointPath(endpoint.modulePath, endpoint.routePath)}`, + ) + .join(", ")} - ${region.reason}. Check it by hand.`; + if (reported.has(message)) continue; + reported.add(message); + ctx.warnings.push(message); + } +}; diff --git a/packages/vitnode/scripts/cli/generate/format.ts b/packages/vitnode/scripts/cli/generate/format.ts new file mode 100644 index 0000000000..fdea05c887 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/format.ts @@ -0,0 +1,103 @@ +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import type { FileChange, GenerationPlan } from "./plan"; + +import { errorMessage } from "../errors"; + +type FormatFn = ( + fileName: string, + source: string, + options?: Record, +) => Promise<{ code: string; errors: unknown[] }>; + +const CONFIG_FILES = [ + "oxfmt.config.ts", + "oxfmt.config.mts", + "oxfmt.config.js", + "oxfmt.config.mjs", + ".oxfmtrc.json", +]; + +const findConfig = (from: string): null | string => { + let current = resolve(from); + + for (;;) { + const found = CONFIG_FILES.map(file => join(current, file)).find(file => + existsSync(file), + ); + if (found !== undefined) return found; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +}; + +const loadConfig = async (file: string): Promise> => { + if (file.endsWith(".json")) { + return JSON.parse(readFileSync(file, "utf8")) as Record; + } + + const { createJiti } = await import("jiti"); + const loaded = await createJiti(pathToFileURL(file).href).import<{ + default?: Record; + }>(file); + + return loaded.default ?? {}; +}; + +export interface FormatOutcome { + skipped?: string; +} + +export const formatPlan = async ( + plan: GenerationPlan, + changes: readonly FileChange[], +): Promise => { + let format: FormatFn; + let options: Record = {}; + + try { + const oxfmtPath = createRequire(join(plan.root, "package.json")).resolve( + "oxfmt", + ); + ({ format } = (await import(pathToFileURL(oxfmtPath).href)) as { + format: FormatFn; + }); + } catch { + return { skipped: "oxfmt is not installed in this project" }; + } + + const configFile = findConfig(plan.root); + if (configFile !== null) { + try { + const { ignorePatterns: _ignored, ...rest } = + await loadConfig(configFile); + options = rest; + } catch (error) { + return { + skipped: `the oxfmt config could not be loaded (${errorMessage(error).split("\n")[0] ?? ""})`, + }; + } + } + + for (const change of changes) { + if (!/\.(?:ts|tsx|mts|cts|js|mjs)$/.test(change.path)) continue; + + if (change.before !== null) { + const original = await format(change.path, change.before, options); + if (original.errors.length > 0 || original.code !== change.before) { + continue; + } + } + + const formatted = await format(change.path, change.after, options); + if (formatted.errors.length === 0) { + plan.replaceContent(change.path, formatted.code); + } + } + + return {}; +}; diff --git a/packages/vitnode/scripts/cli/generate/generators.ts b/packages/vitnode/scripts/cli/generate/generators.ts new file mode 100644 index 0000000000..ee5f7afc8b --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/generators.ts @@ -0,0 +1,794 @@ +import type { ObjectExpression } from "oxc-parser"; + +import { posix } from "node:path"; + +import type { ModuleContainer, ModuleSource } from "./api-tree"; +import type { Edit, ParsedSource } from "./ast"; +import type { PermissionRef } from "./names"; +import type { GenerationPlan } from "./plan"; +import type { GeneratorProject } from "./project"; +import type { HttpMethod, ParamType } from "./templates"; + +import { staffPermissionKey } from "../../../src/lib/staff-permissions"; +import { ConfigError, UserError } from "../errors"; +import { + editableProperty, + locate, + lookupModule, + readModuleContainer, + readModuleSource, + readParsed, + readRootContainer, + resolveBinding, + specifierFor, + UnsupportedStructureError, + withManual, +} from "./api-tree"; +import { + addNamedImport, + addProperty, + appendToArray, + applyEdits, + arrayValue, + findConstInit, + isExported, + listImports, + objectArgument, + objectValue, + stringValue, + topLevelNames, + unwrap, +} from "./ast"; +import { effectiveProperty } from "./effective-property"; +import { assertNoEndpointCollisions, joinEndpointPath } from "./endpoints"; +import { + moduleIdentifier, + permissionLabel, + routeIdentifier, + serviceFactoryIdentifier, +} from "./names"; +import { API_BUILDERS, API_CONFIG, MODULES_DIR } from "./project"; +import { + buildersTemplate, + builtModuleTemplate, + routeTemplate, + serviceTemplate, +} from "./templates"; + +export interface GenerateContext { + notes: string[]; + plan: GenerationPlan; + project: GeneratorProject; + warnings: string[]; +} + +const MANIFEST = "src/manifest.gen.ts"; + +const IDENTIFIER = /^[A-Za-z_$][\w$]*$/; + +export const propertyKey = (name: string): string => + IDENTIFIER.test(name) ? name : JSON.stringify(name); + +export const ensureBuilders = (ctx: GenerateContext): void => { + const { plan, project } = ctx; + + if (plan.exists(API_BUILDERS)) { + const parsed = readParsed(plan, API_BUILDERS); + const missing = ["buildModule", "buildRoute"].filter( + name => !isExported(parsed.program, name), + ); + if (missing.length > 0) { + throw new ConfigError( + `${API_BUILDERS} does not export ${missing.join(" and ")}.`, + { + details: [ + "Generated files import the plugin-bound builders from it. Add them:", + " const apiBuilders = bindApiPlugin(manifest);", + " export const buildModule = apiBuilders.buildModule;", + " export const buildRoute = apiBuilders.buildRoute;", + ], + }, + ); + } + + return; + } + + const manifest = plan.read(MANIFEST); + if ( + manifest === null || + !isExported(readParsed(plan, MANIFEST).program, "manifest") + ) { + throw new ConfigError( + `${API_BUILDERS} is missing, and so is the plugin's manifest.`, + { + details: [ + `The builders bind to the plugin id through ${MANIFEST}, which vitnode build and vitnode dev write.`, + ], + hint: "Run vitnode build in the plugin once, then generate again.", + }, + ); + } + + plan.create( + API_BUILDERS, + buildersTemplate(specifierFor(project, API_BUILDERS, MANIFEST, true)), + "the plugin-bound buildModule and buildRoute", + ); +}; + +const buildersSpecifier = (project: GeneratorProject, file: string) => + specifierFor(project, file, API_BUILDERS, true); + +export const assertFreeName = (parsed: ParsedSource, name: string) => { + if (topLevelNames(parsed.program).has(name)) { + throw new UserError(`${parsed.path} already declares ${name}.`, { + details: [ + "The generator does not shadow or rename existing identifiers.", + ], + }); + } +}; + +const prefersAlias = (parsed: ParsedSource): boolean => { + const moduleImports = listImports(parsed.program).filter(info => + /(?:^@\/|^\.\/)api\/modules\//.test(info.source), + ); + if (moduleImports.length === 0) return true; + + return ( + moduleImports.filter(info => info.source.startsWith("@/")).length >= + moduleImports.length / 2 + ); +}; + +const registerModule = ( + ctx: GenerateContext, + container: ModuleContainer, + identifier: string, + file: string, + where: string, +) => { + const { parsed } = container; + assertFreeName(parsed, identifier); + + const isRoot = container.file === API_CONFIG; + const specifier = specifierFor( + ctx.project, + container.file, + file, + isRoot && prefersAlias(parsed), + ); + const edits: Edit[] = [...addNamedImport(parsed, specifier, identifier)]; + + if (container.array === null) { + edits.push( + ...addProperty( + parsed.source, + container.options, + `modules: [${identifier}]`, + isRoot ? undefined : "routes", + ), + ); + } else { + edits.push(...appendToArray(parsed.source, container.array, identifier)); + } + + ctx.plan.update( + container.file, + applyEdits(parsed.source, edits), + `register ${identifier} in ${where}`, + ); +}; + +export interface ModuleOptions { + parents: boolean; +} + +export const planModule = ( + ctx: GenerateContext, + path: readonly string[], + { parents }: ModuleOptions, +): ModuleSource => { + const { plan, project } = ctx; + const lookup = lookupModule(plan, project, path); + + if (lookup.status === "found") { + throw new UserError(`Module "${path.join("/")}" already exists.`, { + details: [ + `Declared as ${lookup.module.exportName} in ${lookup.module.file}.`, + ], + }); + } + + if (lookup.depth < path.length - 1 && !parents) { + const missing = path.slice(0, lookup.depth + 1).join("/"); + throw new UserError(`The parent module "${missing}" does not exist.`, { + details: [ + `"${path.join("/")}" is registered inside its parent, so the parent has to exist first.`, + ], + hint: `Create it with vitnode g module ${missing}, or pass --parents to create every missing parent.`, + }); + } + + let container = lookup.container; + let dir = + lookup.found.length === 0 + ? MODULES_DIR + : (lookup.found[lookup.found.length - 1]?.dir ?? MODULES_DIR); + let created: ModuleSource | null = null; + + for (let index = lookup.depth; index < path.length; index += 1) { + const segment = path[index] ?? ""; + const modulePath = path.slice(0, index + 1); + const identifier = moduleIdentifier(modulePath); + const file = posix.join(dir, segment, `${segment}.module.ts`); + + plan.create( + file, + builtModuleTemplate({ + builders: buildersSpecifier(project, file), + identifier, + name: segment, + }), + `module "${modulePath.join("/")}"`, + ); + + registerModule( + ctx, + container, + identifier, + file, + index === 0 + ? "buildApiPlugin" + : `module "${path.slice(0, index).join("/")}"`, + ); + + const source = readModuleSource(plan, file, identifier); + if (typeof source === "string") { + throw new ConfigError( + `The generated module could not be read back: ${source}.`, + ); + } + + created = source; + container = readModuleContainer(plan, project, created); + dir = created.dir; + } + + if (created === null) throw new ConfigError("No module was planned."); + + return created; +}; + +export interface RouteOptions { + method: HttpMethod; + name: string; + paramType: ParamType; + parents: boolean; + path: string; + permission?: PermissionRef; +} + +export const pathParams = (path: string): string[] => + [...path.matchAll(/\{([^}]+)\}/g)].map(match => match[1] ?? ""); + +export const endpointPath = (modulePath: readonly string[], path: string) => + joinEndpointPath(modulePath, path); + +export const routeFileFor = (module: ModuleSource, name: string): string => + posix.join(module.dir, "routes", `${name}.route.ts`); + +export const planRoute = ( + ctx: GenerateContext, + modulePath: readonly string[], + options: RouteOptions, +): { file: string; identifier: string } => { + const { plan, project } = ctx; + const lookup = lookupModule(plan, project, modulePath); + + let module: ModuleSource; + if (lookup.status === "found") { + module = lookup.module; + } else if (options.parents) { + module = planModule(ctx, modulePath, { parents: true }); + } else { + throw new UserError(`Module "${modulePath.join("/")}" does not exist.`, { + hint: `Create it with vitnode g module ${modulePath.join("/")}, or pass --parents to create the missing modules.`, + }); + } + + const routesProperty = editableProperty( + module.parsed, + module.options, + "routes", + `module "${modulePath.join("/")}"`, + ); + const routes = arrayValue(routesProperty); + if (routesProperty !== null && routes === null) { + throw new UnsupportedStructureError( + `The routes of module "${modulePath.join("/")}" are not an array literal.`, + [`At ${locate(module.parsed, routesProperty.start)}.`], + ); + } + + const endpoint = `${options.method.toUpperCase()} ${endpointPath(modulePath, options.path)}`; + const identifier = routeIdentifier(options.name, modulePath); + const file = routeFileFor(module, options.name); + + assertNoEndpointCollisions(ctx, [ + { + file, + identifier, + method: options.method, + modulePath, + routePath: options.path, + }, + ]); + assertFreeName(module.parsed, identifier); + + plan.create( + file, + routeTemplate({ + builders: buildersSpecifier(project, file), + endpoint, + fileName: `${options.name}.route.ts`, + identifier, + method: options.method, + params: pathParams(options.path), + paramType: options.paramType, + path: options.path, + permission: options.permission, + permissionSource: + options.permission === undefined + ? undefined + : permissionSource(ctx, file, options.permission), + }), + endpoint, + ); + + const edits: Edit[] = [ + ...addNamedImport( + module.parsed, + specifierFor(project, module.file, file, false), + identifier, + ), + ...(routes === null + ? addProperty( + module.parsed.source, + module.options, + `routes: [${identifier}]`, + "name", + ) + : appendToArray(module.parsed.source, routes, identifier)), + ]; + plan.update( + module.file, + applyEdits(module.parsed.source, edits), + `register ${identifier} in module "${modulePath.join("/")}"`, + ); + + return { file, identifier }; +}; + +export type PermissionDeclarations = + | { modules: Map; status: "resolved" } + | { reason: string; status: "unresolved" }; + +export type PermissionStaffLocation = + | { + exportName: string; + file: string; + kind: "defined"; + parsed: ParsedSource; + staff: ObjectExpression; + } + | { + file: string; + holder: ObjectExpression; + kind: "inline"; + parsed: ParsedSource; + staff: null | ObjectExpression; + } + | { kind: "unresolved"; reason: string }; + +export const locatePermissionStaff = ( + ctx: GenerateContext, +): PermissionStaffLocation => { + const root = readRootContainer(ctx.plan, ctx.project); + const effective = effectiveProperty(root.options, "permissionStaff"); + + if (effective.kind === "unsafe") { + return { + kind: "unresolved", + reason: `in buildApiPlugin (${locate(root.parsed, effective.at)}), ${effective.reason}`, + }; + } + if (effective.kind === "absent") { + return { + file: API_CONFIG, + holder: root.options, + kind: "inline", + parsed: root.parsed, + staff: null, + }; + } + const property = effective.property; + + const value = unwrap(property.value); + if (value.type === "ObjectExpression") { + return { + file: API_CONFIG, + holder: root.options, + kind: "inline", + parsed: root.parsed, + staff: value, + }; + } + + if ( + value.type === "MemberExpression" && + !value.computed && + value.object.type === "Identifier" && + value.property.type === "Identifier" && + value.property.name === "config" + ) { + const binding = resolveBinding( + ctx.plan, + ctx.project, + root.parsed, + value.object.name, + ); + if (typeof binding === "string") { + return { kind: "unresolved", reason: `${value.object.name}: ${binding}` }; + } + + const parsed = readParsed(ctx.plan, binding.file); + const init = findConstInit(parsed.program, binding.exportName); + const call = init === null ? null : unwrap(init); + const staff = + call?.type === "CallExpression" && + call.callee.type === "Identifier" && + call.callee.name === "defineStaffPermissions" + ? objectArgument(call) + : null; + + return staff === null + ? { + kind: "unresolved", + reason: `${binding.exportName} in ${binding.file} is not a defineStaffPermissions({...}) call`, + } + : { + exportName: binding.exportName, + file: binding.file, + kind: "defined", + parsed, + staff, + }; + } + + return { + kind: "unresolved", + reason: "permissionStaff is not an object literal", + }; +}; + +export const permissionSource = ( + ctx: GenerateContext, + fromFile: string, + ref: PermissionRef, +): undefined | { expression: string; importLine: string } => { + const location = locatePermissionStaff(ctx); + if ( + location.kind !== "defined" || + location.file === API_CONFIG || + !isExported(location.parsed.program, location.exportName) + ) { + return undefined; + } + const declarations = readAdminPermissions(ctx); + if ( + declarations.status !== "resolved" || + declarations.modules.get(ref.module)?.includes(ref.permission) !== true + ) { + return undefined; + } + + const member = (key: string) => + IDENTIFIER.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`; + const key = staffPermissionKey(ref.permission); + + return { + expression: `${location.exportName}.admin${member(ref.module)}${member(key)}`, + importLine: `import { ${location.exportName} } from ${JSON.stringify(specifierFor(ctx.project, fromFile, location.file, true))};`, + }; +}; + +export const readAdminPermissions = ( + ctx: GenerateContext, +): PermissionDeclarations => { + const location = locatePermissionStaff(ctx); + if (location.kind === "unresolved") { + return { reason: location.reason, status: "unresolved" }; + } + + const { staff } = location; + if (staff === null) return { modules: new Map(), status: "resolved" }; + + const admin = effectiveProperty(staff, "admin"); + if (admin.kind === "unsafe") { + return { + reason: `in permissionStaff (${locate(location.parsed, admin.at)}), ${admin.reason}`, + status: "unresolved", + }; + } + if (admin.kind === "absent") + return { modules: new Map(), status: "resolved" }; + + const adminObject = objectValue(admin.property); + if ( + adminObject === null || + adminObject.properties.some(property => property.type === "SpreadElement") + ) { + return { + reason: "permissionStaff.admin is not a plain object literal", + status: "unresolved", + }; + } + + const modules = new Map(); + for (const property of adminObject.properties) { + if ( + property.type !== "Property" || + property.computed || + property.kind !== "init" || + property.method + ) { + return { + reason: "permissionStaff.admin has a computed key, getter or method", + status: "unresolved", + }; + } + const key = + property.key.type === "Identifier" + ? property.key.name + : property.key.type === "Literal" + ? String(property.key.value) + : null; + const entries = arrayValue(property); + if (key === null || entries === null) { + return { + reason: `permissionStaff.admin.${key ?? "?"} is not an array literal`, + status: "unresolved", + }; + } + + const names: string[] = []; + for (const element of entries.elements) { + const value = + element === null || element.type === "SpreadElement" + ? null + : unwrap(element); + if (value?.type === "Literal" && typeof value.value === "string") { + names.push(value.value); + } else if (value?.type === "ObjectExpression") { + const entry = effectiveProperty(value, "permission"); + const permission = + entry.kind === "property" ? stringValue(entry.property) : null; + if (permission === null) { + return { + reason: `an entry of permissionStaff.admin.${key} has no literal permission`, + status: "unresolved", + }; + } + names.push(permission); + } else { + return { + reason: `permissionStaff.admin.${key} has an entry that is not a literal`, + status: "unresolved", + }; + } + } + modules.set(key, names); + } + + return { modules, status: "resolved" }; +}; + +const LOCALE = "src/locales/en.json"; + +const planPermissionLabels = (ctx: GenerateContext, ref: PermissionRef) => { + const source = ctx.plan.read(LOCALE); + const keys = { + [`${ctx.project.id}:${ref.module}`]: ref.module + .replace(/[-_]+/g, " ") + .replace(/^./, char => char.toUpperCase()), + [`${ctx.project.id}:${ref.module}:${ref.permission}`]: permissionLabel(ref), + }; + const manual = `Add the AdminCP labels to the root of ${LOCALE}: ${Object.entries( + keys, + ) + .map(([key, value]) => `"${key}": "${value}"`) + .join(", ")}.`; + + if (source === null) { + ctx.notes.push(manual); + + return; + } + + let messages: Record; + try { + messages = JSON.parse(source) as Record; + } catch { + ctx.warnings.push(`${LOCALE} is not valid JSON. ${manual}`); + + return; + } + + if (`${JSON.stringify(messages, null, 2)}\n` !== source) { + ctx.notes.push(manual); + + return; + } + + const missing = Object.entries(keys).filter(([key]) => !(key in messages)); + if (missing.length === 0) return; + + ctx.plan.update( + LOCALE, + `${JSON.stringify({ ...messages, ...Object.fromEntries(missing) }, null, 2)}\n`, + `AdminCP labels for ${ref.module}.${ref.permission}`, + ); +}; + +export const planPermission = ( + ctx: GenerateContext, + ref: PermissionRef, + { declare, contentModules }: { contentModules: boolean; declare: boolean }, +): void => { + const declarations = readAdminPermissions(ctx); + const wanted = `${ref.module}.${ref.permission}`; + const entry = `${propertyKey(ref.module)}: [${JSON.stringify(ref.permission)}]`; + const snippet = `permissionStaff: { admin: { ${entry} } }`; + + if (declarations.status === "unresolved") { + if (declare) { + throw new UnsupportedStructureError( + `Cannot declare ${wanted}: ${declarations.reason}.`, + [ + `Add ${JSON.stringify(ref.permission)} to permissionStaff.admin[${JSON.stringify(ref.module)}] in ${API_CONFIG} by hand.`, + ], + ); + } + ctx.warnings.push( + `Could not verify that ${wanted} is declared (${declarations.reason}). Check ${API_CONFIG}.`, + ); + + return; + } + + if (declarations.modules.get(ref.module)?.includes(ref.permission) === true) + return; + + if (!declare) { + if (contentModules && !declarations.modules.has(ref.module)) { + ctx.warnings.push( + `${wanted} is not declared in ${API_CONFIG}. If a content type generates the "${ref.module}" permission module, this is fine; otherwise nobody can be granted it. Run vitnode plugin validate after building to check.`, + ); + + return; + } + + const location = locatePermissionStaff(ctx); + const defined = location.kind === "defined"; + throw new UserError(`The staff permission ${wanted} is not declared.`, { + details: [ + `Nobody can be granted a permission the plugin does not declare in ${defined ? `${location.file} (defineStaffPermissions)` : API_CONFIG}:`, + ` ${defined ? `admin: { ${entry} }` : snippet}`, + ], + hint: "Pass --declare-permission to add it (and its labels), or pick a declared one.", + }); + } + + const location = locatePermissionStaff(ctx); + if (location.kind === "unresolved") { + throw new UnsupportedStructureError( + `Cannot declare ${wanted}: ${location.reason}.`, + [], + [ + `Add ${JSON.stringify(ref.permission)} to permissionStaff.admin[${JSON.stringify(ref.module)}] by hand:`, + ` ${snippet}`, + ], + ); + } + + const { parsed, staff } = location; + const edits: Edit[] = []; + const manual = [ + `Add ${JSON.stringify(ref.permission)} to permissionStaff.admin[${JSON.stringify(ref.module)}] in ${location.file}${location.kind === "defined" ? ` (the object passed to defineStaffPermissions for ${location.exportName})` : ""}:`, + ` admin: { ${entry} }`, + ]; + const editable = (object: ObjectExpression, name: string, where: string) => + withManual(manual, () => editableProperty(parsed, object, name, where)); + const adminProperty = + staff === null ? null : editable(staff, "admin", "permissionStaff"); + const admin = objectValue(adminProperty); + if (adminProperty !== null && admin === null) { + throw new UnsupportedStructureError( + `Cannot declare ${wanted}: permissionStaff.admin is not an object literal.`, + [`At ${locate(parsed, adminProperty.start)}.`], + manual, + ); + } + const moduleProperty = + admin === null + ? null + : editable(admin, ref.module, "permissionStaff.admin"); + const moduleEntries = arrayValue(moduleProperty); + if (moduleProperty !== null && moduleEntries === null) { + throw new UnsupportedStructureError( + `Cannot declare ${wanted}: permissionStaff.admin[${JSON.stringify(ref.module)}] is not an array literal.`, + [`At ${locate(parsed, moduleProperty.start)}.`], + manual, + ); + } + + if (staff === null) { + if (location.kind === "inline") { + edits.push(...addProperty(parsed.source, location.holder, snippet)); + } + } else if (admin === null) { + edits.push(...addProperty(parsed.source, staff, `admin: { ${entry} }`)); + } else if (moduleEntries === null) { + edits.push(...addProperty(parsed.source, admin, entry)); + } else { + edits.push( + ...appendToArray( + parsed.source, + moduleEntries, + JSON.stringify(ref.permission), + ), + ); + } + + ctx.plan.update( + location.file, + applyEdits(parsed.source, edits), + `declare the admin permission ${wanted}`, + ); + planPermissionLabels(ctx, ref); +}; + +export const hasContentModules = (ctx: GenerateContext): boolean => { + const lookup = lookupModule(ctx.plan, ctx.project, ["admin"]); + if (lookup.status !== "found") return false; + + return readModuleContainer(ctx.plan, ctx.project, lookup.module).entries.some( + entry => entry.kind === "fixed", + ); +}; + +export const planService = ( + ctx: GenerateContext, + modulePath: readonly string[], +): { factory: string; file: string } => { + const lookup = lookupModule(ctx.plan, ctx.project, modulePath); + if (lookup.status !== "found") { + throw new UserError(`Module "${modulePath.join("/")}" does not exist.`, { + hint: `Create it first with vitnode g module ${modulePath.join("/")}.`, + }); + } + + const leaf = modulePath[modulePath.length - 1] ?? ""; + const file = posix.join(lookup.module.dir, `${leaf}.service.ts`); + const factory = serviceFactoryIdentifier(modulePath); + + ctx.plan.create( + file, + serviceTemplate({ factory, module: modulePath.join("/") }), + `service for module "${modulePath.join("/")}"`, + ); + + return { factory, file }; +}; diff --git a/packages/vitnode/scripts/cli/generate/names.ts b/packages/vitnode/scripts/cli/generate/names.ts new file mode 100644 index 0000000000..879b8b1643 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/names.ts @@ -0,0 +1,132 @@ +import { UserError } from "../errors"; + +const SEGMENT = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; + +const suggestKebab = (value: string): string => + value + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/[^A-Za-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); + +export const assertSegment = (value: string, what: string): string => { + if (SEGMENT.test(value)) return value; + + const suggestion = suggestKebab(value); + + throw new UserError(`"${value}" is not a valid ${what}.`, { + details: [ + "Use lower-case kebab-case that starts with a letter, e.g. notes or site-notes.", + `A module name is also its URL segment, so VitNode does not rename it for you.`, + ], + hint: + suggestion !== "" && SEGMENT.test(suggestion) && suggestion !== value + ? `Did you mean "${suggestion}"?` + : undefined, + }); +}; + +export const parseLogicalPath = (input: string, what: string): string[] => { + const trimmed = input.trim(); + + if (trimmed === "") throw new UserError(`The ${what} is empty.`); + + if ( + trimmed.startsWith("/") || + trimmed.startsWith("~") || + /^[A-Za-z]:/.test(trimmed) || + trimmed.includes("\\") + ) { + throw new UserError(`"${input}" looks like a file path, not a ${what}.`, { + details: [ + "Name modules by their logical path inside the plugin, e.g. notes or admin/notes.", + "The generator decides where the files go: src/api/modules//.", + ], + }); + } + + const segments = trimmed.split("/"); + + if ( + segments.some( + segment => segment === "" || segment === "." || segment === "..", + ) + ) { + throw new UserError(`"${input}" is not a valid ${what}.`, { + details: [ + "Segments are separated by a single /, and . or .. are not allowed.", + ], + }); + } + + return segments.map(segment => assertSegment(segment, `${what} segment`)); +}; + +export const camelCase = (kebab: string): string => + kebab.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase()); + +export const pascalCase = (kebab: string): string => { + const camel = camelCase(kebab); + + return `${camel[0]?.toUpperCase() ?? ""}${camel.slice(1)}`; +}; + +export const moduleIdentifier = (modulePath: readonly string[]): string => + `${camelCase(modulePath.join("-"))}Module`; + +export const routeIdentifier = ( + routeName: string, + modulePath: readonly string[], +): string => + `${camelCase(routeName)}${pascalCase(modulePath[modulePath.length - 1] ?? "")}Route`; + +export const serviceFactoryIdentifier = ( + modulePath: readonly string[], +): string => + `create${pascalCase(modulePath[modulePath.length - 1] ?? "")}Service`; + +export const humanize = (kebab: string): string => { + const words = kebab.replace(/[-_]+/g, " ").trim(); + + return `${words[0]?.toUpperCase() ?? ""}${words.slice(1)}`; +}; + +export interface PermissionRef { + module: string; + permission: string; +} + +const PERMISSION_PART = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$/; + +export const parsePermission = (input: string): PermissionRef => { + const parts = input.trim().split("."); + const [module, permission] = parts; + + if ( + parts.length !== 2 || + module === undefined || + permission === undefined || + !PERMISSION_PART.test(module) || + !PERMISSION_PART.test(permission) + ) { + throw new UserError(`"${input}" is not a valid staff permission.`, { + details: [ + "Write it as ., e.g. notes.can_create.", + "Both parts are lower-case words of letters and digits joined by single _ or -. The plugin id is implied.", + ], + }); + } + + return { module, permission }; +}; + +export const permissionLabel = ({ + module, + permission, +}: PermissionRef): string => { + const verb = /^can_(.+)$/.exec(permission)?.[1]; + + return verb === undefined + ? humanize(permission) + : `${humanize(verb)} ${humanize(module).toLowerCase()}`; +}; diff --git a/packages/vitnode/scripts/cli/generate/plan.ts b/packages/vitnode/scripts/cli/generate/plan.ts new file mode 100644 index 0000000000..82203d1950 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/plan.ts @@ -0,0 +1,202 @@ +import { + existsSync, + linkSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; + +import { ConfigError, RuntimeError, UserError } from "../errors"; + +export interface FileChange { + after: string; + before: null | string; + kind: "create" | "update"; + path: string; + reasons: string[]; +} + +const toPosix = (path: string) => path.split(sep).join("/"); + +export class GenerationPlan { + constructor(root: string) { + this.root = resolve(root); + } + private readonly disk = new Map(); + private readonly overlay = new Map< + string, + { content: string; reasons: string[] } + >(); + readonly root: string; + + private normalize(path: string): string { + const absolute = resolve(this.root, path); + const rel = relative(this.root, absolute); + + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { + throw new UserError( + `Refusing to write ${path}: it is outside the project at ${this.root}.`, + ); + } + + return toPosix(rel); + } + + private readDisk(path: string): null | string { + if (!this.disk.has(path)) { + const absolute = join(this.root, path); + this.disk.set( + path, + existsSync(absolute) ? readFileSync(absolute, "utf8") : null, + ); + } + + return this.disk.get(path) ?? null; + } + + changes(): FileChange[] { + const changes: FileChange[] = []; + + for (const [path, { content, reasons }] of this.overlay) { + const before = this.readDisk(path); + if (before === content) continue; + changes.push({ + after: content, + before, + kind: before === null ? "create" : "update", + path, + reasons, + }); + } + + return changes.sort((a, b) => + a.kind === b.kind ? 0 : a.kind === "create" ? -1 : 1, + ); + } + + create(path: string, content: string, reason: string): void { + const rel = this.normalize(path); + + if (this.read(rel) !== null) { + throw new UserError(`${rel} already exists.`, { + details: [ + "The generator never overwrites files. Pick another name, or move the existing file first.", + ], + }); + } + + this.overlay.set(rel, { content, reasons: [reason] }); + } + + exists(path: string): boolean { + return this.read(path) !== null; + } + + read(path: string): null | string { + const rel = this.normalize(path); + + return this.overlay.get(rel)?.content ?? this.readDisk(rel); + } + + replaceContent(path: string, content: string): void { + const rel = this.normalize(path); + const existing = this.overlay.get(rel); + if (existing !== undefined) existing.content = content; + } + + update(path: string, content: string, reason: string): void { + const rel = this.normalize(path); + const existing = this.overlay.get(rel); + + if (existing === undefined && this.readDisk(rel) === null) { + throw new ConfigError(`Cannot update ${rel}: it does not exist.`); + } + + this.overlay.set(rel, { + content, + reasons: [...(existing?.reasons ?? []), reason], + }); + } +} + +export const applyPlan = ( + root: string, + changes: readonly FileChange[], +): void => { + for (const change of changes) { + const absolute = join(root, change.path); + const current = existsSync(absolute) + ? readFileSync(absolute, "utf8") + : null; + + if (current !== change.before) { + throw new RuntimeError( + `${change.path} changed while the generator was running. Nothing was written.`, + { hint: "Run the command again - it re-reads every file." }, + ); + } + } + + const createdDirs: string[] = []; + const ensureDir = (dir: string) => { + const missing: string[] = []; + let current = dir; + while (!existsSync(current)) { + missing.unshift(current); + current = dirname(current); + } + for (const path of missing) { + mkdirSync(path); + createdDirs.push(path); + } + }; + + const stamp = `${String(process.pid)}-${Date.now().toString(36)}`; + const staged: { target: string; temp: string }[] = []; + const applied: FileChange[] = []; + + const rollback = () => { + for (const { temp } of staged) rmSync(temp, { force: true }); + for (const change of applied.reverse()) { + const target = join(root, change.path); + if (change.before === null) rmSync(target, { force: true }); + else writeFileSync(target, change.before, "utf8"); + } + for (const dir of createdDirs.reverse()) { + if (readdirSync(dir).length === 0) rmdirSync(dir); + } + }; + + try { + for (const change of changes) { + const target = join(root, change.path); + ensureDir(dirname(target)); + const temp = `${target}.vitnode-${stamp}.tmp`; + writeFileSync(temp, change.after, "utf8"); + staged.push({ target, temp }); + } + + for (const [index, { target, temp }] of staged.entries()) { + const change = changes[index]; + if (change?.before === null) { + linkSync(temp, target); + rmSync(temp, { force: true }); + } else { + renameSync(temp, target); + } + if (change !== undefined) applied.push(change); + } + staged.length = 0; + } catch (error) { + rollback(); + throw new RuntimeError( + "Writing the generated files failed. The changes already written were rolled back.", + { cause: error }, + ); + } +}; diff --git a/packages/vitnode/scripts/cli/generate/project.ts b/packages/vitnode/scripts/cli/generate/project.ts new file mode 100644 index 0000000000..c4db11160f --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/project.ts @@ -0,0 +1,159 @@ +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; + +import type { Prompter } from "../ui/prompts"; +import type { Ui } from "../ui/ui"; + +import { ConfigError, UserError } from "../errors"; +import { isPluginPackage } from "../plugins/discover"; +import { findWorkspaceRoot, workspacePackageDirs } from "../plugins/workspace"; +import { findPackageRoot, readPackageJson } from "../project/packages"; +import { toDisplayPath } from "../ui/format"; + +export const API_CONFIG = "src/config.api.ts"; +export const API_BUILDERS = "src/api/builders.ts"; +export const MODULES_DIR = "src/api/modules"; + +export interface GeneratorProject { + alias: boolean; + display: string; + id: string; + root: string; +} + +const hasApi = (dir: string) => existsSync(join(dir, API_CONFIG)); + +const describe = (cwd: string, root: string): GeneratorProject => { + const tsconfig = join(root, "tsconfig.json"); + const alias = + existsSync(tsconfig) && + /["']@\/\*["']\s*:/.test(readFileSync(tsconfig, "utf8")); + + return { + alias, + display: toDisplayPath(relative(cwd, root)) || ".", + id: readPackageJson(root)?.name ?? root, + root, + }; +}; + +const requireApi = (cwd: string, root: string): GeneratorProject => { + const project = describe(cwd, root); + + if (!hasApi(root)) { + throw new ConfigError( + `${project.id} has no API: ${API_CONFIG} is missing.`, + { + details: [ + "The generator adds modules to the buildApiPlugin({ modules }) call in src/config.api.ts.", + "Create that file first - a plugin made with create-vitnode-app --plugin already has it.", + ], + }, + ); + } + + return project; +}; + +const eligiblePlugins = (cwd: string): string[] => { + const workspaceRoot = findWorkspaceRoot(cwd); + if (workspaceRoot === null) return []; + + return workspacePackageDirs(workspaceRoot).filter( + dir => isPluginPackage(dir) && hasApi(dir), + ); +}; + +export const resolveGeneratorProject = async ({ + cwd, + project, + prompter, + ui, +}: { + cwd: string; + project?: string; + prompter: Prompter; + ui: Ui; +}): Promise => { + if (project !== undefined) { + const byName = eligiblePluginsOrAll(cwd).find( + dir => readPackageJson(dir)?.name === project, + ); + if (byName !== undefined) return requireApi(cwd, byName); + + const asPath = isAbsolute(project) ? project : resolve(cwd, project); + if (existsSync(join(asPath, "package.json"))) { + if (!isPluginPackage(asPath)) { + throw new UserError(`${project} is not a VitNode plugin package.`, { + details: [ + "API generators currently write into plugins: a package that depends on @vitnode/core and has src/config.tsx.", + ], + }); + } + + return requireApi(cwd, asPath); + } + + const candidates = eligiblePlugins(cwd).map( + dir => readPackageJson(dir)?.name ?? dir, + ); + throw new UserError( + `No plugin named or located at "${project}" was found.`, + { + hint: + candidates.length === 0 + ? "Pass the plugin's exact package name or the path to its folder." + : `Plugins with an API here: ${candidates.join(", ")}`, + }, + ); + } + + const packageRoot = findPackageRoot(cwd); + if (packageRoot !== null && isPluginPackage(packageRoot)) { + return requireApi(cwd, packageRoot); + } + + const candidates = eligiblePlugins(cwd); + + if (candidates.length === 1 && candidates[0] !== undefined) { + const only = describe(cwd, candidates[0]); + ui.note( + `Using ${only.id} (${only.display}), the only plugin with an API in this workspace.`, + ); + + return only; + } + + if (candidates.length === 0) { + throw new ConfigError("No plugin to generate into was found.", { + details: [ + "Run the command inside a plugin folder, or pass --project .", + "API generators write into plugins with a src/config.api.ts.", + ], + hint: "Create a plugin with npx create-vitnode-app --plugin .", + }); + } + + const choices = candidates.map(dir => { + const info = describe(cwd, dir); + + return { name: `${info.id} ${ui.colors.muted(info.display)}`, value: dir }; + }); + + if (!ui.interactive) { + throw new UserError("More than one plugin could take this code.", { + details: choices.map(choice => ` ${choice.name}`), + hint: "Pass --project to choose one.", + }); + } + + return describe(cwd, await prompter.select("Which plugin?", choices)); +}; + +const eligiblePluginsOrAll = (cwd: string): string[] => { + const workspaceRoot = findWorkspaceRoot(cwd); + + return workspaceRoot === null + ? [] + : workspacePackageDirs(workspaceRoot).filter(dir => isPluginPackage(dir)); +}; diff --git a/packages/vitnode/scripts/cli/generate/resource-templates.ts b/packages/vitnode/scripts/cli/generate/resource-templates.ts new file mode 100644 index 0000000000..671cc159b2 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/resource-templates.ts @@ -0,0 +1,358 @@ +import type { ParamType, PermissionSource } from "./templates"; + +import { UserError } from "../errors"; +import { localImports } from "./templates"; + +export interface ResourceField { + name: string; + optional: boolean; + type: ResourceFieldType; +} + +export type ResourceFieldType = "boolean" | "int" | "number" | "string"; + +const FIELD_TYPES: Record = { + boolean: "z.boolean()", + int: "z.number().int()", + number: "z.number()", + string: "z.string()", +}; + +const FIELD_NAME = /^[a-z][A-Za-z0-9]*$/; + +export const parseFields = (input: string): ResourceField[] => { + const trimmed = input.trim(); + if (trimmed === "") return []; + + const fields = trimmed.split(",").map(part => { + const match = /^\s*([^:?\s]+)(\?)?\s*:\s*(\S+)\s*$/.exec(part); + const [, name, optional, type] = match ?? []; + + if (name === undefined || type === undefined) { + throw new UserError(`"${part.trim()}" is not a field definition.`, { + details: [ + "Write fields as name:type, separated by commas, e.g. title:string,pinned?:boolean.", + ], + }); + } + if (!FIELD_NAME.test(name) || name === "id") { + throw new UserError(`"${name}" is not a supported field name.`, { + details: [ + "Use camelCase starting with a lower-case letter. id is added for you.", + ], + }); + } + if (!Object.hasOwn(FIELD_TYPES, type)) { + throw new UserError(`"${type}" is not a supported field type.`, { + hint: `Use one of: ${Object.keys(FIELD_TYPES).join(", ")}.`, + }); + } + + return { + name, + optional: optional === "?", + type: type as ResourceFieldType, + }; + }); + + const names = fields.map(field => field.name); + const duplicate = names.find((name, index) => names.indexOf(name) !== index); + if (duplicate !== undefined) { + throw new UserError(`The field "${duplicate}" is listed twice.`); + } + + return fields; +}; + +export interface ResourceNames { + createSchema: string; + createType: string; + factory: string; + itemSchema: string; + itemType: string; + label: string; + leaf: string; + updateSchema: string; + updateType: string; +} + +const idSchema = (type: ParamType) => + type === "number" ? "z.number().int().positive()" : "z.string()"; + +export const resourceSchemaTemplate = ({ + fields, + idType, + names, +}: { + fields: readonly ResourceField[]; + idType: ParamType; + names: ResourceNames; +}): string => { + const line = (field: ResourceField) => + ` ${field.name}: ${FIELD_TYPES[field.type]}${field.optional ? ".optional()" : ""},`; + const note = + fields.length === 0 + ? ` +// Generated without --fields: only the id is modelled. Add the fields your +// ${names.leaf} have to both schemas - the routes and the service follow them. +` + : ""; + + return `import { z } from "@hono/zod-openapi"; +${note} +/** One ${names.leaf} entry, as the API returns it. */ +export const ${names.itemSchema} = z.object({ + id: ${idSchema(idType)}, +${fields.map(line).join("\n")} +}); + +/** What creating a ${names.leaf} entry accepts. */ +export const ${names.createSchema} = z.object({ +${fields.map(line).join("\n")} +}); + +/** What updating a ${names.leaf} entry accepts: any subset of the create fields. */ +export const ${names.updateSchema} = ${names.createSchema}.partial(); + +export type ${names.itemType} = z.infer; +export type ${names.createType} = z.infer; +export type ${names.updateType} = z.infer; +`; +}; + +export const resourceServiceTemplate = ({ + idType, + names, + schemaSpecifier, +}: { + idType: ParamType; + names: ResourceNames; + schemaSpecifier: string; +}): string => { + const deps = `${names.factory.replace(/^create/, "")}Deps`; + const id = idType === "number" ? "number" : "string"; + const maybeItem = [names.itemType, "null"] + .sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" })) + .join(" | "); + + return `import type { EnvVariablesVitNode } from "@vitnode/core/api/middlewares/global.middleware"; + +import { HTTPException } from "hono/http-exception"; + +import type { + ${names.createType}, + ${names.itemType}, + ${names.updateType}, +} from ${JSON.stringify(schemaSpecifier)}; + +/** What the ${names.label} service needs, passed in by whoever builds it. */ +export interface ${deps} { + /** In a route: \`c.get("db")\`. */ + db: Omit; +} + +/** Every operation answers 501 until it has a real implementation. */ +const notImplemented = async (operation: string): Promise => + Promise.reject( + new HTTPException(501, { + message: \`${names.label}: \${operation} is not implemented yet. Implement it in ${names.leaf}.service.ts.\`, + }), + ); + +/** + * Operations of the \`${names.label}\` resource. + * + * Generated as typed stubs with no storage behind them: each one rejects with + * 501 until you implement it - none returns made-up data. Keep the signatures + * and replace the bodies, e.g. with queries through \`deps.db\`. + */ +export const ${names.factory} = (deps: ${deps}) => ({ + deps, + list: async (): Promise<${names.itemType}[]> => notImplemented("list"), + /** Resolves \`null\` when there is no entry with this id. */ + get: async (_id: ${id}): Promise<${maybeItem}> => notImplemented("get"), + create: async (_input: ${names.createType}): Promise<${names.itemType}> => + notImplemented("create"), + /** Resolves \`null\` when there is no entry with this id. */ + update: async (_id: ${id}, _input: ${names.updateType}): Promise<${maybeItem}> => + notImplemented("update"), + /** Resolves \`false\` when there is no entry with this id. */ + remove: async (_id: ${id}): Promise => notImplemented("remove"), +}); +`; +}; + +export type ResourceOperation = + | "create" + | "delete" + | "list" + | "show" + | "update"; + +export const RESOURCE_OPERATIONS: readonly { + method: "delete" | "get" | "patch" | "post"; + name: ResourceOperation; + path: string; + permission: "can_create" | "can_delete" | "can_edit" | "can_view"; +}[] = [ + { method: "get", name: "list", path: "/", permission: "can_view" }, + { method: "get", name: "show", path: "/{id}", permission: "can_view" }, + { method: "post", name: "create", path: "/", permission: "can_create" }, + { method: "patch", name: "update", path: "/{id}", permission: "can_edit" }, + { method: "delete", name: "delete", path: "/{id}", permission: "can_delete" }, +]; + +export const resourceRouteTemplate = ({ + builders, + endpoint, + factorySpecifier, + identifier, + idType, + names, + operation, + permission, + permissionSource, + schemaSpecifier, +}: { + builders: string; + endpoint: string; + factorySpecifier: string; + identifier: string; + idType: ParamType; + names: ResourceNames; + operation: ResourceOperation; + permission?: { module: string; permission: string }; + permissionSource?: PermissionSource; + schemaSpecifier: string; +}): string => { + const hasId = + operation === "show" || operation === "update" || operation === "delete"; + const schemaImports = { + create: [names.createSchema, names.itemSchema], + delete: [], + list: [names.itemSchema], + show: [names.itemSchema], + update: [names.itemSchema, names.updateSchema], + }[operation]; + const method = + RESOURCE_OPERATIONS.find(item => item.name === operation)?.method ?? "get"; + const path = hasId ? "/{id}" : "/"; + const service = `${names.factory}({ db: c.get("db") })`; + const params = + idType === "number" + ? "z.object({ id: z.coerce.number().int().positive() })" + : "z.object({ id: z.string() })"; + const notFound = ` throw new HTTPException(404, { message: "No ${names.leaf} entry with this id." });`; + + const request = [ + hasId ? ` params: ${params},` : null, + operation === "create" || operation === "update" + ? ` body: { + required: true, + content: { + "application/json": { + schema: ${operation === "create" ? names.createSchema : names.updateSchema}, + }, + }, + },` + : null, + ].filter(line => line !== null); + + const success = { + create: ` 201: { + content: { "application/json": { schema: ${names.itemSchema} } }, + description: "The created entry.", + },`, + delete: ` 204: { description: "The entry was deleted." },`, + list: ` 200: { + content: { "application/json": { schema: z.array(${names.itemSchema}) } }, + description: "Every entry.", + },`, + show: ` 200: { + content: { "application/json": { schema: ${names.itemSchema} } }, + description: "The entry.", + },`, + update: ` 200: { + content: { "application/json": { schema: ${names.itemSchema} } }, + description: "The updated entry.", + },`, + }[operation]; + + const handler = { + create: ` handler: async c => { + const entry = await ${service}.create(c.req.valid("json")); + + return c.json(entry, 201); + },`, + delete: ` handler: async c => { + const { id } = c.req.valid("param"); + const deleted = await ${service}.remove(id); + if (!deleted) { + ${notFound} + } + + return c.body(null, 204); + },`, + list: ` handler: async c => c.json(await ${service}.list(), 200),`, + show: ` handler: async c => { + const { id } = c.req.valid("param"); + const entry = await ${service}.get(id); + if (!entry) { + ${notFound} + } + + return c.json(entry, 200); + },`, + update: ` handler: async c => { + const { id } = c.req.valid("param"); + const entry = await ${service}.update(id, c.req.valid("json")); + if (!entry) { + ${notFound} + } + + return c.json(entry, 200); + },`, + }[operation]; + + const usesZ = operation === "list" || hasId; + const usesHttpException = hasId; + const guard = + permission === undefined + ? "" + : permissionSource === undefined + ? ` adminStaffPermission: { module: ${JSON.stringify(permission.module)}, permission: ${JSON.stringify(permission.permission)} },\n` + : ` adminStaffPermission: ${permissionSource.expression},\n`; + const ownership = + permission === undefined || + operation === "list" || + operation === "show" || + operation === "create" + ? "" + : ` + * + * The staff permission decides who may call it. Whether this caller may change + * this particular entry is for the service or handler to check.`; + + return `${usesZ ? 'import { z } from "@hono/zod-openapi";\n' : ""}${usesHttpException ? 'import { HTTPException } from "hono/http-exception";\n' : ""} +${localImports(builders, permissionSource)} + +${schemaImports.length > 0 ? `import { ${schemaImports.join(", ")} } from ${JSON.stringify(schemaSpecifier)};\n` : ""}import { ${names.factory} } from ${JSON.stringify(factorySpecifier)}; + +/** + * ${endpoint} - part of the generated \`${names.label}\` resource. + * + * Answers 501 until \`${names.factory}().${operation === "delete" ? "remove" : operation === "show" ? "get" : operation}\` is implemented.${ownership} + */ +export const ${identifier} = buildRoute({ +${guard} route: { + method: ${JSON.stringify(method)}, + path: ${JSON.stringify(path)}, +${request.length === 0 ? "" : ` request: {\n${request.join("\n")}\n },\n`} responses: { +${success} +${permission === undefined ? "" : ` 403: { description: ${JSON.stringify(`Requires the ${permission.module}.${permission.permission} staff permission.`)} },\n`}${hasId ? ' 404: { description: "No entry with this id." },\n' : ""} 501: { description: "Not implemented yet." }, + }, + }, +${handler} +}); +`; +}; diff --git a/packages/vitnode/scripts/cli/generate/resource.ts b/packages/vitnode/scripts/cli/generate/resource.ts new file mode 100644 index 0000000000..e54029836c --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/resource.ts @@ -0,0 +1,278 @@ +import { posix } from "node:path"; + +import type { ModuleSource } from "./api-tree"; +import type { GenerateContext } from "./generators"; +import type { ResourceField, ResourceNames } from "./resource-templates"; +import type { ParamType } from "./templates"; + +import { isAdminApiPath } from "../../../src/api/lib/route-metadata"; +import { UserError } from "../errors"; +import { + editableProperty, + locate, + lookupModule, + specifierFor, + UnsupportedStructureError, +} from "./api-tree"; +import { + addNamedImport, + addProperty, + appendToArray, + applyEdits, + arrayValue, + findConstInit, + objectArgument, + parseSource, + unwrap, +} from "./ast"; +import { assertNoEndpointCollisions } from "./endpoints"; +import { + assertFreeName, + endpointPath, + permissionSource, + planModule, + planPermission, + readAdminPermissions, + routeFileFor, +} from "./generators"; +import { pascalCase, routeIdentifier, serviceFactoryIdentifier } from "./names"; +import { API_BUILDERS } from "./project"; +import { + RESOURCE_OPERATIONS, + resourceRouteTemplate, + resourceSchemaTemplate, + resourceServiceTemplate, +} from "./resource-templates"; + +export interface ApiResourceOptions { + fields: readonly ResourceField[]; + idType: ParamType; + parents: boolean; + permissions?: { declare: boolean; module: string }; +} + +export interface ApiResourceResult { + files: { routes: string[]; schema: string; service: string }; + names: ResourceNames; +} + +const namesFor = (modulePath: readonly string[]): ResourceNames => { + const leaf = modulePath[modulePath.length - 1] ?? ""; + const pascal = pascalCase(leaf); + + return { + createSchema: `zod${pascal}CreateBody`, + createType: `${pascal}CreateInput`, + factory: serviceFactoryIdentifier(modulePath), + itemSchema: `zod${pascal}Item`, + itemType: `${pascal}Item`, + label: modulePath.join("/"), + leaf, + updateSchema: `zod${pascal}UpdateBody`, + updateType: `${pascal}UpdateInput`, + }; +}; + +export const planApiResource = ( + ctx: GenerateContext, + modulePath: readonly string[], + options: ApiResourceOptions, +): ApiResourceResult => { + const { plan, project } = ctx; + const names = namesFor(modulePath); + const isAdmin = isAdminApiPath(modulePath.join("/")); + + if (isAdmin && options.permissions === undefined) { + throw new UserError("An admin resource needs --permission-module.", { + details: [ + "Each operation is guarded by its own permission in that module:", + " list, show → can_view create → can_create update → can_edit delete → can_delete", + ], + }); + } + + if (options.permissions !== undefined) { + const { declare, module } = options.permissions; + const wanted = [ + ...new Set(RESOURCE_OPERATIONS.map(operation => operation.permission)), + ]; + const declarations = readAdminPermissions(ctx); + const declared = + declarations.status === "resolved" + ? new Set(declarations.modules.get(module)) + : null; + const missing = + declared === null ? [] : wanted.filter(name => !declared.has(name)); + + if (!declare && missing.length > 0) { + throw new UserError( + `${missing.map(name => `${module}.${name}`).join(", ")} ${missing.length === 1 ? "is" : "are"} not declared.`, + { + details: [ + `Nobody can be granted a permission the plugin does not declare in permissionStaff.admin[${JSON.stringify(module)}].`, + ], + hint: "Pass --declare-permission to add them (and their labels).", + }, + ); + } + + for (const permission of wanted) { + planPermission( + ctx, + { module, permission }, + { contentModules: false, declare }, + ); + } + } + + const lookup = lookupModule(plan, project, modulePath); + let module: ModuleSource; + if (lookup.status === "found") { + module = lookup.module; + } else { + module = planModule(ctx, modulePath, { parents: options.parents }); + } + + const routesProperty = editableProperty( + module.parsed, + module.options, + "routes", + `module "${names.label}"`, + ); + const routes = arrayValue(routesProperty); + if (routesProperty !== null && routes === null) { + throw new UnsupportedStructureError( + `The routes of module "${names.label}" are not an array literal.`, + [`At ${locate(module.parsed, routesProperty.start)}.`], + ); + } + if (routes !== null && routes.elements.length > 0) { + throw new UserError(`Module "${names.label}" already has routes.`, { + details: [ + "A resource scaffold owns its module's list/show/create/update/delete routes.", + "Generate it into a new module, or add single routes with vitnode g route.", + ], + }); + } + + assertNoEndpointCollisions( + ctx, + RESOURCE_OPERATIONS.map(operation => ({ + file: routeFileFor(module, operation.name), + identifier: routeIdentifier(operation.name, modulePath), + method: operation.method, + modulePath, + routePath: operation.path, + })), + ); + + for (const operation of RESOURCE_OPERATIONS) { + assertFreeName(module.parsed, routeIdentifier(operation.name, modulePath)); + } + + const schema = posix.join(module.dir, `${names.leaf}.schema.ts`); + const service = posix.join(module.dir, `${names.leaf}.service.ts`); + + plan.create( + schema, + resourceSchemaTemplate({ + fields: options.fields, + idType: options.idType, + names, + }), + `schemas for the ${names.label} resource`, + ); + plan.create( + service, + resourceServiceTemplate({ + idType: options.idType, + names, + schemaSpecifier: specifierFor(project, service, schema, false), + }), + `service stubs for the ${names.label} resource`, + ); + + const routeFiles: string[] = []; + const identifiers: string[] = []; + let source = module.parsed.source; + + for (const operation of RESOURCE_OPERATIONS) { + const identifier = routeIdentifier(operation.name, modulePath); + const file = routeFileFor(module, operation.name); + const endpoint = `${operation.method.toUpperCase()} ${endpointPath(modulePath, operation.path)}`; + + plan.create( + file, + resourceRouteTemplate({ + builders: specifierFor(project, file, API_BUILDERS, true), + endpoint, + factorySpecifier: specifierFor(project, file, service, false), + identifier, + idType: options.idType, + names, + operation: operation.name, + permission: + options.permissions === undefined + ? undefined + : { + module: options.permissions.module, + permission: operation.permission, + }, + permissionSource: + options.permissions === undefined + ? undefined + : permissionSource(ctx, file, { + module: options.permissions.module, + permission: operation.permission, + }), + schemaSpecifier: specifierFor(project, file, schema, false), + }), + endpoint, + ); + routeFiles.push(file); + identifiers.push(identifier); + source = applyEdits( + source, + addNamedImport( + parseSource(module.file, source), + specifierFor(project, module.file, file, false), + identifier, + ), + ); + } + + const parsed = parseSource(module.file, source); + const init = findConstInit(parsed.program, module.exportName); + const call = init === null ? null : unwrap(init); + const moduleOptions = + call?.type === "CallExpression" ? objectArgument(call) : null; + if (moduleOptions === null) { + throw new UnsupportedStructureError( + `Module "${names.label}" could not be read back.`, + [`File: ${module.file}`], + ); + } + + const list = identifiers.join(", "); + const current = arrayValue( + editableProperty( + parsed, + moduleOptions, + "routes", + `module "${names.label}"`, + ), + ); + source = applyEdits( + source, + current === null + ? addProperty(source, moduleOptions, `routes: [${list}]`, "name") + : appendToArray(source, current, list), + ); + plan.update( + module.file, + source, + `register the ${names.label} resource routes`, + ); + + return { files: { routes: routeFiles, schema, service }, names }; +}; diff --git a/packages/vitnode/scripts/cli/generate/templates.ts b/packages/vitnode/scripts/cli/generate/templates.ts new file mode 100644 index 0000000000..6c7383dcbe --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/templates.ts @@ -0,0 +1,242 @@ +export type HttpMethod = "delete" | "get" | "patch" | "post" | "put"; + +export const builtModuleTemplate = ({ + builders, + identifier, + name, +}: { + builders: string; + identifier: string; + name: string; +}): string => `import { buildModule } from ${JSON.stringify(builders)}; + +export const ${identifier} = buildModule({ + name: ${JSON.stringify(name)}, + routes: [], +}); +`; + +export const buildersTemplate = (manifest: string): string => + `import { bindApiPlugin } from "@vitnode/core/api/lib/bind-api-plugin"; + +import { manifest } from ${JSON.stringify(manifest)}; + +const apiBuilders = bindApiPlugin(manifest); + +export const buildModule = apiBuilders.buildModule; +export const buildRoute = apiBuilders.buildRoute; +`; + +export type ParamType = "number" | "string"; + +const paramSchema = (type: ParamType) => + type === "number" ? "z.coerce.number().int().positive()" : "z.string()"; + +const paramsObject = (params: readonly string[], type: ParamType) => + `z.object({ ${params.map(param => `${param}: ${paramSchema(type)}`).join(", ")} })`; + +export interface PermissionSource { + expression: string; + importLine: string; +} + +const permissionLine = ( + permission?: { module: string; permission: string }, + source?: PermissionSource, +) => + permission === undefined + ? "" + : source === undefined + ? ` adminStaffPermission: { module: ${JSON.stringify(permission.module)}, permission: ${JSON.stringify(permission.permission)} },\n` + : ` adminStaffPermission: ${source.expression},\n`; + +export const localImports = ( + builders: string, + source?: PermissionSource, +): string => { + const buildersLine = `import { buildRoute } from ${JSON.stringify(builders)};`; + if (source === undefined) return buildersLine; + + return source.importLine.includes('"@/') === buildersLine.includes('"@/') + ? [buildersLine, source.importLine].sort().join("\n") + : `${buildersLine}\n\n${source.importLine}`; +}; + +const forbiddenResponse = (permission?: { + module: string; + permission: string; +}) => + permission === undefined + ? "" + : ` 403: { description: ${JSON.stringify(`Requires the ${permission.module}.${permission.permission} staff permission.`)} },\n`; + +export interface RouteTemplateOptions { + builders: string; + endpoint: string; + fileName: string; + identifier: string; + method: HttpMethod; + params: readonly string[]; + paramType: ParamType; + path: string; + permission?: { module: string; permission: string }; + permissionSource?: PermissionSource; +} + +const sampleGetTemplate = (options: RouteTemplateOptions): string => { + const { + builders, + endpoint, + fileName, + identifier, + params, + paramType, + path, + permission, + } = options; + const message = `Sample data from ${fileName}. Replace it with the real response.`; + const paramFields = params + .map( + param => + `${param}: ${paramType === "number" ? "z.number()" : "z.string()"}, `, + ) + .join(""); + const handler = + params.length === 0 + ? ` handler: c => + c.json( + { sample: true as const, message: ${JSON.stringify(message)} }, + 200, + ),` + : ` handler: c => { + const { ${params.join(", ")} } = c.req.valid("param"); + + return c.json( + { sample: true as const, ${params.join(", ")}, message: ${JSON.stringify(message)} }, + 200, + ); + },`; + + return `import { z } from "@hono/zod-openapi"; + +${localImports(builders, options.permissionSource)} + +/** + * ${endpoint} - a generated starter that answers with sample data. + * + * Replace the 200 schema and the handler with the real response. + */ +export const ${identifier} = buildRoute({ +${permissionLine(permission, options.permissionSource)} route: { + method: "get", + path: ${JSON.stringify(path)}, +${params.length === 0 ? "" : ` request: {\n params: ${paramsObject(params, paramType)},\n },\n`} responses: { + 200: { + content: { + "application/json": { + schema: z.object({ sample: z.literal(true), ${paramFields}message: z.string() }), + }, + }, + description: "Sample data from a generated route.", + }, +${forbiddenResponse(permission)} }, + }, +${handler} +}); +`; +}; + +const notImplementedTemplate = (options: RouteTemplateOptions): string => { + const { + builders, + endpoint, + fileName, + identifier, + method, + params, + paramType, + path, + permission, + } = options; + const hasBody = method === "post" || method === "put" || method === "patch"; + const request = [ + params.length === 0 + ? null + : ` params: ${paramsObject(params, paramType)},`, + hasBody + ? ` body: { + required: true, + content: { + // Describe the fields this endpoint accepts. + "application/json": { schema: z.object({}) }, + }, + },` + : null, + ].filter(line => line !== null); + const ownership = + permission === undefined + ? "" + : ` + * + * The staff permission decides who may call it at all. It does not check + * access to a particular record - do that in the handler when it matters.`; + + return `${request.length > 0 ? 'import { z } from "@hono/zod-openapi";\n' : ""}import { HTTPException } from "hono/http-exception"; + +${localImports(builders, options.permissionSource)} + +/** + * ${endpoint} - a generated scaffold that answers 501 until implemented. + * + * Describe the input, add the success response to \`responses\`, then replace + * the \`throw\` with the real operation.${ownership} + */ +export const ${identifier} = buildRoute({ +${permissionLine(permission, options.permissionSource)} route: { + method: ${JSON.stringify(method)}, + path: ${JSON.stringify(path)}, +${request.length === 0 ? "" : ` request: {\n${request.join("\n")}\n },\n`} responses: { +${forbiddenResponse(permission)} 501: { description: "Not implemented yet." }, + }, + }, + handler: () => { + throw new HTTPException(501, { + message: ${JSON.stringify(`${endpoint} is not implemented yet. Implement it in ${fileName}.`)}, + }); + }, +}); +`; +}; + +export const routeTemplate = (options: RouteTemplateOptions): string => + options.method === "get" + ? sampleGetTemplate(options) + : notImplementedTemplate(options); + +export const serviceTemplate = ({ + factory, + module, +}: { + factory: string; + module: string; +}): string => { + const deps = `${factory.replace(/^create/, "")}Deps`; + + return `import type { EnvVariablesVitNode } from "@vitnode/core/api/middlewares/global.middleware"; + +/** What the ${module} service needs, passed in by whoever builds it. */ +export interface ${deps} { + /** In a route: \`c.get("db")\`. */ + db: Omit; +} + +/** + * Business operations for the \`${module}\` module. + * + * Generated empty - it has no operations yet. Add functions that take plain + * input (and the acting user when one matters), not the Hono context, and + * build the service where it is used: \`${factory}({ db: c.get("db") })\`. + */ +export const ${factory} = (deps: ${deps}) => ({ deps }); +`; +}; diff --git a/packages/vitnode/scripts/cli/help.ts b/packages/vitnode/scripts/cli/help.ts index 18c9ecf196..d6e548999e 100644 --- a/packages/vitnode/scripts/cli/help.ts +++ b/packages/vitnode/scripts/cli/help.ts @@ -12,8 +12,17 @@ export const PUBLIC_COMMANDS = [ { description: "Start the development environment", name: "dev" }, { description: "Build for production", name: "build" }, { description: "Start the production server", name: "start" }, + { + description: "Generate plugin API modules, routes, services and resources", + name: "generate ", + }, { description: "List plugins", name: "plugin list" }, { description: "Validate plugins", name: "plugin validate" }, + { description: "Check plugins for configuration problems", name: "doctor" }, + { + description: "Show what plugins register: routes, jobs or events", + name: "inspect ", + }, { description: "Generate a migration", name: "db generate" }, { description: "Run pending migrations", name: "db migrate" }, { description: "Push schema changes (development)", name: "db push" }, @@ -29,6 +38,8 @@ export const describeCommand = (name: PublicCommandName): string => const EXAMPLES = [ "vitnode dev", "vitnode build --analyze", + "vitnode g route notes/list --dry-run", + "vitnode doctor", "vitnode db migrate --yes", ]; diff --git a/packages/vitnode/scripts/cli/index.test.ts b/packages/vitnode/scripts/cli/index.test.ts index 8e33b15c12..e91cc3978f 100644 --- a/packages/vitnode/scripts/cli/index.test.ts +++ b/packages/vitnode/scripts/cli/index.test.ts @@ -35,8 +35,11 @@ describe("vitnode", () => { dev Start the development environment build Build for production start Start the production server + generate Generate plugin API modules, routes, services and resources plugin list List plugins plugin validate Validate plugins + doctor Check plugins for configuration problems + inspect Show what plugins register: routes, jobs or events db generate Generate a migration db migrate Run pending migrations db push Push schema changes (development) @@ -46,6 +49,8 @@ describe("vitnode", () => { Examples vitnode dev vitnode build --analyze + vitnode g route notes/list --dry-run + vitnode doctor vitnode db migrate --yes Run vitnode --help for a command's options. diff --git a/packages/vitnode/scripts/cli/inspect/api-metadata.ts b/packages/vitnode/scripts/cli/inspect/api-metadata.ts new file mode 100644 index 0000000000..d652b2ade4 --- /dev/null +++ b/packages/vitnode/scripts/cli/inspect/api-metadata.ts @@ -0,0 +1,278 @@ +import { existsSync } from "node:fs"; +import { join, relative } from "node:path"; + +import type { NestedRegistration } from "../../../src/api/lib/nested-registrations"; +import type { RouteStaffPermissionRef } from "../../../src/api/lib/route-metadata"; +import type { LoadConfiguredPluginIds } from "../plugins/discover"; +import type { ModuleImporter } from "../plugins/validate"; + +import { findNestedRegistrations } from "../../../src/api/lib/nested-registrations"; +import { routeStaffPermissions } from "../../../src/api/lib/route-metadata"; +import { matchesPlugin } from "../commands/plugin-validate"; +import { errorMessage } from "../errors"; +import { discoverPlugins, isPluginPackage } from "../plugins/discover"; +import { + describeMissingDefinition, + nativeImport, + resolveDefinition, +} from "../plugins/validate"; +import { findPackageRoot, readPackageJson } from "../project/packages"; +import { toDisplayPath } from "../ui/format"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export interface InspectedRoute { + admin?: RouteStaffPermissionRef; + method: string; + moderator?: RouteStaffPermissionRef; + modulePath: string; + path: string; +} + +export interface InspectedJob { + kind: "cron" | "queue"; + module: string; + name: string; + /** Queue tasks only: whether a schema validates the payload. Never the payload itself. */ + payload?: "schema" | "untyped"; + schedule?: string; +} + +export interface InspectedListener { + event: string; + kind: "event" | "websocket"; + module: string; + /** Event listeners only: whether the listener was built from an event definition. Never the payload itself. */ + payload?: "schema" | "untyped"; +} + +export interface PluginApiMetadata { + ignored: NestedRegistration[]; + jobs: InspectedJob[]; + listeners: InspectedListener[]; + permissionStaff: { + admin: Record; + moderator: Record; + }; + routes: InspectedRoute[]; +} + +export type PluginApiLoad = + | { id: string; metadata: PluginApiMetadata; root: string; status: "loaded" } + | { + id: string; + reason: string; + root: null | string; + status: "error" | "skipped"; + }; + +interface ModuleLike { + cronJobs?: unknown[]; + events?: unknown[]; + modules?: ModuleLike[]; + name: string; + queueTasks?: unknown[]; + routes?: unknown[]; + webSockets?: unknown[]; +} + +const permissionNames = (entries: unknown): string[] => + Array.isArray(entries) + ? entries.flatMap(entry => + typeof entry === "string" + ? [entry] + : isRecord(entry) && typeof entry.permission === "string" + ? [entry.permission] + : [], + ) + : []; + +const permissionMap = (value: unknown): Record => + isRecord(value) + ? Object.fromEntries( + Object.entries(value).map(([module, entries]) => [ + module, + permissionNames(entries), + ]), + ) + : {}; + +const stringField = (value: unknown, key: string): string => + isRecord(value) && typeof value[key] === "string" ? value[key] : "?"; + +export const describeApiDefinition = ( + definition: Record, +): PluginApiMetadata => { + const modules = ( + Array.isArray(definition.modules) ? definition.modules : [] + ) as ModuleLike[]; + const routes: InspectedRoute[] = []; + + const walk = (module: ModuleLike, prefix: string[]) => { + const path = [...prefix, module.name]; + for (const route of module.routes ?? []) { + const config = + isRecord(route) && isRecord(route.route) ? route.route : {}; + const routePath = typeof config.path === "string" ? config.path : "?"; + const permissions = routeStaffPermissions(route); + routes.push({ + admin: permissions?.admin, + method: + typeof config.method === "string" ? config.method.toUpperCase() : "?", + moderator: permissions?.moderator, + modulePath: path.join("/"), + path: `/${path.join("/")}${routePath === "/" ? "" : routePath}`, + }); + } + for (const child of module.modules ?? []) walk(child, path); + }; + for (const module of modules) walk(module, []); + + const tagged = (value: unknown) => (Array.isArray(value) ? value : []); + const staff = isRecord(definition.permissionStaff) + ? definition.permissionStaff + : {}; + + return { + ignored: findNestedRegistrations(modules), + jobs: [ + ...tagged(definition.cronJobs).map(job => ({ + kind: "cron" as const, + module: stringField(job, "module"), + name: stringField(job, "name"), + schedule: stringField(job, "schedule"), + })), + ...tagged(definition.queueTasks).map(task => ({ + kind: "queue" as const, + module: stringField(task, "module"), + name: stringField(task, "name"), + payload: + isRecord(task) && isRecord(task.payload) + ? ("schema" as const) + : ("untyped" as const), + })), + ], + listeners: [ + ...tagged(definition.events).map(listener => ({ + event: stringField(listener, "event"), + kind: "event" as const, + module: stringField(listener, "module"), + payload: + isRecord(listener) && isRecord(listener.definition) + ? ("schema" as const) + : ("untyped" as const), + })), + ...tagged(definition.webSockets).map(socket => ({ + event: stringField(socket, "id"), + kind: "websocket" as const, + module: stringField(socket, "module"), + })), + ], + permissionStaff: { + admin: permissionMap(staff.admin), + moderator: permissionMap(staff.moderator), + }, + routes, + }; +}; + +export const pluginApiFromDefinition = ( + id: string, + root: string, + definition: Record, +): PluginApiLoad => { + try { + return { + id, + metadata: describeApiDefinition(definition), + root, + status: "loaded", + }; + } catch (error) { + return { id, reason: errorMessage(error), root, status: "error" }; + } +}; + +export const loadPluginApi = async ( + id: string, + root: null | string, + importModule: ModuleImporter = nativeImport, +): Promise => { + if (root === null) { + return { + id, + reason: "configured but not installed", + root, + status: "error", + }; + } + + const file = join(root, "dist", "src", "config.api.js"); + if (!existsSync(file)) { + return existsSync(join(root, "src", "config.api.ts")) + ? { + id, + reason: `not built: ${toDisplayPath(relative(root, file))} is missing - run vitnode build in the plugin`, + root, + status: "skipped", + } + : { + id, + reason: "has no API (no src/config.api.ts)", + root, + status: "skipped", + }; + } + + try { + const resolution = resolveDefinition(await importModule(file)); + if (resolution.status !== "definition") { + return { + id, + reason: describeMissingDefinition( + resolution, + toDisplayPath(relative(root, file)), + "buildApiPlugin()", + ).join("\n"), + root, + status: "error", + }; + } + + return pluginApiFromDefinition(id, root, resolution.definition); + } catch (error) { + return { id, reason: errorMessage(error), root, status: "error" }; + } +}; + +export const diagnosticTargets = async ( + cwd: string, + name: string | undefined, + loadIds?: LoadConfiguredPluginIds, +): Promise<{ configured: boolean; id: string; root: null | string }[]> => { + const packageRoot = findPackageRoot(cwd); + if ( + name === undefined && + packageRoot !== null && + isPluginPackage(packageRoot) + ) { + const id = readPackageJson(packageRoot)?.name ?? packageRoot; + let configured = false; + try { + const { plugins } = await discoverPlugins(cwd, { loadIds }); + configured = plugins.some( + plugin => plugin.id === id && plugin.configured, + ); + } catch { + configured = false; + } + + return [{ configured, id, root: packageRoot }]; + } + + const { plugins } = await discoverPlugins(cwd, { loadIds }); + return plugins + .filter(plugin => name === undefined || matchesPlugin(plugin, name)) + .map(({ configured, id, root }) => ({ configured, id, root })); +}; diff --git a/packages/vitnode/scripts/cli/inspect/static-checks.ts b/packages/vitnode/scripts/cli/inspect/static-checks.ts new file mode 100644 index 0000000000..a6aac18875 --- /dev/null +++ b/packages/vitnode/scripts/cli/inspect/static-checks.ts @@ -0,0 +1,337 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, posix, relative } from "node:path"; + +import type { ModuleSource } from "../generate/api-tree"; +import type { PluginCheck } from "../plugins/validate"; + +import { errorMessage } from "../errors"; +import { + readModuleContainer, + readParsed, + readRootContainer, + resolveBinding, +} from "../generate/api-tree"; +import { + arrayValue, + getProperty, + isExported, + listImports, + parseSource, + unwrap, +} from "../generate/ast"; +import { GenerationPlan } from "../generate/plan"; +import { API_CONFIG } from "../generate/project"; +import { workspacePackageDirs } from "../plugins/workspace"; +import { plural } from "../ui/format"; + +const MODULES_DIR = "src/api/modules"; + +const sourceFiles = (root: string, dir: string): string[] => { + const absolute = join(root, dir); + if (!existsSync(absolute)) return []; + + return readdirSync(absolute).flatMap(entry => { + const path = posix.join(dir, entry); + if (statSync(join(root, path)).isDirectory()) + return sourceFiles(root, path); + + return /\.tsx?$/.test(entry) && + !/\.(test|spec)\.tsx?$/.test(entry) && + !entry.endsWith(".d.ts") + ? [path] + : []; + }); +}; + +const builderExports = ( + plan: GenerationPlan, + file: string, + builder: "buildModule" | "buildRoute", +): string[] => { + const parsed = readParsed(plan, file); + + return parsed.program.body.flatMap(statement => { + if (statement.type !== "ExportNamedDeclaration") return []; + if (statement.declaration?.type !== "VariableDeclaration") return []; + + return statement.declaration.declarations.flatMap(declarator => { + if (declarator.id.type !== "Identifier" || !declarator.init) return []; + const init = unwrap(declarator.init); + + return init.type === "CallExpression" && + init.callee.type === "Identifier" && + init.callee.name === builder + ? [declarator.id.name] + : []; + }); + }); +}; + +export const registrationCheck = (root: string): PluginCheck => { + const name = "API registration"; + if (!existsSync(join(root, API_CONFIG))) { + return { + details: [], + name, + status: "skipped", + summary: "no src/config.api.ts", + }; + } + + const plan = new GenerationPlan(root); + const tsconfig = join(root, "tsconfig.json"); + const project = { + alias: + existsSync(tsconfig) && + /["']@\/\*["']\s*:/.test(readFileSync(tsconfig, "utf8")), + display: ".", + id: "", + root, + }; + const reachedModules = new Set(); + const reachedRoutes = new Set(); + const unresolved: string[] = []; + + try { + const visit = (module: ModuleSource) => { + const id = `${module.file}#${module.exportName}`; + if (reachedModules.has(id)) return; + reachedModules.add(id); + + const routes = arrayValue(getProperty(module.options, "routes")); + for (const element of routes?.elements ?? []) { + if (element === null || element.type === "SpreadElement") continue; + const value = unwrap(element); + if (value.type !== "Identifier") continue; + const binding = resolveBinding( + plan, + project, + module.parsed, + value.name, + ); + if (typeof binding === "string") + unresolved.push(`${value.name}: ${binding}`); + else reachedRoutes.add(`${binding.file}#${binding.exportName}`); + } + + for (const entry of readModuleContainer(plan, project, module).entries) { + if (entry.kind === "module") visit(entry.source); + if (entry.kind === "unresolved") + unresolved.push(`${entry.label}: ${entry.reason}`); + } + }; + + for (const entry of readRootContainer(plan, project).entries) { + if (entry.kind === "module") visit(entry.source); + if (entry.kind === "unresolved") + unresolved.push(`${entry.label}: ${entry.reason}`); + } + } catch (error) { + return { + details: [errorMessage(error)], + name, + status: "skipped", + summary: "the module tree could not be read from source", + }; + } + + const unregistered: string[] = []; + for (const file of sourceFiles(root, MODULES_DIR)) { + let modules: string[]; + let routes: string[]; + try { + modules = builderExports(plan, file, "buildModule"); + routes = builderExports(plan, file, "buildRoute"); + } catch (error) { + unresolved.push(`${file}: ${errorMessage(error)}`); + continue; + } + for (const exportName of modules) { + if (!reachedModules.has(`${file}#${exportName}`)) { + unregistered.push( + `Module ${exportName} (${file}) is not registered in any modules array.`, + ); + } + } + for (const exportName of routes) { + if (!reachedRoutes.has(`${file}#${exportName}`)) { + unregistered.push( + `Route ${exportName} (${file}) is not in any registered module's routes.`, + ); + } + } + } + + if (unresolved.length > 0) { + return { + details: [ + ...unregistered, + ...unresolved.map(line => `Not followed: ${line}`), + "Entries that could not be followed may register more than is reported here.", + ], + name, + status: "warning", + }; + } + + return unregistered.length > 0 + ? { details: unregistered, name, status: "warning" } + : { + details: [], + name, + status: "ok", + summary: `${plural(reachedModules.size, "module")}, ${plural(reachedRoutes.size, "route")} reached`, + }; +}; + +export const registryCheck = (root: string): PluginCheck => { + const name = "Fetcher registry"; + if (!existsSync(join(root, API_CONFIG))) { + return { + details: [], + name, + status: "skipped", + summary: "no src/config.api.ts", + }; + } + + const problems: string[] = []; + const warnings: string[] = []; + try { + const parsed = parseSource( + API_CONFIG, + readFileSync(join(root, API_CONFIG), "utf8"), + ); + if ( + !isExported(parsed.program, "VitNodeApiPlugin") && + !/export\s+type\s+VitNodeApiPlugin\b/.test(parsed.source) + ) { + problems.push( + `${API_CONFIG} does not export type VitNodeApiPlugin = ApiPluginContract<...>; apps cannot type fetcher calls to this plugin.`, + ); + } + } catch (error) { + problems.push(errorMessage(error)); + } + + if (!existsSync(join(root, "types", "api-registry.gen.d.ts"))) { + warnings.push( + "types/api-registry.gen.d.ts is missing - run vitnode build or vitnode dev in the plugin so its own pages can call its API.", + ); + } else { + const tsconfig = join(root, "tsconfig.json"); + if ( + existsSync(tsconfig) && + !/["']types["']/.test(readFileSync(tsconfig, "utf8")) + ) { + warnings.push( + 'tsconfig.json does not include "types", so types/api-registry.gen.d.ts is not loaded.', + ); + } + } + + if (problems.length > 0) + return { details: [...problems, ...warnings], name, status: "error" }; + if (warnings.length > 0) + return { details: warnings, name, status: "warning" }; + + return { details: [], name, status: "ok" }; +}; + +const apiConfigImports = ( + workspaceRoot: null | string, + appRoot: null | string, +): Map => { + const roots = new Set(); + if (appRoot !== null) roots.add(appRoot); + if (workspaceRoot !== null) + for (const dir of workspacePackageDirs(workspaceRoot)) roots.add(dir); + + const found = new Map(); + for (const dir of roots) { + const file = join(dir, "src", "vitnode.api.config.ts"); + if (!existsSync(file)) continue; + try { + const parsed = parseSource(file, readFileSync(file, "utf8")); + found.set( + file, + listImports(parsed.program).map(info => info.source), + ); + } catch { + continue; + } + } + + return found; +}; + +export const appRegistrationCheck = ({ + appRoot, + configured, + cwd, + id, + root, + workspaceRoot, +}: { + appRoot: null | string; + configured: boolean; + cwd: string; + id: string; + root: string; + workspaceRoot: null | string; +}): PluginCheck => { + const name = "App registration"; + if (!existsSync(join(root, API_CONFIG))) { + return { + details: [], + name, + status: "skipped", + summary: "no API to register", + }; + } + + const configs = apiConfigImports(workspaceRoot, appRoot); + if (configs.size === 0) { + return { + details: [], + name, + status: "skipped", + summary: "no vitnode.api.config.ts found in this workspace", + }; + } + + const registeredIn = [...configs].filter(([, sources]) => + sources.includes(`${id}/config.api`), + ); + const where = registeredIn.map(([file]) => relative(cwd, file) || file); + + if (configured && registeredIn.length === 0) { + return { + details: [ + `The app configures ${id} in vitnode.config.ts, but no vitnode.api.config.ts imports "${id}/config.api".`, + "Its pages load, but every call to its API answers 404. Add its API plugin to buildApiConfig({ plugins }).", + ], + name, + status: "error", + }; + } + + if (!configured && registeredIn.length > 0) { + return { + details: [ + `${where.join(", ")} registers the API of ${id}, but the app does not list it in vitnode.config.ts.`, + ], + name, + status: "warning", + }; + } + + return registeredIn.length === 0 + ? { + details: [], + name, + status: "skipped", + summary: "not used by an app in this workspace", + } + : { details: [], name, status: "ok", summary: where.join(", ") }; +}; diff --git a/packages/vitnode/scripts/cli/plugins/validate.ts b/packages/vitnode/scripts/cli/plugins/validate.ts index 35442698a0..bb445b1c5b 100644 --- a/packages/vitnode/scripts/cli/plugins/validate.ts +++ b/packages/vitnode/scripts/cli/plugins/validate.ts @@ -25,6 +25,9 @@ export interface PluginCheck { } export interface PluginValidation { + api?: + | { definition: Record; status: "loaded" } + | { status: "failed" }; checks: PluginCheck[]; id: string; root: string; @@ -33,38 +36,84 @@ export interface PluginValidation { export type ModuleImporter = (file: string) => Promise; -const nativeImport: ModuleImporter = async file => +export const nativeImport: ModuleImporter = async file => import(pathToFileURL(file).href) as Promise; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +export type DefinitionResolution = + | { + definition: Record; + exportName: string; + status: "definition"; + } + /** + * No export returned a definition, and these threw when called - most + * likely the factory itself, e.g. `buildApiPlugin` refusing a nested cron + * job in development. The original errors, unchanged. + */ + | { + failures: { error: unknown; exportName: string }[]; + status: "threw"; + } + /** No export returned a definition, and none threw. */ + | { status: "none" }; + /** * The plugin definition a module exports: the first exported factory that, * called with no arguments, returns an object with a `pluginId`. * * A plugin exports `blogPlugin = () => buildPlugin(...)` under a name of its - * own choosing, so the name cannot be relied on - the shape can. + * own choosing, so the name cannot be relied on - the shape can. Each export + * is called at most once, so a factory that throws is reported with its own + * error rather than called again to find out why. */ -export const findDefinition = ( - loaded: unknown, -): null | Record => { - if (!isRecord(loaded)) return null; +export const resolveDefinition = (loaded: unknown): DefinitionResolution => { + if (!isRecord(loaded)) return { status: "none" }; - for (const value of Object.values(loaded)) { + const failures: { error: unknown; exportName: string }[] = []; + for (const [exportName, value] of Object.entries(loaded)) { if (typeof value !== "function") continue; let result: unknown; try { result = (value as () => unknown)(); - } catch { + } catch (error) { + failures.push({ error, exportName }); continue; } - if (isRecord(result) && typeof result.pluginId === "string") return result; + if (isRecord(result) && typeof result.pluginId === "string") { + return { definition: result, exportName, status: "definition" }; + } } - return null; + return failures.length > 0 + ? { failures, status: "threw" } + : { status: "none" }; }; +export const describeMissingDefinition = ( + resolution: Exclude, + file: string, + factory: string, +): string[] => + resolution.status === "none" + ? [`${file} exports no ${factory} factory.`] + : [ + `${file}: calling the exported ${resolution.failures.length === 1 ? "function" : "functions"} ${resolution.failures + .map(({ exportName }) => `${exportName}()`) + .join(", ")} threw, so no ${factory} definition could be read:`, + ...resolution.failures.flatMap(({ error, exportName }) => + errorMessage(error) + .split("\n") + .map((line, index) => + index === 0 && resolution.failures.length > 1 + ? `${exportName}(): ${line}` + : line, + ), + ), + ]; + /** `"@vitnode/blog.home.title"` → is that path an object or string in `messages`? */ export const hasMessagePath = ( messages: unknown, @@ -158,7 +207,9 @@ export const validatePlugin = async ( const pass = (name: string, summary?: string) => { checks.push({ details: [], name, status: "ok", summary }); }; + let api: PluginValidation["api"]; const done = (): PluginValidation => ({ + api, checks, id, root, @@ -210,12 +261,19 @@ export const validatePlugin = async ( // Plugin definition let definition: null | Record = null; try { - definition = findDefinition(await importModule(configFile)); - if (definition === null) { - fail("Plugin definition", [ - `${show(configFile)} exports no factory returning buildPlugin({ manifest, ... }).`, - ]); - } else { + const resolution = resolveDefinition(await importModule(configFile)); + definition = + resolution.status === "definition" ? resolution.definition : null; + if (resolution.status !== "definition") { + fail( + "Plugin definition", + describeMissingDefinition( + resolution, + show(configFile), + "buildPlugin({ manifest, ... })", + ), + ); + } else if (definition !== null) { pluginsFromLoadedConfig( { vitNodeConfig: { plugins: [definition] } }, show(configFile), @@ -237,19 +295,31 @@ export const validatePlugin = async ( let apiDefinition: null | Record = null; if (existsSync(apiFile)) { try { - apiDefinition = findDefinition(await importModule(apiFile)); - if (apiDefinition === null) { - fail("API definition", [ - `${show(apiFile)} exports no factory returning buildApiPlugin({ pluginId, ... }).`, - ]); - } else if (apiDefinition.pluginId !== id) { + const resolution = resolveDefinition(await importModule(apiFile)); + apiDefinition = + resolution.status === "definition" ? resolution.definition : null; + api = + resolution.status === "definition" + ? { definition: resolution.definition, status: "loaded" } + : { status: "failed" }; + if (resolution.status !== "definition") { + fail( + "API definition", + describeMissingDefinition( + resolution, + show(apiFile), + "buildApiPlugin({ manifest, ... })", + ), + ); + } else if (apiDefinition?.pluginId !== id) { fail("API definition", [ - `pluginId is "${String(apiDefinition.pluginId)}" but the package is "${id}".`, + `pluginId is "${String(apiDefinition?.pluginId)}" but the package is "${id}".`, ]); } else { pass("API definition"); } } catch (error) { + api = { status: "failed" }; fail("API definition", [errorMessage(error)]); } } diff --git a/packages/vitnode/scripts/cli/program.ts b/packages/vitnode/scripts/cli/program.ts index 20769011b9..252b094889 100644 --- a/packages/vitnode/scripts/cli/program.ts +++ b/packages/vitnode/scripts/cli/program.ts @@ -121,6 +121,49 @@ export const createProgram = (hooks: ProgramHooks): Command => { ), ); + registerGenerateCommands(program, run); + + outputOptions(program.command("doctor")) + .description(describe("doctor")) + .argument( + "[name]", + "plugin package name or folder (default: all, or the one you are in)", + ) + .action( + run( + async () => (await import("./commands/doctor")).runDoctorCommand, + ([name]) => ({ name: name as string | undefined }), + ), + ); + + const inspect = program + .command("inspect") + .helpCommand(false) + .description(describe("inspect ")); + const inspectHandlers = async () => import("./commands/inspect"); + for (const [kind, description, handler] of [ + [ + "routes", + "Routes, their paths and declared staff permissions", + "runInspectRoutesCommand", + ], + ["jobs", "Cron jobs and queue tasks", "runInspectJobsCommand"], + ["events", "Event listeners and WebSockets", "runInspectEventsCommand"], + ] as const) { + outputOptions(inspect.command(kind)) + .description(description) + .argument( + "[name]", + "plugin package name or folder (default: all, or the one you are in)", + ) + .action( + run( + async () => (await inspectHandlers())[handler], + ([name]) => ({ name: name as string | undefined }), + ), + ); + } + const db = program .command("db") .description("Generate, apply and inspect database migrations"); @@ -264,3 +307,118 @@ const registerLegacyCommands = (program: Command, run: Run) => { .option("--generate", "only generate migrations") .action(run(async () => (await legacy()).runMigrateCommand)); }; + +const registerGenerateCommands = (program: Command, run: Run) => { + const handlers = async () => import("./commands/generate"); + const generate = program + .command("generate") + .alias("g") + .helpCommand(false) + .description(describeCommand("generate ")) + .addHelpText( + "after", + "\nExamples:\n vitnode g module notes\n vitnode g route notes/show --path '/{id}'\n vitnode g route admin/notes/create --permission notes.can_create --declare-permission\n vitnode g resource notes --kind api --fields title:string,body:string\n vitnode g module notes --dry-run", + ); + + const common = (command: Command): Command => + outputOptions(command) + .option( + "--project ", + "plugin to generate into: exact package name or folder (default: the plugin you are in)", + ) + .option("--dry-run", "show the planned diff without writing anything"); + + common(generate.command("module")) + .description("Create an API module and register it") + .argument("", "module path, e.g. notes or admin/notes") + .option("--admin", "create it in the admin module tree (admin/)") + .option("--parents", "also create missing parent modules") + .action( + run( + async () => (await handlers()).runGenerateModuleCommand, + ([name]) => ({ name: name as string }), + ), + ); + + common(generate.command("route")) + .description("Create a typed route and add it to its module") + .argument("", "/, e.g. notes/list") + .option("-m, --method ", "get, post, put, patch or delete") + .option("--path ", "path relative to the module, e.g. / or /{id}") + .option( + "--id-type ", + "schema of path parameters: string or number", + "string", + ) + .option( + "--admin", + "put the route in the admin module tree (admin/)", + ) + .option( + "--permission ", + "admin staff permission that guards the route", + ) + .option( + "--declare-permission", + "declare the permission in config.api.ts when it is missing", + ) + .option("--parents", "also create missing modules") + .action( + run( + async () => (await handlers()).runGenerateRouteCommand, + ([target]) => ({ target: target as string }), + ), + ); + + common(generate.command("resource")) + .description("Create a module with schemas, a service and CRUD routes") + .argument("", "module path for the resource, e.g. notes") + .option("--kind ", "what to generate: api or content") + .option( + "--fields ", + "fields as name:type, e.g. title:string,pinned?:boolean", + ) + .option( + "--id-type ", + "api: string or number (default string); content: serial, uuid or bigint (default serial)", + ) + .option("--admin", "api: create it in the admin module tree (admin/)") + .option( + "--content-id ", + "content: the content type id, plugin.entity (default: derived)", + ) + .option( + "--table ", + "content: the table name (default: _)", + ) + .option("--singular