Skip to content

Commit

Permalink
feat(nextjs): Support Next.js Server Actions (#1991)
Browse files Browse the repository at this point in the history
This adds support for Next.js Server Actions. It primarily adds an async `request()` function that builds a minimal request object with just Headers and Cookies, but was enabled by reducing the details we need in our Service.

I've also built an example on [Next.js 15](https://nextjs.org/blog/next-15) to show that `characteristics` must be set on the SDK if the IP is not available in the Headers. I chose to set a UUID cookie when the page is initially loaded and use it to fingerprint, which makes fingerprinting consistent between development and production.

Closes #1200
  • Loading branch information
blaine-arcjet authored Oct 22, 2024
1 parent 0afc412 commit 07e68dc
Show file tree
Hide file tree
Showing 20 changed files with 5,331 additions and 0 deletions.
25 changes: 25 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,31 @@ updates:
- dependency-name: eslint
versions: [">=9"]

- package-ecosystem: npm
directory: /examples/nextjs-server-actions
schedule:
# Our dependencies should be checked daily
interval: daily
assignees:
- blaine-arcjet
reviewers:
- blaine-arcjet
commit-message:
prefix: deps(example)
prefix-development: deps(example)
groups:
dependencies:
patterns:
- "*"
ignore:
# Ignore updates to the @types/node package due to conflict between
# Headers in DOM.
- dependency-name: "@types/node"
versions: [">18.18"]
# TODO(#539): Upgrade to eslint 9
- dependency-name: eslint
versions: [">=9"]

- package-ecosystem: npm
directory: /examples/nodejs-express-launchdarkly
schedule:
Expand Down
42 changes: 42 additions & 0 deletions .github/workflows/reusable-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,48 @@ jobs:
working-directory: examples/nextjs-14-sensitive-info
run: npm run build

nextjs-server-actions:
name: Next.js + Server Actions
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# Environment security
- name: Harden Runner
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
with:
disable-sudo: true
egress-policy: block
allowed-endpoints: >
fonts.googleapis.com:443
fonts.gstatic.com:443
github.com:443
registry.npmjs.org:443
# Checkout
# Most toolchains require checkout first
- name: Checkout
uses: actions/checkout@v4

# Language toolchains
- name: Install Node
uses: actions/setup-node@v4.0.4
with:
node-version: 20

# Workflow

- name: Install dependencies
run: npm ci

- name: Install example dependencies
working-directory: examples/nextjs-server-actions
run: npm ci

- name: Build
working-directory: examples/nextjs-server-actions
run: npm run build

nodejs-hono-rl:
name: Node.js + Hono + Rate Limit
runs-on: ubuntu-latest
Expand Down
15 changes: 15 additions & 0 deletions arcjet-next/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { NextApiResponse } from "next";
import { NextResponse } from "next/server.js";
import { headers, cookies } from "next/headers.js";
import type {
NextFetchEvent,
NextMiddleware,
Expand Down Expand Up @@ -27,6 +28,20 @@ import { createTransport } from "@arcjet/transport";
// Re-export all named exports from the generic SDK
export * from "arcjet";

export async function request(): Promise<ArcjetNextRequest> {
const hdrs = await headers();
const cook = await cookies();

const cookieEntries = cook
.getAll()
.map((cookie) => [cookie.name, cookie.value]);

return {
headers: hdrs,
cookies: Object.fromEntries(cookieEntries),
};
}

// TODO: Deduplicate with other packages
function errorMessage(err: unknown): string {
if (err) {
Expand Down
1 change: 1 addition & 0 deletions examples/nextjs-server-actions/.env.local.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ARCJET_KEY=
3 changes: 3 additions & 0 deletions examples/nextjs-server-actions/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
40 changes: 40 additions & 0 deletions examples/nextjs-server-actions/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# env files (can opt-in for commiting if needed)
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
38 changes: 38 additions & 0 deletions examples/nextjs-server-actions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<a href="https://arcjet.com" target="_arcjet-home">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://arcjet.com/logo/arcjet-dark-lockup-voyage-horizontal.svg">
<img src="https://arcjet.com/logo/arcjet-light-lockup-voyage-horizontal.svg" alt="Arcjet Logo" height="128" width="auto">
</picture>
</a>

# Arcjet email verification with Next.js Server Actions

This example shows how to use Arcjet with Next.js [server
actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations).

## How to use

1. From the root of the project, install the SDK dependencies.

```bash
npm ci
```

2. Enter this directory and install the example's dependencies.

```bash
cd examples/nextjs-server-actions
npm ci
```

3. Rename `.env.local.example` to `.env.local` and add your Arcjet key.

4. Start the dev server.

```bash
npm run dev
```

5. Visit `http://localhost:3000/`

6. Enter some email addresses in the form to validate them.
47 changes: 47 additions & 0 deletions examples/nextjs-server-actions/app/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use server"

import arcjet, { request, validateEmail } from "@arcjet/next";

const aj = arcjet({
key: process.env.ARCJET_KEY!,
// Use the `uid` cookie that is set by the middleware to fingerprint requests
characteristics: ['http.request.cookie["uid"]'],
rules: [
validateEmail({ mode: "LIVE", block: ["DISPOSABLE", "NO_MX_RECORDS"] })
]
});

export async function validate(prev: { message: string }, formData: FormData) {
const email = formData.get("email");

// TypeScript types allow this to be a `File`, `string`, or `null` so we need
// to check it is a string type before using it
if (typeof email !== "string") {
throw new Error("Invalid form data")
}

// Access request data that Arcjet needs when you call `protect()` similarly
// to `await headers()` and `await cookies()` in `next/headers`
const req = await request();

const decision = await aj.protect(req, { email });

// If Arcjet encounters an error, you could fail "open" or you could respond
// with a "closed"-style message like below
if (decision.isErrored()) {
console.log("Error occurred:", decision.reason.message);
return {
message: "Encountered an error"
}
}

if (decision.isDenied()) {
return {
message: "Email is INVALID"
};
}

return {
message: "Email is VALID"
}
}
Binary file added examples/nextjs-server-actions/app/favicon.ico
Binary file not shown.
Binary file not shown.
Binary file not shown.
42 changes: 42 additions & 0 deletions examples/nextjs-server-actions/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
:root {
--background: #ffffff;
--foreground: #171717;
}

@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}

html,
body {
max-width: 100vw;
overflow-x: hidden;
}

body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

* {
box-sizing: border-box;
padding: 0;
margin: 0;
}

a {
color: inherit;
text-decoration: none;
}

@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}
33 changes: 33 additions & 0 deletions examples/nextjs-server-actions/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";

const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
</html>
);
}
Loading

0 comments on commit 07e68dc

Please sign in to comment.