Skip to content

fix!: Reorganize SDK types to tighten helpers around custom props #18

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

Merged
merged 6 commits into from
Dec 18, 2023
Merged
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
95 changes: 59 additions & 36 deletions arcjet-next/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import arcjet, {
ArcjetHeaders,
Runtime,
ArcjetRequest,
EmptyObject,
ExtraProps,
RemoteClient,
RemoteClientOptions,
Expand All @@ -28,6 +27,43 @@ import findIP from "@arcjet/ip";
// Re-export all named exports from the generic SDK
export * from "arcjet";

// Type helpers from https://github.com/sindresorhus/type-fest but adjusted for
// our use.
//
// Simplify:
// https://github.com/sindresorhus/type-fest/blob/964466c9d59c711da57a5297ad954c13132a0001/source/simplify.d.ts
// EmptyObject:
// https://github.com/sindresorhus/type-fest/blob/b9723d4785f01f8d2487c09ee5871a1f615781aa/source/empty-object.d.ts
//
// Licensed: MIT License Copyright (c) Sindre Sorhus <[email protected]>
// (https://sindresorhus.com)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions: The above copyright
// notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};
declare const emptyObjectSymbol: unique symbol;
type WithoutCustomProps = {
[emptyObjectSymbol]?: never;
};

type PlainObject = {
[key: string]: unknown;
};

/**
* Ensures redirects are followed to properly support the Next.js/Vercel Edge
* Runtime.
Expand Down Expand Up @@ -84,10 +120,10 @@ export interface ArcjetNextRequest {

ip?: string;

nextUrl?: Partial<{ pathname: string, search: string }>;
nextUrl?: Partial<{ pathname: string; search: string }>;
}

export interface ArcjetNext<Rules extends (Primitive | Product)[]> {
export interface ArcjetNext<Props extends PlainObject> {
get runtime(): Runtime;
/**
* Protects an API route when running under the default runtime (non-edge).
Expand All @@ -103,7 +139,7 @@ export interface ArcjetNext<Rules extends (Primitive | Product)[]> {
request: ArcjetNextRequest,
// We use this neat trick from https://stackoverflow.com/a/52318137 to make a single spread parameter
// that is required if the ExtraProps aren't strictly an empty object
...props: ExtraProps<Rules> extends EmptyObject ? [] : [ExtraProps<Rules>]
...props: Props extends WithoutCustomProps ? [] : [Props]
): Promise<ArcjetDecision>;
}

Expand All @@ -123,7 +159,7 @@ export interface ArcjetNext<Rules extends (Primitive | Product)[]> {
*/
export default function arcjetNext<const Rules extends (Primitive | Product)[]>(
options: ArcjetOptions<Rules>,
): ArcjetNext<Rules> {
): ArcjetNext<Simplify<ExtraProps<Rules>>> {
const client = options.client ?? createNextRemoteClient();

const aj = arcjet({ ...options, client });
Expand All @@ -134,7 +170,7 @@ export default function arcjetNext<const Rules extends (Primitive | Product)[]>(
},
async protect(
request: ArcjetNextRequest,
...[props]: ExtraProps<Rules> extends EmptyObject
...[props]: ExtraProps<Rules> extends WithoutCustomProps
? []
: [ExtraProps<Rules>]
): Promise<ArcjetDecision> {
Expand Down Expand Up @@ -183,39 +219,32 @@ export default function arcjetNext<const Rules extends (Primitive | Product)[]>(
headers,
extra,
// TODO(#220): The generic manipulations get really mad here, so we just cast it
} as ArcjetRequest<Rules>);
} as ArcjetRequest<ExtraProps<Rules>>);

return decision;
},
});
}

