Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(directus-extension): Add an example demonstrating hosting Remix inside a Directus extension. #354

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions directus-extension/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# These are the keys that have been overridden to make this work with Remix
# Add them to the top of the .env file generated by the directus init command.

# Where to redirect to when navigating to /. Accepts a relative path, absolute URL, or false to disable ["./admin"]
ROOT_REDIRECT="false"
# Ensures Directus can't override whether we are in development or production. Set to `production` in production environments.
REMIX_ENV=development
# Only necessary in development environments.
REMIX_DEV_ORIGIN=http://0.0.0.0:3001/
# Required for the Remix app to work
CONTENT_SECURITY_POLICY_DIRECTIVES__SCRIPT_SRC="array:'self','unsafe-inline','unsafe-eval'"
# Required for live reload to work. Not strictly necessary in production.
CONTENT_SECURITY_POLICY_DIRECTIVES__CONNECT_SRC="array:'self',https:,http:,wss:,ws:"
# Tells Directus to reload extensions when the source files change. Not required in production.
EXTENSIONS_AUTO_RELOAD=true
4 changes: 4 additions & 0 deletions directus-extension/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
};
8 changes: 8 additions & 0 deletions directus-extension/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules

/.cache
/build
/public/build
.env
data.db
extensions
87 changes: 87 additions & 0 deletions directus-extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Remix Directus Extension

This example demonstrates how a Remix app can be embedded in a [Directus](https://directus.io) app using an endpoint extension. The app is comprised of three parts:

- The Remix app
- The self-hosted Directus instance
- A Directus endpoint extension, called `remix-frontend`

## What is Directus?

Directus is a self-hosted CMS written in JavaScript and Vue. It allows you to connect a relational database, define data models, view and edit the data, upload and manage files, set up automated workflows and webhooks, view data panels, manage users, roles, and auth... it provides a lot.

It's also highly extensible. Many parts of the UI and backend can be modified and changed using the Directus extension API.

## How it Works

Directus allows you to add API endpoints to a running Directus instance using a special extension. This extension exposes an Express router which we can use to pass requests to our Remix app. With a bit of clever configuration, we can make our app available at the root (eg. `/`) of our Directus instance and serve all requests (except those to the Directus admin at `/admin/*`) from our Remix app.

We can also take advantage of Remix load context and pass Directus utilities to our Remix app. Things like the Directus services, which provide a convenient API for accessing Directus resources, accountability information about the currently logged in user, and direct access to the database.

In this example, we use the `ItemsService`, which is accessed through load context, to pull our list of blog posts in our loader to render to the page.

### Patching Vue Types

This example installs Directus directly, which includes `vue` as a dependency. Unfortunately, Vue uses interface overloading to alter the way TypeScript types JSX, causing errors in idiomatic React code.

To solve this, this example uses `patch-package` to change the types in the Vue package so they don't interfere with React's JSX typings.

This is handled automatically with a `postinstall` package.json script.

## Development

This example includes an example environment variables file.

Before doing anything else, you'll need to set up your Directus instance. From your terminal run:

```sh
npx directus@latest init
```


Follow the prompts to set up the Directus app how you want. If you're just trying it out, use the `sqlite` database driver.

Once that's done you'll need to add the necessary environment variables at top of the `.env.example` file to your `.env` file. These are required to enable Remix to run from within Directus.

You can apply the example snapshot by running:

```sh
npx directus schema apply ./snapshot.yml
```

Make sure the extension has been built before the Directus instance starts for the first time.

From your terminal:

```sh
npm run build
```

then

```sh
npm run dev
```

This will start the Remix dev server, the extension compiler in watch mode, and the Directus data studio in
development mode.

You can access your app at `http://localhost:8055` and access the Directus data studio at `http://localhost:8055/admin`.

Sign into the data studio, add a post, visit the site, and you'll see the post appear.

## Deployment

First, build your app for production:

```sh
npm run build
```

Then run the app in production mode:

```sh
npm start
```

Now you'll need to pick a host to deploy it to. It should work out-of-the-box with any Nixpack-compatible host, like Railway or Flightcontrol. It should also work with a simple Node-based Dockerfile on hosts that support that. Just use `npm run build` as the build command and `npm run start` as the start command, and make sure you've set up the necessary environment variables.
18 changes: 18 additions & 0 deletions directus-extension/app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.client
*/

import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";

startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});
137 changes: 137 additions & 0 deletions directus-extension/app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.server
*/

import { PassThrough } from "node:stream";

import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";

const ABORT_DELAY = 5_000;

export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
loadContext: AppLoadContext
) {
return isbot(request.headers.get("user-agent"))
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
);
}

function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);

responseHeaders.set("Content-Type", "text/html");

resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);

pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);

setTimeout(abort, ABORT_DELAY);
});
}

function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);

responseHeaders.set("Content-Type", "text/html");

resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);

pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);

setTimeout(abort, ABORT_DELAY);
});
}
36 changes: 36 additions & 0 deletions directus-extension/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { cssBundleHref } from "@remix-run/css-bundle";
import type { LinksFunction } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import type { EndpointExtensionContext } from "@directus/types";

export interface AppLoadContext extends EndpointExtensionContext {}

export const links: LinksFunction = () => [
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
];

export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
48 changes: 48 additions & 0 deletions directus-extension/app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
json,
type LoaderFunctionArgs,
type MetaFunction,
} from "@remix-run/node";
import type { ItemsService as TItemsService } from "@directus/api/services/items";
import { Link, useLoaderData } from "@remix-run/react";
import type { Posts } from "../types";
export const meta: MetaFunction = () => {
return [
{ title: "New Remix App" },
{ name: "description", content: "Welcome to Remix!" },
];
};

export async function loader({ context }: LoaderFunctionArgs) {
const ItemsService = (context.services as any)
.ItemsService as typeof TItemsService<Posts>;
const itemsService = new ItemsService("posts", {
schema: context.schema as any,
accountability: { admin: true, role: "" },
});

const posts = await itemsService.readByQuery({
fields: ["id", "slug", "title"],
filter: { status: { _eq: "published" } },
sort: ["-date_published"],
limit: -1,
});
return json({ posts });
}

export default function Index() {
const { posts } = useLoaderData<typeof loader>();

return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<h1>My Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link to={`/post/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
</div>
);
}
Loading