Effect and effetch
Use the session as an Effect service, call your API with effetch, and cache each account's data with effetch-query.
In this guide, you use the session from an Effect app. You read it as a service, call your API with effetch, and cache each account's data with effetch-query. Two entry points do this:
@monarcode/session-kit/effectgives your Effects the session as theAuthservice. It wraps the client you already have, so React, your router and your Effects share one session. Its Effects fail with a taggedAuthFailure, whichEffect.catchTaghandles like your own errors.@monarcode/session-kit/effetchhas thesessionBearerplugin. It sends the access token with each effetch call, and refreshes after a 401.
A complete app is in
examples/tanstack-start-spa-effetch.
Every export is listed in the reference for
./effect and ./effetch.
Before you begin
- Read Getting started. This guide builds the same app with Effect Schemas and effetch. Refresh and Errors explain how the client renews and fails.
- Know Effect's generators, services and layers.
Installation
To install the packages, run the following command:
npm install @monarcode/session-kit effect @monarcode/effetcheffect and @monarcode/effetch are optional peer dependencies of Session
kit. ./effect and ./effetch need effect 4.1 or newer, and ./effetch
also needs @monarcode/effetch.
To cache with effetch-query, also install
effetch-query 0.1.0-rc.2 or newer, and its @effect/atom-react peer for
React:
npm install @monarcode/effetch-query @effect/atom-react1. Describe your backend
This is the backend from Getting started, as Effect Schemas: the user, the tokens, and the body DummyJSON explains a failure with. They're schemas only, with no client, so the rest of the app, and the server, import them from one place.
import * as from "effect/Schema";
export const = "https://dummyjson.com";
export const = .({
: .,
: .,
: .,
: .,
});
/** The user, as Session kit's `user` option takes it. */
export const = .();
export const = .({
: .,
: .,
});
/** DummyJSON returns the user's fields beside the tokens. */
export const = .({
....,
....,
});
export const = .({ : . });2. Refresh with effetch
The refresh gets an effetch client of its own, made with its own
Fetch.make, for two reasons:
- The app's API client waits for a refresh after a 401. A refresh sent through it would wait for itself.
- The app's runtime is built on the session client this refresh belongs to,
so the refresh can't run in it. It provides the
FetchHttpClienteffetch sends with itself.
refreshSession resolves null when the backend rejects the refresh token,
and fails for anything else, as Refresh
explains. The server uses it too:
import * as from "@monarcode/effetch";
import * as from "effect/Effect";
import * as from "effect/http/FetchHttpClient";
import { , } from "./backend.js";
// Not the app's API client: its session plugin waits for this refresh.
const = .({ : });
/** New tokens. `null` signs out; a failure keeps the session for a retry. */
export const = (: string, : ) =>
.(
.(function* () {
const = yield* ;
return yield* .("/auth/refresh", {
: { },
: ,
});
}).(
.("HttpError", () =>
. === 401 || . === 403
? .(null)
: .(),
),
.(.),
),
{ },
);3. Register the client
Create the client as usual, then register its type once, so every Effect carries your user:
import { , } from "@monarcode/session-kit";
import { } from "@monarcode/session-kit/react";
import { } from "./backend.js";
import { } from "./refresh.js";
export const = ({
: "app",
: ,
: (),
: ({ , }) => (, ),
});
export const { , , } =
<typeof >();
declare module "@monarcode/session-kit/effect" {
interface Register {
: typeof ;
}
}4. Provide the service
Auth.layer provides the client as the Auth service. It doesn't mount the
client: AuthProvider, SessionGate or the router adapters still do that.
To provide it, create the layer once:
import { } from "@monarcode/session-kit/effect";
import { } from "./auth.js";
export const = .();In tests, provide a layer over another client instead.
5. Read the session
Auth.session is the current session. If the access token expired, it
refreshes first. It fails with UNAUTHENTICATED when no one is signed in.
For code that also runs signed out, Auth.sessionOption gives
Option.none() instead:
import { } from "@monarcode/session-kit/effect";
import * as from "effect/Effect";
import * as from "effect/Option";
import { } from "./layer.js";
export const = ..(
.(
.({
: () => "Welcome",
: () => `Welcome back, ${..}`,
}),
),
);
export const = () =>
.(.(, ));6. Call your API with effetch
Make the app's API client with effetch's Fetch.make: the backend's base
URL, its error body, and the plugins every call goes through. The
sessionBearer plugin does what createAuthFetch
does: it sends the access token, and refreshes after a 401.
Put sessionBearer last, so that a retry reads the current token. Give it the
client's baseUrl, since the token goes only to that origin. Fetch.make
then needs the Auth service, once:
import * as from "@monarcode/effetch";
import { } from "@monarcode/effetch/plugins";
import { } from "@monarcode/session-kit/effetch";
import * as from "effect/Context";
import * as from "effect/Effect";
import * as from "effect/http/HttpClient";
import * as from "effect/Layer";
import { , } from "./backend.js";
const = .({
: ,
: ,
: [(), ({ : , : true })],
}).(
// Effect adds tracing headers to every request, which fail the preflight
// of an API that doesn't allow them.
.(., false),
);
export class extends .<
,
.<typeof >
>()("ApiClient") {}
/** The API client, over whichever `Auth` is provided. */
export const = .(, );Calls can fail with AuthFailure. When one client serves public and private
endpoints, as this one does, give the plugin optional: true: a call made
while signed out is then sent without a token, and a private endpoint
answers with its own 401. A locked client still fails with LOCKED.
Effect code yields the client and calls its methods, such as get and
post. The sign-in call skips the plugin with sessionBearer: false,
because it's how the user gets a token. To read the current user and to sign
in, add src/user.ts:
import * as from "effect/Effect";
import { } from "./api.js";
import { , } from "./backend.js";
export const = .(function* () {
const = yield* ;
return yield* .("/auth/me", { : });
});
export const = (: string, : string) =>
.(function* () {
const = yield* ;
return yield* .("/auth/login", {
: { , },
: ,
: { : false },
});
});With another HTTP client, use
Auth.credentials, the access token. It's kept apart from Auth.session, so
code that only needs the user never holds a token. After a 401,
Auth.renew(credentials) gets new credentials once for every caller.
7. Run Effects in one runtime
Compose the app's layers in one place: the API client, the session, and the
FetchHttpClient effetch sends with, each made once:
import * as from "effect/http/FetchHttpClient";
import * as from "effect/Layer";
import * as from "effect/ManagedRuntime";
import { } from "./api.js";
import { } from "./layer.js";
const = .(
.(),
.(.),
);
/** One runtime for the app's lifetime. */
export const = .();
export type = typeof ;Run every Effect with this runtime, as in
appRuntime.runPromise(currentUser), instead of providing AuthLive as
step 5 does.
Handle failures
Every Auth Effect fails with AuthFailure. Its code is the client's
AuthErrorCode, and its cause is the
AuthError itself. Misuse, which the client reports as INVALID_OPTIONS, is
a defect rather than a failure.
For example, to show "Guest" when no one is signed in:
import { } from "@monarcode/session-kit/effect";
import * as from "effect/Effect";
export const = ..(
.(() => ..),
.("AuthFailure", () =>
. === "UNAUTHENTICATED"
? .("Guest")
: .(),
),
);Code outside Auth that rejects with an AuthError, such as
createAuthFetch, converts it with Auth.fromAuthError.
Update the user
Auth.updateUser takes the user, or an Effect that fetches it. The session is
captured before the Effect runs, so a profile fetched for one account fails
with SESSION_CHANGED instead of landing on another. The Effect's own
failure comes through unchanged:
import { } from "@monarcode/session-kit/effect";
import { } from "./user.js";
export const = .();Watch the session
Auth.changes is a Stream of the client's AuthState: the current state,
then every change, until the stream ends:
import { } from "@monarcode/session-kit/effect";
import * as from "effect/Effect";
import * as from "effect/Stream";
export const = ..(
.(() => .),
.,
.(() => .(`Auth is ${}`)),
);Cache with effetch-query
effetch-query caches endpoints: a route and its schemas, defined with
Fetch.endpoint, which it calls with the app's API client. Its queries and
mutations run with the app's runtime, so components never run an Effect by
hand. Give it the client's defaultError, which an endpoint can't read from
the client:
import { } from "@monarcode/effetch-query";
import { } from "@monarcode/effetch-query/effetch";
import type * as from "effect/ManagedRuntime";
import { } from "./api.js";
import { } from "./backend.js";
import { , type } from "./runtime.js";
declare module "@monarcode/effetch-query" {
interface Register {
readonly : .ManagedRuntime.<>;
}
}
export const = (, { : });
export const = () => .({ : });A cache must not outlive the account that filled it. Let QueryProvider own
its client (make instead of client) and pass the session ID as resetKey.
Signing out or switching accounts then disposes the client, with its cache
and its requests in flight, and renders the tree with a new one:
import type { } from "react";
import { } from "@monarcode/effetch-query/react";
import { } from "./auth.js";
import { } from "./query.js";
export function ({ }: { : }) {
const = (() => . ?? "signed-out");
return (
< ={} ={}>
{}
</>
);
}Render it inside AuthProvider or SessionGate, above every Suspense
boundary. A refresh, a failed refresh or a profile update keeps the
sessionId, and so the cache. So does locking: queries that need a token
fail with LOCKED until unlock(), then refetch as usual. Until a saved
session is restored, the key is "signed-out", and the restore replaces
that client.
A feature defines the endpoints it caches, and its components use the queries and mutations made of them:
import * as from "@monarcode/effetch";
import { , } from "@monarcode/effetch-query/react";
import * as from "effect/reactivity/AsyncResult";
import * as from "effect/Schema";
import { } from "../auth.js";
import { , } from "../backend.js";
import { } from "../query.js";
const = .(
.("POST /auth/login", {
: .({ : ., : . }),
: ,
}),
{ : { : false } },
);
const = .(
.("GET /auth/me", { : }),
);
export function () {
const = ();
const { , } = ();
return (
<
={.}
={() =>
(
// DummyJSON's documented test user.
{ : { : "emilys", : "emilyspass" } },
{
: ({ , , ... }) =>
.({ , , }),
},
)
}
>
Sign in
</>
);
}
export function () {
const = (());
return .(, {
: () => <>Loading…</>,
: () => <>Couldn't load your profile.</>,
: ({ }) => <>{.}</>,
});
}The owned client is for the browser and React Native. With server rendering,
give QueryProvider a client per request instead, as effetch-query's
README describes. Make it on the server over that request's session, as
Render queries on the server shows.
On the server
With server rendering, server functions, actions and route handlers run
Effects too, and the session they read is the cookie the server keeps
(Server sessions).
serverAuth.forRequest(jar) is one request's session, and Auth.layer takes
it, so Auth.session, Auth.credentials and sessionBearer work there:
sessionBearer sends the cookie's token, and saves a renewed one after a 401.
On the server, the session comes from createServerAuth, with the user
schema the app registers and the same refresh. The server's files build on
src/backend.ts, src/refresh.ts, src/api.ts and src/user.ts from the
earlier steps.
In a server-rendered app, the registered client is the browser's
createMirrorAuth client, not createAuth. Each framework's guide sets it
up: TanStack Start,
Next.js and
React Router.
To create the server's session engine, add src/server/auth.ts:
import { } from "@monarcode/session-kit/server";
import { } from "../backend.js";
import { } from "../refresh.js";
declare const : string; // from the environment
export const = ({
: "app",
: ,
,
: ({ , }) => (, ),
});A request's session belongs to that request, and so does everything that uses it. Build those per request, and keep a runtime for what belongs to no user:
import { } from "@monarcode/session-kit/effect";
import type { } from "@monarcode/session-kit/server";
import * as from "effect/Effect";
import * as from "effect/http/FetchHttpClient";
import * as from "effect/Layer";
import * as from "effect/ManagedRuntime";
import { type , } from "../api.js";
import { } from "./auth.js";
/** Built once: what belongs to no user. */
const = .(.);
/** Built per request: its session, and the API client that uses it. */
export const = (: ) =>
.(
.(.(.())),
);
/** Runs `effect` with the session of the request `jar` belongs to. */
export function <, >(
: ,
: .<, , | >,
): <> {
return .(.(, ()));
}The runtime has no Auth and no API client, so an Effect that needs them
doesn't compile until it goes through runRequest, and nothing a service
keeps outlives its request. Building them is cheap next to the request your
API answers.
Each framework's adapter gives the jar, in the server function, action or handler:
import type { } from "@monarcode/session-kit/server";
import { } from "../user.js";
import { } from "./run.js";
// TanStack Start: startJar(). React Router: routerJar(context).
// Next.js: nextJar(await cookies(), { writable: true }).
export const = (: ) => (, );Sign in on the server
Auth.signIn and Auth.updateUser aren't available on a request's session:
the user comes in through serverAuth. Ask your backend with an Effect, then
seal what it returns:
import type { } from "@monarcode/session-kit/server";
import { } from "../user.js";
import { } from "./auth.js";
import { } from "./run.js";
export async function (
: ,
: string,
: string,
) {
const { , , ... } = await (
,
(, ),
);
return .(, { , , });
}Render queries on the server
To render a page with its data, make a query cache per request, on the server, over that request's layer, and dispose of it once the page is sent:
import { } from "@monarcode/effetch-query";
import type { } from "@monarcode/session-kit/server";
import * as from "effect/http/FetchHttpClient";
import * as from "effect/Layer";
import { } from "./run.js";
export const = (: ) =>
.({
: ().(.(.)),
});Give it no memoMap (Things to know says why). Prefetch
before the page starts streaming, or refresh in middleware first, since a
cookie set once streaming has begun is lost. In the browser, the cache is the
owned one from Cache with effetch-query, over
the mirror client.
Things to know
- One registration. An app with server rendering registers its mirror
client, and that registration types
Authon the server too:Auth.layertakes a request's session whose user is the registered one. In the browser,Auth.layer(mirror)is built where the mirror is created.Auth.signInneeds the mirror'ssignInoption there,sessionBeareritscredentialsoption, andAuth.updateUserisn't available, since the user comes from the server. - Keep server modules free of the browser client. In this guide, the
server's files import only
src/backend.ts,src/refresh.ts,src/api.tsandsrc/user.ts, which create no Session kit client. Keep it that way in your app. A module the server imports, such as the one that definesApiClientLiveor the user schema, mustn't also create a browser client or its layer, or the server loads it too. - Name the services in
runRequest's signature, assrc/server/run.tsdoes, rather than inferring them fromrequestLayer. With a mirror registered, the registration depends on the server functions the mirror calls, which callrunRequest, and TypeScript reports a circular type. For the same reason, the server functions the mirror is made from (itsrevalidate,signInandsignOut) read the session withserverAuth, notAuth.session. - Keep a request's layer off a shared
memoMap, such as the runtime's or one passed toQueryClient.make. Layers are shared by identity there, so overlapping requests can get one build: a service that keepsAuthwhen it's built would then serve one request's session to another. - Read-only jars, such as a Next.js Server Component's:
Auth.sessionreturns the cookie's session as it is, whileAuth.credentialsandsessionBearerfail withREFRESH_FAILEDonce access has expired, and after a 401, which can't be renewed there unless the same request's writable jar already renewed it. Refresh in middleware or a proxy first, withserverAuth.refreshIfDue, as the framework guides do. Sign out in an action or route handler. - Only
Auth's Effects. A request's session has only whatAuth's Effects use, so don't reach for other client methods throughAuth.usethere.
What stays on the client
Auth covers what Effect code does with a session: reading it, signing in
and out, and updating the user. Everything else stays on the client, which
React and the routers already drive: lock and unlock, refresh and
retry, mount, and the client's options. On the server, signing in and
updating the user are serverAuth's.
Next steps
- Server sessions: how the server keeps the session in a cookie, and refreshes it.
- Locking: lock a native app's session.
Auth.sessionandsessionBearerthen fail withLOCKEDuntil it's unlocked. - Other HTTP clients: the calls
sessionBeareris built on, for any other client.