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

Use AsyncLocalStorage #40

Open
wants to merge 11 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
9 changes: 7 additions & 2 deletions examples/remix-cms/app/components/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export function Toast({
useEffect(() => {
if (message) {
setShow(true);
} else {
setShow(false);
}
}, [message]);

Expand All @@ -31,6 +33,8 @@ export function Toast({
return () => {
clearTimeout(timeout);
};
} else {
setShow(false);
}
}, [show]);

Expand All @@ -57,12 +61,13 @@ export function Toast({
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
{success ? (
{success && (
<CheckCircleIcon
className="h-6 w-6 text-green-400"
aria-hidden="true"
/>
) : (
)}
{error && (
<ExclamationCircleIcon
className="h-6 w-6 text-red-400"
aria-hidden="true"
Expand Down
7 changes: 4 additions & 3 deletions examples/remix-cms/app/routes/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,21 @@ import { Link, NavLink, Outlet, useLoaderData } from "@remix-run/react";
import { Toast } from "~/components/Toast";
import { json, type LoaderArgs, redirect } from "@remix-run/cloudflare";
import { User } from "~/models/User";
import { auth } from "superflare";

const navigation = [
{ name: "Dashboard", href: "/admin", icon: HomeIcon, end: true },
{ name: "Articles", href: "./articles", icon: FolderIcon },
];

export async function loader({ context: { auth, session } }: LoaderArgs) {
if (!(await auth.check(User))) {
export async function loader({ context: { session } }: LoaderArgs) {
if (!(await auth().check(User))) {
return redirect("/auth/login");
}

const flash = session.getFlash("flash");

const user = await auth.user(User);
const user = await auth().user(User);

return json({
flash,
Expand Down
4 changes: 3 additions & 1 deletion examples/remix-cms/app/routes/admin/articles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { Article } from "~/models/Article";
import { useChannel } from "~/utils/use-channel";

export async function loader() {
const articles = await Article.with("user").orderBy("createdAt", "desc");
const articles = await Article.with("user")
.orderBy("createdAt", "desc")
.get();
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A limitation with ALS in CF Workers today is that custom thenables don't properly retain context: https://twitter.com/jasnell/status/1634764772121145344

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


return json({ articles });
}
Expand Down
17 changes: 13 additions & 4 deletions examples/remix-cms/app/routes/auth/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,38 @@ import { json, redirect, type ActionArgs } from "@remix-run/cloudflare";
import { Button } from "~/components/admin/Button";
import { FormField } from "~/components/Form";
import { User } from "~/models/User";
import { auth } from "superflare";

export async function action({ request, context: { auth } }: ActionArgs) {
if (await auth.check(User)) {
export async function action({ request }: ActionArgs) {
if (await auth().check(User)) {
return redirect("/admin");
}

const formData = new URLSearchParams(await request.text());

if (formData.get("bypass") === "user") {
auth.login((await User.first()) as User);
auth().login((await User.first()) as User);
return redirect("/admin");
}

const email = formData.get("email") as string;
const password = formData.get("password") as string;

if (await auth.attempt(User, { email, password })) {
if (await auth().attempt(User, { email, password })) {
return redirect("/admin");
}

return json({ error: "Invalid credentials" }, { status: 400 });
}

export async function loader() {
if (await auth().check(User)) {
return redirect("/admin");
}

return null;
}

export default function Login() {
const actionData = useActionData();

Expand Down
7 changes: 4 additions & 3 deletions examples/remix-cms/app/routes/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { type ActionArgs, redirect } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { auth } from "superflare";

export async function action({ context: { auth } }: ActionArgs) {
auth.logout();
export async function action() {
auth().logout();

return redirect("/");
}
16 changes: 12 additions & 4 deletions examples/remix-cms/app/routes/auth/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { json, redirect, type ActionArgs } from "@remix-run/cloudflare";
import { Button } from "~/components/admin/Button";
import { FormField } from "~/components/Form";
import { User } from "~/models/User";
import { hash } from "superflare";
import { auth, hash } from "superflare";

export async function action({ request, context: { auth } }: ActionArgs) {
if (await auth.check(User)) {
export async function action({ request }: ActionArgs) {
if (await auth().check(User)) {
return redirect("/admin");
}

Expand All @@ -25,11 +25,19 @@ export async function action({ request, context: { auth } }: ActionArgs) {
password: await hash().make(password),
});

auth.login(user);
auth().login(user);

return redirect("/admin");
}

export async function loader() {
if (await auth().check(User)) {
return redirect("/admin");
}

return null;
}

export default function Register() {
const actionData = useActionData();

Expand Down
1 change: 1 addition & 0 deletions examples/remix-cms/app/utils/markdown.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { marked } from "marked";
// TODO: Find a lighter solution for syntax highlighting
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not related to ALS per se, but my CF Workers bundle is like 4MB because of this 🙃

import hljs from "highlight.js";

export async function convertToHtml(input: string) {
Expand Down
12 changes: 6 additions & 6 deletions examples/remix-cms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
"@cloudflare/kv-asset-handler": "^0.3.0",
"@headlessui/react": "^1.7.13",
"@heroicons/react": "^2.0.16",
"@remix-run/cloudflare": "^1.14.1",
"@remix-run/react": "^1.14.1",
"@remix-run/serve": "^1.14.1",
"@remix-run/cloudflare": "0.0.0-nightly-da5486d-20230323",
"@remix-run/react": "0.0.0-nightly-da5486d-20230323",
"@remix-run/serve": "0.0.0-nightly-da5486d-20230323",
"@superflare/remix": "workspace:*",
"@tailwindcss/forms": "^0.5.3",
"clsx": "^1.2.1",
Expand All @@ -38,9 +38,9 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20230307.0",
"@faker-js/faker": "^7.6.0",
"@remix-run/dev": "^1.14.1",
"@remix-run/eslint-config": "^1.14.1",
"@remix-run/server-runtime": "^1.14.1",
"@remix-run/dev": "0.0.0-nightly-da5486d-20230323",
"@remix-run/eslint-config": "0.0.0-nightly-da5486d-20230323",
"@remix-run/server-runtime": "0.0.0-nightly-da5486d-20230323",
"@tailwindcss/typography": "^0.5.9",
"@types/marked": "^4.0.8",
"@types/react": "^18.0.28",
Expand Down
2 changes: 1 addition & 1 deletion examples/remix-cms/worker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createRequestHandler } from "@remix-run/cloudflare";
import config from "./superflare.config";
import * as build from "./build";
import {
getAssetFromKV,
Expand All @@ -9,6 +8,7 @@ import {
import manifestJSON from "__STATIC_CONTENT_MANIFEST";
import { handleQueue } from "superflare";
import { handleFetch } from "@superflare/remix";
import config from "./superflare.config";

export { Channel } from "superflare";

Expand Down
5 changes: 0 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,5 @@
"dependencies": {
"@changesets/changelog-git": "^0.1.14",
"@changesets/cli": "^2.26.0"
},
"pnpm": {
"patchedDependencies": {
"@remix-run/[email protected]": "patches/@[email protected]"
}
}
}
5 changes: 2 additions & 3 deletions packages/superflare-remix/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { createCookieSessionStorage } from "@remix-run/cloudflare";
import {
defineConfig,
handleFetch as superflareHandleFetch,
SuperflareAuth,
SuperflareSession,
type defineConfig,
} from "superflare";

/**
Expand Down Expand Up @@ -51,8 +51,7 @@ export async function handleFetch<Env extends { APP_KEY: string }>(
},
async () => {
/**
* We inject env and session into the Remix load context.
* Someday, we could replace this with AsyncLocalStorage.
* TODO: REMOVE THIS since we're using AsyncLocalStorage
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or just deprecate it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me, I think I'll still put some separate things in the context for now. But a lot of people probably never should need to care about context.

*/
const loadContext: SuperflareAppLoadContext<Env> = {
session,
Expand Down
1 change: 1 addition & 0 deletions packages/superflare-remix/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
},
"dependencies": {
"@cloudflare/kv-asset-handler": "^0.3.0",
"@types/node": "^18.14.1",
"superflare": "workspace:*"
}
}
36 changes: 29 additions & 7 deletions packages/superflare/cli/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { inspect } from "node:util";
import path from "node:path";
import { CommonYargsArgv, StrictYargsOptionsToInterface } from "./yargs-types";
import { createD1Database } from "./d1-database";
import { Script } from "node:vm";

export function consoleOptions(yargs: CommonYargsArgv) {
return yargs
Expand Down Expand Up @@ -88,14 +89,35 @@ export async function createRepl({
server.context["db"] = db;

/**
* Run the Superflare `config` to ensure Models have access to the database.
* Define a custom `eval` function to wrap the code in a `runWithContext` call.
*/
server.eval(
`const {setConfig} = require('superflare'); setConfig({database: { default: db }});`,
server.context,
"repl",
() => {}
);
const customEval = (
cmd: string,
_: any,
filename: string,
callback: (err: Error | null, result: any) => void
) => {
const wrappedCmd = `
const { getContextFromUserConfig, runWithContext } = require('superflare');
const context = getContextFromUserConfig({database: { default: db }});
runWithContext(context, async () => {
return ${cmd}
});
`;

const response = new Script(wrappedCmd, { filename }).runInThisContext();

if (response instanceof Promise) {
response
.then((result) => callback(null, result))
.catch((err) => callback(err, null));
} else {
callback(null, response);
}
};

// @ts-ignore
server["eval"] = customEval;

/**
* Get a list of the models in the user's dir.
Expand Down
25 changes: 15 additions & 10 deletions packages/superflare/docs/security/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ To set up authentication, you need to do a few things:

1. Add a `User` model (and a `users` table)
2. Ensure you've created an instance of [`SuperflareSession`](/sessions)
3. Create an instance of `SuperflareAuth` (with a session) and pass it to a place your application code can access it during a request (like a Remix `AppContext`).
4. Add some sort of `/register` and `/login` routes to your application so users can log in.
3. Add some sort of `/register` and `/login` routes to your application so users can log in.

{% callout title="Batteries-included" %}
If you've created a Superflare app with `npx superflare new`, you should already have these requirements met! Go take a nap or something.
Expand All @@ -30,37 +29,43 @@ Superflare provides basic `/auth/register` and `/auth/login` routes and forms fo

## Protecting routes

You can protect routes by using the `SuperflareAuth` instance you created in your app's entrypoint. For the purpose of this example, we'll pretend you're using Remix, and that you've injected the `SuperflareAuth` instance into the `AppContext` as `auth`:
You can protect routes by using the `auth` helper exported by Superflare:

```ts
import { auth } from "superflare";

// routes/my-secret-route.tsx
export async function loader({ context: { auth } }: LoaderArgs) {
export async function loader() {
// If the user is not logged in, redirect them to the login page
if (!(await auth.check(User))) {
if (!(await auth().check(User))) {
return redirect("/auth/login");
}

const user = await auth.user(User);
const user = await auth().user(User);

// If the user is logged in, show them the secret page
return json({ message: `You're logged in, ${user.name}!` });
}
```

{% callout title="Fetch requests only" %}
Auth is only available during `fetch` requests to your worker—not during `queue` or `scheduled` requests. This is because `fetch` requests are the only ones that have access to a user's session. Auth is injected using [AsyncLocalStorage](https://nodejs.org/api/async_context.html#class-asynclocalstorage), which is only available in `fetch` requests.
{% /callout %}

## Logging out

To log out, you can use the `SuperflareAuth` instance's `logout()` method:
To log out, you can use the `auth().logout()` method:

```ts
// routes/logout.tsx
export async function action({ context: { auth } }: LoaderArgs) {
auth.logout();
export async function action() {
auth().logout();

return redirect("/auth/login");
}
```

## `SuperflareAuth` API
## `auth()` API

### `check(model: typeof Model): Promise<boolean>`

Expand Down
12 changes: 10 additions & 2 deletions packages/superflare/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { setConfig, defineConfig } from "./src/config";
export { defineConfig } from "./src/config";
export { Model } from "./src/model";
export { SuperflareSession } from "./src/session";
export { DatabaseException } from "./src/query-builder";
Expand All @@ -8,10 +8,18 @@ export { Factory } from "./src/factory";
export { handleFetch } from "./src/fetch";
export { handleQueue } from "./src/queue";
export { Job } from "./src/job";
export { SuperflareAuth } from "./src/auth";
export { auth, SuperflareAuth } from "./src/auth";
export { hash } from "./src/hash";
export { Event } from "./src/event";
export { Listener } from "./src/listener";
export { handleWebSockets } from "./src/websockets";
export { Channel } from "./src/durable-objects/Channel";
export {
getContext,

// Internal use only:
runWithContext,
enterWithConfig,
getContextFromUserConfig,
} from "./src/context";
export { Schema } from "./src/schema";
Loading