/**
* Protects your Next.js application using Arcjet middleware. It will
* automatically detect if the request is an API request or a page request and
* return the appropriate response.
* Protects your Next.js application using Arcjet middleware.
*
* @param key Your Arcjet key.
* @param options Configuration options.
* @param options.mode The mode to run in: `dry-run` or `live` (default:
* `dry-run`). In `dry-run` mode, all requests will be allowed and you can
* review what the action would have been from your dashboard. In `live` mode,
* requests will be allowed, challenged or blocked based on the returned
* decision.
* @return A `NextResponse` instance that can be passed back to the client.
* @param arcjet An instantiated Arcjet SDK
* @param middleware Any existing middleware you'd like to be called after
* Arcjet decides a request is allowed.
* @returns If the request is allowed, the next middleware or handler will be
* called. If the request is denied, a `Response` will be returned immediately
* and the no further middleware or handlers will be called.
*/
export function createMiddleware<const Rules extends (Primitive | Product)[]>(
// TODO(#221): This type needs to be tightened to only allow Primitives or Products that don't have extra props
options: ArcjetOptions<Rules>,
export function createMiddleware(
arcjet: ArcjetNext<WithoutCustomProps>,
existingMiddleware?: NextMiddleware,
): NextMiddleware {
const aj = arcjetNext(options);

return async function middleware(
request: NextRequest,
event: NextFetchEvent,
): Promise<NextMiddlewareResult> {
let decision = await aj.protect(request);
let decision = await arcjet.protect(request);

if (decision.isDenied()) {
// TODO(#222): Content type negotiation using `Accept` header
Expand Down Expand Up @@ -268,20 +297,14 @@ function isNextApiResponse(val: unknown): val is NextApiResponse {
* Wraps a Next.js page route, edge middleware, or an API route running on the
* Edge Runtime.
*
* @param key Your Arcjet key.
* @param options Configuration options.
* @param options.mode The mode to run in: `dry-run` or `live` (default:
* `dry-run`). In `dry-run` mode, all requests will be allowed and you can
* review what the action would have been from your dashboard. In `live` mode,
* requests will be allowed, challenged or blocked based on the returned
* decision.
* @returns If the request is allowed, the wrapped handler will be called. If
* the request is blocked, a `NextApiResponse` instance will be returned based
* on the configured decision response.
* @param arcjet An instantiated Arcjet SDK
* @param handler The request handler to wrap
* @returns If the request is allowed, the wrapped `handler` will be called. If
* the request is denied, a `Response` will be returned based immediately and
* the wrapped `handler` will never be called.
*/
export function withArcjet<Args extends [ArcjetNextRequest, ...unknown[]], Res>(
// TODO(#221): This type needs to be tightened to only allow Primitives or Products that don't have extra props
arcjet: ArcjetNext<(Primitive<EmptyObject> | Product<EmptyObject>)[]>,
arcjet: ArcjetNext<WithoutCustomProps>,
handler: (...args: Args) => Promise<Res>,
) {
return async (...args: Args) => {
Expand Down
26 changes: 12 additions & 14 deletions arcjet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,11 @@ function errorMessage(err: unknown): string {
return "Unknown problem";
}

// Simplify, EmptyObject, and UnionToIntersection from
// https://github.com/sindresorhus/type-fest
// Type helpers from https://github.com/sindresorhus/type-fest but adjusted for
// our use.
//
// Simplify:
// https://github.com/sindresorhus/type-fest/blob/964466c9d59c711da57a5297ad954c13132a0001/source/simplify.d.ts
// EmptyObject:
// https://github.com/sindresorhus/type-fest/blob/b9723d4785f01f8d2487c09ee5871a1f615781aa/source/empty-object.d.ts
// UnionToIntersection:
// https://github.com/sindresorhus/type-fest/blob/017bf38ebb52df37c297324d97bcc693ec22e920/source/union-to-intersection.d.ts
//
Expand All @@ -130,10 +128,8 @@ function errorMessage(err: unknown): string {
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};
declare const emptyObjectSymbol: unique symbol;
export type EmptyObject = { [emptyObjectSymbol]?: never };
export type UnionToIntersection<Union> =
type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};
type UnionToIntersection<Union> =
// `extends unknown` is always going to be the case and is used to convert the
// `Union` into a [distributive conditional
// type](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#distributive-conditional-types).
Expand Down Expand Up @@ -479,8 +475,8 @@ export type ExtraProps<Rules> = Rules extends []
? UnionToIntersection<PropsForRule<Rules[number]>>
: never;

export type ArcjetRequest<Rules> = Simplify<
Partial<ArcjetRequestDetails & ExtraProps<Rules>>
export type ArcjetRequest<Props extends PlainObject> = Simplify<
Partial<ArcjetRequestDetails & Props>
>;

// Primitives and Products are the external names for Rules even though they are defined the same
Expand Down Expand Up @@ -748,7 +744,7 @@ export interface ArcjetOptions<Rules extends [...(Primitive | Product)[]]> {
* The Arcjet client provides a public `protect()` method to
* make a decision about how a request should be handled.
*/
export interface Arcjet<Rules extends [...(Primitive | Product)[]]> {
export interface Arcjet<Props extends PlainObject> {
get runtime(): Runtime;
/**
* Make a decision about how to handle a request. This will analyze the
Expand All @@ -765,7 +761,7 @@ export interface Arcjet<Rules extends [...(Primitive | Product)[]]> {
*
* @returns An {@link ArcjetDecision} indicating Arcjet's decision about the request.
*/
protect(request: ArcjetRequest<Rules>): Promise<ArcjetDecision>;
protect(request: ArcjetRequest<Props>): Promise<ArcjetDecision>;
}

/**
Expand All @@ -775,7 +771,7 @@ export interface Arcjet<Rules extends [...(Primitive | Product)[]]> {
*/
export default function arcjet<
const Rules extends [...(Primitive | Product)[]] = [],
>(options: ArcjetOptions<Rules>): Arcjet<Rules> {
>(options: ArcjetOptions<Rules>): Arcjet<Simplify<ExtraProps<Rules>>> {
const log = new Logger();

// We destructure here to make the function signature neat when viewed by consumers
Expand All @@ -798,7 +794,9 @@ export default function arcjet<
get runtime() {
return runtime();
},
async protect(request: ArcjetRequest<Rules>): Promise<ArcjetDecision> {
async protect(
request: ArcjetRequest<ExtraProps<Rules>>,
): Promise<ArcjetDecision> {
// This goes against the type definition above, but users might call
// `protect()` with no value and we don't want to crash
if (typeof request === "undefined") {
Expand Down
13 changes: 13 additions & 0 deletions examples/nextjs-14-app-dir-rl/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import arcjet, { createMiddleware } from "@arcjet/next";

export const config = {
// matcher tells Next.js which routes to run the middleware on
matcher: ["/"],
};

const aj = arcjet({
key: "ajkey_yourkey",
rules: [],
});

export default createMiddleware(aj);