Skip to content

Commit

Permalink
fix!: Reorganize SDK types to tighten helpers around custom props (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
blaine-arcjet authored Dec 18, 2023
1 parent f6d54f5 commit 3b0c1fb
Show file tree
Hide file tree
Showing 3 changed files with 84 additions and 50 deletions.
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);

0 comments on commit 3b0c1fb

Please sign in to comment.