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

Add typed cookies utilities #104

Merged
merged 1 commit into from
Nov 9, 2022
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
70 changes: 69 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,8 @@ export function Document({ children, title }: Props) {

Now, any link you defined in the `DynamicLinksFunction` will be added to the HTML as any static link in your `LinksFunction`s.

> Note: You can also put the `DynamicLinks` after the `Links` component, it's up to you what to prioritize, since static links are probably prefetched when you do `<Link prefetch>` you may want to put the `DynamicLinks` first to prioritize them.
> **Note**
> You can also put the `DynamicLinks` after the `Links` component, it's up to you what to prioritize, since static links are probably prefetched when you do `<Link prefetch>` you may want to put the `DynamicLinks` first to prioritize them.

### ExternalScripts

Expand Down Expand Up @@ -914,6 +915,73 @@ export let loader: LoaderFunction = async ({ request }) => {
};
```

### TypedCookies

Cookie objects in Remix allows any type, the typed cookies from Remix Utils lets you use Zod to parse the cookie values and ensure they conform to a schema.

```ts
import { createCookie } from "remix";
import { createTypedCookie } from "remix-utils";
import { z } from "zod";

let cookie = createCookie("returnTo", cookieOptions);
let schema = z.string().url();

// pass the cookie and the schema
let typedCookie = createTypedCookie({ cookie, schema });

// this will be a string and also a URL
let returnTo = await typedCookie.parse(request.headers.get("Cookie"));

// this will not pass the schema validation and throw a ZodError
await cookie.serialize("a random string that's not a URL");
// this will make TS yell because it's not a string, if you ignore it it will
// throw a ZodError
await cookie.serialize(123);
```

You could also use typed cookies with any sessionStorage mechanism from Remix.

```ts
let cookie = createCookie("session", cookieOptions);
let schema = z.object({ token: z.string() });

let sessionStorage = createCookieSessionStorage({
cookie: createTypedCookie({ cookie, schema }),
});

// if this works then the correct data is stored in the session
let session = sessionStorage.getSession(request.headers.get("Cookie"));

session.unset("token"); // remove a required key from the session

// this will throw a ZodError because the session is missing the required key
await sessionStorage.commitSession(session);
```

Now Zod will ensure the data you try to save to the session is valid removing any extra field and throwing if you don't set the correct data in the session.

> **Note**
> The session object is not really typed so doing session.get will not return the correct type, you can do `schema.parse(session.data)` to get the typed version of the session data.

You can also use async refinements in your schemas because typed cookies uses parseAsync method from Zod.

```ts
let cookie = createCookie("session", cookieOptions);

let schema = z.object({
token: z.string().refine(async (token) => {
let user = await getUserByToken(token);
return user !== null;
}, "INVALID_TOKEN"),
});

let sessionTypedCookie = createTypedCookie({ cookie, schema });

// this will throw if the token stored in the cookie is not valid anymore in the DB
sessionTypedCookie.parse(request.headers.get("Cookie"));
```

## Author

- [Sergio Xalambrí](https://sergiodxa.com)
Expand Down
21 changes: 20 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"peerDependencies": {
"@remix-run/react": "^1.1.1",
"@remix-run/server-runtime": "^1.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"zod": "^3.19.1"
},
"devDependencies": {
"@babel/core": "^7.16.0",
Expand Down Expand Up @@ -80,7 +81,8 @@
"react": "^17.0.2",
"react-router-dom": "^6.0.0-beta.6",
"ts-node": "^10.4.0",
"typescript": "^4.2.4"
"typescript": "^4.2.4",
"zod": "^3.19.1"
},
"dependencies": {
"intl-parse-accept-language": "^1.0.0",
Expand Down
1 change: 1 addition & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from "./server/get-client-ip-address";
export * from "./server/get-client-locales";
export * from "./server/is-prefetch";
export * from "./server/responses";
export * from "./server/typed-cookie";
49 changes: 49 additions & 0 deletions src/server/typed-cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
Cookie,
CookieParseOptions,
CookieSerializeOptions,
} from "@remix-run/server-runtime";
import type { z } from "zod";

export interface TypedCookie<Schema extends z.ZodTypeAny> extends Cookie {
isTyped: true;
parse(
cookieHeader: string,
options?: CookieParseOptions
): Promise<z.infer<Schema> | null>;

serialize(
value: z.infer<Schema>,
options?: CookieSerializeOptions
): Promise<string>;
}

export function createTypedCookie<Schema extends z.ZodTypeAny>({
cookie,
schema,
}: {
cookie: Cookie;
schema: Schema;
}): TypedCookie<Schema> {
return {
isTyped: true,
get name() {
return cookie.name;
},
get isSigned() {
return cookie.isSigned;
},
get expires() {
return cookie.expires;
},
async parse(cookieHeader, options) {
if (!cookieHeader) return null;
let value = await cookie.parse(cookieHeader, options);
return await schema.parseAsync(value);
},
async serialize(value, options) {
let parsedValue = await schema.parseAsync(value);
return cookie.serialize(parsedValue, options);
},
};
}
72 changes: 72 additions & 0 deletions test/server/typed-cookie.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
createCookie,
createCookieSessionStorage,
isCookie,
} from "@remix-run/node";
import { z, ZodError } from "zod";
import { createTypedCookie } from "../../src";

describe("TypedCookie", () => {
let cookie = createCookie("name", { secrets: ["secret"] });
let typedCookie = createTypedCookie({
cookie,
schema: z.string().min(3),
});

test("isCookie pass", () => {
expect(isCookie(typedCookie)).toBe(true);
});

test("throw if serialized type is not valid", async () => {
await expect(() => typedCookie.serialize("a")).rejects.toThrowError(
ZodError
);
// @ts-expect-error We now this will be a TS error
await expect(() => typedCookie.serialize(123)).rejects.toThrowError(
ZodError
);
});

test("throw if parsed type is not valid", async () => {
let cookieHeader = await cookie.serialize("a");
await expect(() => typedCookie.parse(cookieHeader)).rejects.toThrowError(
ZodError
);
});

test("sessionStorage must accepts typed-cookie", async () => {
let cookie = createCookie("name", { secrets: ["secret"] });
let schema = z.object({ token: z.string() });

let typedCookie = createTypedCookie({ cookie, schema });

let sessionStorage = createCookieSessionStorage({ cookie: typedCookie });

let cookieHeader = await typedCookie.serialize({ token: "a-b-c" });

let session = await sessionStorage.getSession(cookieHeader);

expect(schema.parse(session.data)).toEqual({ token: "a-b-c" });

session.unset("token");

await expect(() =>
sessionStorage.commitSession(session)
).rejects.toThrowError(ZodError);
});

test("supports async schemas", async () => {
let cookie = createCookie("name", { secrets: ["secret"] });
let schema = z.object({
token: z.string().refine(async () => {
return false;
}, "INVALID_TOKEN"),
});

let typedCookie = createTypedCookie({ cookie, schema });

expect(() =>
typedCookie.serialize({ token: "a-b-c" })
).rejects.toThrowError(ZodError);
});
});