diff --git a/app/src/App.ts b/app/src/App.ts index ae0ee55e..fb05ee6e 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -23,13 +23,34 @@ import type { IEmailDriver, ICacheDriver } from "core/drivers"; import { Api, type ApiOptions } from "Api"; export type AppPluginConfig = { + /** + * The name of the plugin. + */ name: string; + /** + * The schema of the plugin. + */ schema?: () => MaybePromise | void>; + /** + * Called before the app is built. + */ beforeBuild?: () => MaybePromise; + /** + * Called after the app is built. + */ onBuilt?: () => MaybePromise; + /** + * Called when the server is initialized. + */ onServerInit?: (server: Hono) => MaybePromise; - onFirstBoot?: () => MaybePromise; + /** + * Called when the app is booted. + */ onBoot?: () => MaybePromise; + /** + * Called when the app is first booted. + */ + onFirstBoot?: () => MaybePromise; }; export type AppPlugin = (app: App) => AppPluginConfig; diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index c3d271bd..03689d59 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -11,7 +11,7 @@ type BunEnv = Bun.Env; export type BunBkndConfig = RuntimeBkndConfig & Omit; export async function createApp( - { distPath, ...config }: BunBkndConfig = {}, + { distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig = {}, args: Env = {} as Env, opts?: RuntimeOptions, ) { @@ -20,7 +20,11 @@ export async function createApp( return await createRuntimeApp( { - serveStatic: serveStatic({ root }), + serveStatic: + _serveStatic ?? + serveStatic({ + root, + }), ...config, }, args ?? (process.env as Env), diff --git a/app/src/adapter/cloudflare/vite.ts b/app/src/adapter/cloudflare/vite.ts index 53ba9993..c8c073e4 100644 --- a/app/src/adapter/cloudflare/vite.ts +++ b/app/src/adapter/cloudflare/vite.ts @@ -6,7 +6,7 @@ import { resolve } from "node:path"; * Vite plugin that provides Node.js filesystem access during development * by injecting a polyfill into the SSR environment */ -export function devFsPlugin({ +export function devFsVitePlugin({ verbose = false, configFile = "bknd.config.ts", }: { diff --git a/app/src/plugins/cloudflare/image-optimization.plugin.ts b/app/src/plugins/cloudflare/image-optimization.plugin.ts index ab88161b..7d0456e9 100644 --- a/app/src/plugins/cloudflare/image-optimization.plugin.ts +++ b/app/src/plugins/cloudflare/image-optimization.plugin.ts @@ -19,22 +19,41 @@ const schema = s.partialObject({ type ImageOptimizationSchema = s.Static; export type CloudflareImageOptimizationOptions = { + /** + * The url to access the image optimization plugin, defaults to `/api/plugin/image/optimize` + */ accessUrl?: string; + /** + * The path to resolve the image from, defaults to `/api/media/file` + */ resolvePath?: string; + /** + * Whether to explain the image optimization schema, defaults to `false` + */ explain?: boolean; + /** + * The default options to use, defaults to `{}` + * @param: config + */ defaultOptions?: ImageOptimizationSchema; + /** + * The fixed options to use, defaults to `{}` + */ fixedOptions?: ImageOptimizationSchema; + /** + * The cache control to use, defaults to `public, max-age=31536000, immutable` + */ cacheControl?: string; }; export function cloudflareImageOptimization({ - accessUrl = "/_plugin/image/optimize", + accessUrl = "/api/plugin/image/optimize", resolvePath = "/api/media/file", explain = false, defaultOptions = {}, fixedOptions = {}, }: CloudflareImageOptimizationOptions = {}): AppPlugin { - const disallowedAccessUrls = ["/api", "/admin", "/_optimize"]; + const disallowedAccessUrls = ["/api", "/admin", "/api/plugin"]; if (disallowedAccessUrls.includes(accessUrl) || accessUrl.length < 2) { throw new Error(`Disallowed accessUrl: ${accessUrl}`); } diff --git a/docs/content/docs/(documentation)/extending/plugins.mdx b/docs/content/docs/(documentation)/extending/plugins.mdx new file mode 100644 index 00000000..7e8d777e --- /dev/null +++ b/docs/content/docs/(documentation)/extending/plugins.mdx @@ -0,0 +1,209 @@ +--- +title: Plugins +tags: ["documentation"] +--- +import { TypeTable } from 'fumadocs-ui/components/type-table'; + + +bknd allows you to extend its functionality by creating plugins. These allows to hook into the app lifecycle and to provide a data structure that is guaranteed to be merged. A plugin is a function that takes in an instance of `App` and returns the following structure: + + + +## Creating a simple plugin + +To create a simple plugin which guarantees an entity `pages` to be available and an additioanl endpoint to render a html list of pages, you can create it as follows: + +```tsx title="myPagesPlugin.tsx" +/** @jsxImportSource hono/jsx */ +import { type App, type AppPlugin, em, entity, text } from "bknd"; + +export const myPagesPlugin: AppPlugin = (app) => ({ + name: "my-pages-plugin", + // define the schema of the plugin + // this will always be merged into the app's schema + schema: () => em({ + pages: entity("pages", { + title: text(), + content: text(), + }), + }), + // execute code after the app is built + onBuilt: () => { + // register a new endpoint, make sure that you choose an endpoint that is reachable for bknd + app.server.get("/my-pages", async (c) => { + const { data: pages } = await app.em.repo("pages").findMany({}); + return c.html( + +

Pages: {pages.length}

+
    + {pages.map((page: any) => ( +
  • {page.title}
  • + ))} +
+ , + ); + }); + }, +}); + +``` + +And then register it in your `bknd.config.ts` file: + +```typescript +import type { BkndConfig } from "bknd/adapter"; +import { myPagesPlugin } from "./myPagesPlugin"; + +export default { + options: { + plugins: [myPagesPlugin], + } +} satisfies BkndConfig; +``` + +The schema returned from the plugin will be merged into the schema of the app. + + +## Built-in plugins + +bknd comes with a few built-in plugins that you can use. + +### `syncTypes` + +A simple plugin that writes down the TypeScript types of the data schema on boot and each build. The output is equivalent to running `npx bknd types`. + +```typescript title="bknd.config.ts" +import { syncTypes } from "bknd/plugins"; +import { writeFile } from "node:fs/promises"; + +export default { + options: { + plugins: [ + syncTypes({ + // whether to enable the plugin, make sure to disable in production + enabled: true, + // your writing function (required) + write: async (et) => { + await writeFile("bknd-types.d.ts", et.toString(), "utf-8"); + } + }), + ] + }, +} satisfies BkndConfig; +``` + +### `syncConfig` + +A simple plugin that writes down the app configuration on boot and each build. + +```typescript title="bknd.config.ts" +import { syncConfig } from "bknd/plugins"; +import { writeFile } from "node:fs/promises"; + +export default { + options: { + plugins: [ + syncConfig({ + // whether to enable the plugin, make sure to disable in production + enabled: true, + // your writing function (required) + write: async (config) => { + await writeFile("config.json", JSON.stringify(config, null, 2), "utf-8"); + }, + }), + ] + }, +} satisfies BkndConfig; +``` + +### `showRoutes` + +A simple plugin that logs the routes of your app in the console. + +```typescript title="bknd.config.ts" +import { showRoutes } from "bknd/plugins"; + +export default { + options: { + plugins: [ + showRoutes({ + // whether to show the routes only once (on first build) + once: true + }) + ], + }, +} satisfies BkndConfig; +``` + +### `cloudflareImageOptimization` + +A plugin that add Cloudflare Image Optimization to your app's media storage. + +```typescript title="bknd.config.ts" +import { cloudflareImageOptimization } from "bknd/plugins"; + +export default { + options: { + plugins: [ + cloudflareImageOptimization({ + // the url to access the image optimization plugin + accessUrl: "/api/plugin/image/optimize", + // the path to resolve the image from, defaults to `/api/media/file` + resolvePath: "/api/media/file", + // for example, you may want to have default option to limit to a width of 1000px + defaultOptions: { + width: 1000, + } + }) + ], + }, +} satisfies BkndConfig; +``` + +Here is a break down of all configuration options: + + + +When enabled, you can now access your images at your configured `accessUrl`. For example, if you have a media file at `/api/media/file/image.jpg`, you can access the optimized image at `/api/plugin/image/optimize/image.jpg` for optimization. + +Now you can add query parameters for the transformations, e.g. `?width=1000&height=1000`. + + + + + + + diff --git a/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx b/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx index 257d2a72..daeadeac 100644 --- a/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx +++ b/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx @@ -123,9 +123,8 @@ import { serve } from "bknd/adapter/cloudflare"; export default serve({ // ... onBuilt: async (app) => { - // [!code highlight] app.server.get("/hello", (c) => c.json({ hello: "world" })); // [!code highlight] - }, // [!code highlight] + }, }); ``` @@ -141,7 +140,6 @@ With the Cloudflare Workers adapter, you're being offered to 4 modes to choose f | `fresh` | On every request, the configuration gets refetched, app built and then served. | Ideal if you don't want to deal with eviction, KV or Durable Objects. | | `warm` | It tries to keep the built app in memory for as long as possible, and rebuilds if evicted. | Better response times, should be the default choice. | | `cache` | The configuration is fetched from KV to reduce the initial roundtrip to the database. | Generally faster response times with irregular access patterns. | -| `durable` | The bknd app is ran inside a Durable Object and can be configured to stay alive. | Slowest boot time, but fastest responses. Can be kept alive for as long as you want, giving similar response times as server instances. | ### Modes: `fresh` and `warm` @@ -172,76 +170,6 @@ export default serve({ }); ``` -### Mode: `durable` (advanced) - -To use the `durable` mode, you have to specify the Durable Object to extract from your -environment, and additionally export the `DurableBkndApp` class: - -```ts -import { serve, DurableBkndApp } from "bknd/adapter/cloudflare"; - -export { DurableBkndApp }; -export default serve({ - // ... - mode: "durable", - bindings: ({ env }) => ({ dobj: env.DOBJ }), - keepAliveSeconds: 60, // optional -}); -``` - -Next, you need to define the Durable Object in your `wrangler.toml` file (refer to the [Durable -Objects](https://developers.cloudflare.com/durable-objects/) documentation): - -```toml -[[durable_objects.bindings]] -name = "DOBJ" -class_name = "DurableBkndApp" - -[[migrations]] -tag = "v1" -new_classes = ["DurableBkndApp"] -``` - -Since the communication between the Worker and Durable Object is serialized, the `onBuilt` -property won't work. To use it (e.g. to specify special routes), you need to extend from the -`DurableBkndApp`: - -```ts -import type { App } from "bknd"; -import { serve, DurableBkndApp } from "bknd/adapter/cloudflare"; - -export default serve({ - // ... - mode: "durable", - bindings: ({ env }) => ({ dobj: env.DOBJ }), - keepAliveSeconds: 60, // optional -}); - -export class CustomDurableBkndApp extends DurableBkndApp { - async onBuilt(app: App) { - app.modules.server.get("/custom/endpoint", (c) => c.text("Custom")); - } -} -``` - -In case you've already deployed your Worker, the deploy command may complain about a new class -being used. To fix this issue, you need to add a "rename migration": - -```toml -[[durable_objects.bindings]] -name = "DOBJ" -class_name = "CustomDurableBkndApp" - -[[migrations]] -tag = "v1" -new_classes = ["DurableBkndApp"] - -[[migrations]] -tag = "v2" -renamed_classes = [{from = "DurableBkndApp", to = "CustomDurableBkndApp"}] -deleted_classes = ["DurableBkndApp"] -``` - ## D1 Sessions (experimental) D1 now supports to enable [global read replication](https://developers.cloudflare.com/d1/best-practices/read-replication/). This allows to reduce latency by reading from the closest region. In order for this to work, D1 has to be started from a bookmark. You can enable this behavior on bknd by setting the `d1.session` property: @@ -272,3 +200,83 @@ If bknd is used in a stateful user context (like in a browser), it'll automatica ```bash curl -H "x-cf-d1-session: " ... ``` + +## Filesystem access with Vite Plugin +The [Cloudflare Vite Plugin](https://developers.cloudflare.com/workers/vite-plugin/) allows to use Vite with Miniflare to emulate the Cloudflare Workers runtime. This is great, however, `unenv` disables any Node.js APIs that aren't supported, including the `fs` module. If you want to use plugins such as [`syncTypes`](/extending/plugins#synctypes), this will cause issues. + +To fix this, bknd exports a Vite plugin that provides filesystem access during development. You can use it by adding the following to your `vite.config.ts` file: + +```ts +import { devFsVitePlugin } from "bknd/adapter/cloudflare/vite"; + +export default defineConfig({ + plugins: [devFsVitePlugin()], // [!code highlight] +}); +``` + +Now to use this polyfill, you can use the `devFsWrite` function to write files to the filesystem. + +```ts +import { devFsWrite } from "bknd/adapter/cloudflare/vite"; // [!code highlight] +import { syncTypes } from "bknd/plugins"; + +export default { + options: { + plugins: [ + syncTypes({ + write: async (et) => { + await devFsWrite("bknd-types.d.ts", et.toString()); // [!code highlight] + } + }), + ] + }, +} satisfies BkndConfig; +``` + +## Cloudflare Bindings in CLI + +The bknd CLI does not automatically have access to the Cloudflare bindings. We need to manually proxy them to the CLI by using the `withPlatformProxy` helper function: + +```typescript title="bknd.config.ts" +import { d1 } from "bknd/adapter/cloudflare"; +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; + +export default withPlatformProxy({ + app: ({ env }) => ({ + connection: d1({ binding: env.DB }), + }), +}); +``` + +Now you can use the CLI with your Cloudflare resources. + + + Make sure to not import from this file in your app, as this would include `wrangler` as a dependency. + + +Instead, it's recommended to split this configuration into separate files, e.g. `bknd.config.ts` and `config.ts`: + +```typescript title="config.ts" +import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare"; + +export default { + app: ({ env }) => ({ + connection: d1({ binding: env.DB }), + }), +} satisfies CloudflareBkndConfig; +``` + +`config.ts` now holds the configuration, and can safely be imported in your app. Since the CLI looks for a `bknd.config.ts` file by default, we change it to wrap the configuration from `config.ts` in the `withPlatformProxy` helper function. + +```typescript title="bknd.config.ts" +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; +import config from "./config"; + +export default withPlatformProxy(config); +``` + +As an additional safe guard, you have to set a `PROXY` environment variable to `1` to enable the proxy. + +```bash +PROXY=1 npx bknd types +``` \ No newline at end of file diff --git a/docs/content/docs/(documentation)/meta.json b/docs/content/docs/(documentation)/meta.json index a476c956..bcd6ef02 100644 --- a/docs/content/docs/(documentation)/meta.json +++ b/docs/content/docs/(documentation)/meta.json @@ -18,6 +18,7 @@ "---Extending---", "./extending/config", "./extending/events", + "./extending/plugins", "---Integration---", "./integration/introduction", "./integration/(frameworks)/",