├── .prettierignore ├── setupTests.ts ├── .vscode ├── extensions.json └── settings.json ├── tsconfig.json ├── scratchpad └── tsconfig.json ├── tsconfig.src.json ├── .gitignore ├── .prettierrc.json ├── tsconfig.build.json ├── tsconfig.examples.json ├── tsconfig.test.json ├── .changeset └── config.json ├── vitest.config.ts ├── src ├── index.ts ├── Navigation.ts ├── Params.ts ├── internal │ ├── middleware-chain.ts │ ├── executor.ts │ └── async-context.ts ├── Cache.ts ├── Headers.ts ├── NextMiddleware.ts └── Next.ts ├── test ├── Next.basic.test.ts ├── Next.wrap.test.ts ├── Middleware.catch.test.ts ├── Middlewares.ensure-execution.test.ts ├── Middleware.provides-missing.test.ts ├── Middlewares.timing.test.ts ├── Next.defect.test.ts ├── Next.test.ts ├── Middleware.failure-propagation.test.ts ├── Next.defect-propagation.test.ts ├── Cache.revalidation.test.ts ├── Middleware.ordering.test.ts └── AsyncContext.test.ts ├── .github ├── actions │ └── setup │ │ └── action.yml └── workflows │ ├── snapshot.yml │ ├── release.yml │ └── check.yml ├── patches ├── babel-plugin-annotate-pure-calls@0.4.0.patch └── @changesets__get-github-info@0.6.0.patch ├── example ├── Params.ts ├── Route.ts ├── ServerComponent.ts ├── Action.ts ├── Layout.ts ├── MiddlewareWithDep.ts ├── Page.ts └── Middleware.ts ├── LICENSE ├── tsconfig.base.json ├── package.json ├── eslint.config.mjs ├── CHANGELOG.md └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.ts 3 | *.cjs -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | import * as it from "@effect/vitest" 2 | 3 | it.addEqualityTesters() 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "effectful-tech.effect-vscode", 4 | "dbaeumer.vscode-eslint" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "editor.formatOnSave": true, 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": [], 4 | "references": [ 5 | { "path": "tsconfig.src.json" }, 6 | { "path": "tsconfig.test.json" }, 7 | { "path": "tsconfig.examples.json" } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /scratchpad/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "declaration": false, 6 | "declarationMap": false, 7 | "composite": false, 8 | "incremental": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.src.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "types": ["node"], 6 | "outDir": "build/src", 7 | "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", 8 | "rootDir": "src" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | *.tsbuildinfo 3 | node_modules/ 4 | .DS_Store 5 | tmp/ 6 | dist/ 7 | build/ 8 | scratchpad/* 9 | !scratchpad/tsconfig.json 10 | .direnv/ 11 | .idea/ 12 | .env 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "printWidth": 120, 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "semi": false, 7 | "singleQuote": false, 8 | "trailingComma": "none", 9 | "arrowParens": "always" 10 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.src.json", 3 | "compilerOptions": { 4 | "types": ["node"], 5 | "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", 6 | "outDir": "build/esm", 7 | "declarationDir": "build/dts", 8 | "stripInternal": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.examples.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": ["example"], 4 | "references": [{ "path": "tsconfig.src.json" }], 5 | "compilerOptions": { 6 | "tsBuildInfoFile": ".tsbuildinfo/examples.tsbuildinfo", 7 | "rootDir": "example", 8 | "noEmit": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": ["test"], 4 | "references": [ 5 | { "path": "tsconfig.src.json" } 6 | ], 7 | "compilerOptions": { 8 | "types": ["node"], 9 | "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", 10 | "rootDir": "test", 11 | "noEmit": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { 6 | "repo": "mcrovero/effect-nextjs", 7 | "access": "public" 8 | } 9 | ], 10 | "commit": false, 11 | "fixed": [], 12 | "linked": [], 13 | "access": "public", 14 | "baseBranch": "main", 15 | "updateInternalDependencies": "patch", 16 | "ignore": [] 17 | } -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import { defineConfig } from "vitest/config" 3 | 4 | export default defineConfig({ 5 | plugins: [], 6 | test: { 7 | setupFiles: [path.join(__dirname, "setupTests.ts")], 8 | include: ["./test/**/*.test.ts"], 9 | globals: true 10 | }, 11 | resolve: { 12 | alias: { 13 | "@template/basic/test": path.join(__dirname, "test"), 14 | "@template/basic": path.join(__dirname, "src") 15 | } 16 | } 17 | }) 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @since 0.30.0 3 | */ 4 | export * as Cache from "./Cache.js" 5 | 6 | /** 7 | * @since 0.30.0 8 | */ 9 | export * as Headers from "./Headers.js" 10 | 11 | /** 12 | * @since 0.30.0 13 | */ 14 | export * as Navigation from "./Navigation.js" 15 | 16 | /** 17 | * @since 0.5.0 18 | */ 19 | export * as Next from "./Next.js" 20 | 21 | /** 22 | * @since 0.5.0 23 | */ 24 | export * as NextMiddleware from "./NextMiddleware.js" 25 | 26 | /** 27 | * @since 0.30.0 28 | * @category params 29 | */ 30 | export * as Params from "./Params.js" 31 | -------------------------------------------------------------------------------- /test/Next.basic.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, it } from "@effect/vitest" 2 | import { Layer } from "effect" 3 | import * as Effect from "effect/Effect" 4 | import * as Next from "../src/Next.js" 5 | 6 | describe("Next basic", () => { 7 | it.effect("build without middleware runs handler and returns result", () => 8 | Effect.gen(function*() { 9 | const page = Next.make("Basic", Layer.empty) 10 | 11 | const result = yield* Effect.promise(() => page.build(() => Effect.succeed(42 as const))()) 12 | assert.strictEqual(result, 42) 13 | })) 14 | }) 15 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup 2 | description: Perform standard setup and install dependencies using pnpm. 3 | inputs: 4 | node-version: 5 | description: The version of Node.js to install 6 | required: true 7 | default: 20.16.0 8 | 9 | runs: 10 | using: composite 11 | steps: 12 | - name: Install pnpm 13 | uses: pnpm/action-setup@v3 14 | - name: Install node 15 | uses: actions/setup-node@v4 16 | with: 17 | cache: pnpm 18 | node-version: ${{ inputs.node-version }} 19 | - name: Install dependencies 20 | shell: bash 21 | run: pnpm install 22 | -------------------------------------------------------------------------------- /patches/babel-plugin-annotate-pure-calls@0.4.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/lib/index.js b/lib/index.js 2 | index 2182884e21874ebb37261e2375eec08ad956fc9a..ef5630199121c2830756e00c7cc48cf1078c8207 100644 3 | --- a/lib/index.js 4 | +++ b/lib/index.js 5 | @@ -78,7 +78,7 @@ const isInAssignmentContext = path => { 6 | 7 | parentPath = _ref.parentPath; 8 | 9 | - if (parentPath.isVariableDeclaration() || parentPath.isAssignmentExpression()) { 10 | + if (parentPath.isVariableDeclaration() || parentPath.isAssignmentExpression() || parentPath.isClassDeclaration()) { 11 | return true; 12 | } 13 | } while (parentPath !== statement); 14 | -------------------------------------------------------------------------------- /example/Params.ts: -------------------------------------------------------------------------------- 1 | import { Layer, Schema } from "effect" 2 | import * as Effect from "effect/Effect" 3 | import { decodeParamsUnknown } from "src/Params.js" 4 | import * as Next from "../src/Next.js" 5 | 6 | const BasePage = Next.make("Home", Layer.empty) 7 | 8 | const ParamsSchema = Schema.Struct({ id: Schema.String }) 9 | 10 | const HomePage = Effect.fn("HomePage")(function*(props: { params: Promise> }) { 11 | const params = yield* Effect.orDie(decodeParamsUnknown(ParamsSchema)(props.params)) 12 | return `Hello ${params.id}!` 13 | }) 14 | 15 | export default BasePage 16 | .build( 17 | HomePage 18 | ) 19 | -------------------------------------------------------------------------------- /.github/workflows/snapshot.yml: -------------------------------------------------------------------------------- 1 | name: Snapshot 2 | 3 | on: 4 | pull_request: 5 | branches: [main, next-minor, next-major] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | snapshot: 13 | name: Snapshot 14 | if: github.repository_owner == 'mcrovero' 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 10 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Install dependencies 20 | uses: ./.github/actions/setup 21 | - name: Build package 22 | run: pnpm build 23 | - name: Create snapshot 24 | id: snapshot 25 | run: pnpx pkg-pr-new@0.0.24 publish --pnpm --comment=off 26 | -------------------------------------------------------------------------------- /example/Route.ts: -------------------------------------------------------------------------------- 1 | import { Layer } from "effect" 2 | import * as Context from "effect/Context" 3 | import * as Effect from "effect/Effect" 4 | import * as Next from "../src/Next.js" 5 | import * as NextMiddleware from "../src/NextMiddleware.js" 6 | 7 | export class ServerTime extends Context.Tag("ServerTime")() {} 8 | 9 | export class TimeMiddleware extends NextMiddleware.Tag()( 10 | "TimeMiddleware", 11 | { provides: ServerTime } 12 | ) {} 13 | 14 | const TimeLive = Layer.succeed( 15 | TimeMiddleware, 16 | TimeMiddleware.of(() => Effect.succeed({ now: Date.now() })) 17 | ) 18 | 19 | // In route.ts 20 | 21 | const _GET = Effect.fn("ServerTimeRoute")(function*() { 22 | const server = yield* ServerTime 23 | return { server } 24 | }) 25 | 26 | export const GET = Next.make("Base", TimeLive) 27 | .middleware(TimeMiddleware) 28 | .build(_GET) 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | permissions: {} 11 | 12 | jobs: 13 | release: 14 | if: github.repository_owner == 'mcrovero' 15 | name: Release 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 10 18 | permissions: 19 | contents: write 20 | id-token: write 21 | pull-requests: write 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Install dependencies 25 | uses: ./.github/actions/setup 26 | - name: Create Release Pull Request or Publish 27 | id: changesets 28 | uses: changesets/action@v1 29 | with: 30 | version: pnpm changeset-version 31 | publish: pnpm changeset-publish 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | -------------------------------------------------------------------------------- /src/Navigation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @since 0.30.0 3 | */ 4 | import { Effect } from "effect" 5 | import { notFound, permanentRedirect, redirect } from "next/navigation.js" 6 | 7 | /** 8 | * Redirect to another route. This never returns. 9 | * 10 | * @since 0.30.0 11 | * @category navigation 12 | */ 13 | export const Redirect = ( 14 | ...args: Parameters 15 | ): Effect.Effect => Effect.sync(() => redirect(...args)) 16 | 17 | /** 18 | * Permanent redirect (308). This never returns. 19 | * 20 | * @since 0.30.0 21 | * @category navigation 22 | */ 23 | export const PermanentRedirect = ( 24 | ...args: Parameters 25 | ): Effect.Effect => Effect.sync(() => permanentRedirect(...args)) 26 | 27 | /** 28 | * Render the 404 page. This never returns. 29 | * 30 | * @since 0.30.0 31 | * @category navigation 32 | */ 33 | export const NotFound: Effect.Effect = Effect.sync(() => notFound()) 34 | -------------------------------------------------------------------------------- /example/ServerComponent.ts: -------------------------------------------------------------------------------- 1 | import { Layer } from "effect" 2 | import * as Context from "effect/Context" 3 | import * as Effect from "effect/Effect" 4 | import * as Next from "../src/Next.js" 5 | import * as NextMiddleware from "../src/NextMiddleware.js" 6 | 7 | export class ServerTime extends Context.Tag("ServerTime")() {} 8 | 9 | export class TimeMiddleware extends NextMiddleware.Tag()( 10 | "TimeMiddleware", 11 | { provides: ServerTime } 12 | ) {} 13 | 14 | const TimeLive = Layer.succeed( 15 | TimeMiddleware, 16 | TimeMiddleware.of(() => Effect.succeed({ now: Date.now() })) 17 | ) 18 | 19 | // In serverComponent.tsx 20 | 21 | const _ServerComponent = Effect.fn("ServerComponent")(function*({ time }: { time: { now: number } }) { 22 | const server = yield* ServerTime 23 | return { time: { ...time, now: server.now + 1000 } } 24 | }) 25 | 26 | export default Next.make("Base", TimeLive) 27 | .middleware(TimeMiddleware) 28 | .build(_ServerComponent) 29 | -------------------------------------------------------------------------------- /src/Params.ts: -------------------------------------------------------------------------------- 1 | import { Effect, Schema } from "effect" 2 | 3 | type NextBaseParams = Promise< 4 | Record | undefined> 5 | > 6 | 7 | /** 8 | * @since 0.30.0 9 | * @category params 10 | */ 11 | export const decodeParamsUnknown = (schema: Schema.Schema) => (params: P) => 12 | Effect.promise(() => params).pipe( 13 | Effect.flatMap(Schema.decodeUnknown(schema)) 14 | ) 15 | 16 | /** 17 | * @since 0.30.0 18 | * @category params 19 | */ 20 | export const decodeSearchParamsUnknown = 21 | (schema: Schema.Schema) => (searchParams: P) => 22 | Effect.promise(() => searchParams).pipe( 23 | Effect.flatMap(Schema.decodeUnknown(schema)) 24 | ) 25 | 26 | /** 27 | * @since 0.30.0 28 | * @category params 29 | */ 30 | export const decodeParams = (schema: Schema.Schema) => (params: Promise

) => 31 | Effect.promise(() => params).pipe( 32 | Effect.flatMap(Schema.decode(schema)) 33 | ) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025-present Mattia Crovero 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/internal/middleware-chain.ts: -------------------------------------------------------------------------------- 1 | import type { Effect } from "effect/Effect" 2 | import * as Effect_ from "effect/Effect" 3 | import type * as NextMiddleware from "../NextMiddleware.js" 4 | 5 | /** 6 | * @since 0.5.0 7 | * @category utils 8 | */ 9 | export const createMiddlewareChain = ( 10 | tags: ReadonlyArray, 11 | resolve: (tag: NextMiddleware.TagClassAny) => any, 12 | base: Effect, 13 | options: { props: unknown } 14 | ): Effect => { 15 | const buildChain = (index: number): Effect => { 16 | if (index >= tags.length) { 17 | return base 18 | } 19 | const tag = tags[index] 20 | const middleware = resolve(tag) 21 | const tail = buildChain(index + 1) 22 | if (tag.wrap) { 23 | return middleware({ ...options, next: tail }) 24 | } 25 | return tag.provides !== undefined 26 | ? Effect_.provideServiceEffect( 27 | tail, 28 | tag.provides as any, 29 | middleware(options) as any 30 | ) 31 | : Effect_.zipRight( 32 | middleware(options) as any, 33 | tail 34 | ) 35 | } 36 | return buildChain(0) 37 | } 38 | -------------------------------------------------------------------------------- /test/Next.wrap.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "@effect/vitest" 2 | import { assertEquals } from "@effect/vitest/utils" 3 | import * as Context from "effect/Context" 4 | import * as Effect from "effect/Effect" 5 | import * as Layer from "effect/Layer" 6 | import * as Schema from "effect/Schema" 7 | import * as Next from "../src/Next.js" 8 | import * as NextMiddleware from "../src/NextMiddleware.js" 9 | 10 | describe("Next wrap middleware", () => { 11 | it.effect("can override return value", () => 12 | Effect.gen(function*() { 13 | class Dummy extends Context.Tag("Dummy")() {} 14 | 15 | class Wrap extends NextMiddleware.Tag()("Wrap", { 16 | wrap: true, 17 | catches: Schema.Never, 18 | returns: Schema.String 19 | }) {} 20 | 21 | const WrapLive: Layer.Layer = Layer.succeed( 22 | Wrap, 23 | ({ next }) => Effect.as(next, "overridden") 24 | ) 25 | 26 | const app = Next.make("Base", Layer.mergeAll(Layer.succeed(Dummy, { id: "1" }), WrapLive)) 27 | const page = app.middleware(Wrap) 28 | 29 | const res = yield* Effect.promise(() => page.build(() => Effect.succeed("original" as const))()) 30 | 31 | assertEquals(res, "overridden") 32 | })) 33 | }) 34 | -------------------------------------------------------------------------------- /example/Action.ts: -------------------------------------------------------------------------------- 1 | import { Layer, Schema } from "effect" 2 | import * as Context from "effect/Context" 3 | import * as Effect from "effect/Effect" 4 | import { Next } from "src/index.js" 5 | import * as NextMiddleware from "../src/NextMiddleware.js" 6 | 7 | // A simple context tag for the current user 8 | export class CurrentUser extends Context.Tag("CurrentUser")() {} 9 | 10 | // Non-wrapped middleware: runs before and provides a service 11 | export class AuthMiddleware extends NextMiddleware.Tag()( 12 | "AuthMiddleware", 13 | { 14 | provides: CurrentUser, 15 | failure: Schema.String 16 | } 17 | ) {} 18 | 19 | // Implementation for non-wrapped middleware: compute value to provide 20 | const AuthLive = Layer.succeed( 21 | AuthMiddleware, 22 | AuthMiddleware.of(() => Effect.succeed({ id: "123", name: "other" })) 23 | ) 24 | 25 | // In action.ts 26 | 27 | const Action = Effect.fn("Action")(function*(input: { test: string }) { 28 | const user = yield* CurrentUser 29 | return { user, parsed: input.test } 30 | }) 31 | 32 | // The async here is important to satisfy Next.js's requirement for server actions 33 | export const action = Next.make("Base", AuthLive) 34 | .middleware(AuthMiddleware).build( 35 | Action 36 | ) 37 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "exactOptionalPropertyTypes": true, 5 | "moduleDetection": "force", 6 | "composite": true, 7 | "downlevelIteration": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": false, 10 | "declaration": true, 11 | "skipLibCheck": true, 12 | "emitDecoratorMetadata": true, 13 | "experimentalDecorators": true, 14 | "moduleResolution": "NodeNext", 15 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 16 | "types": [], 17 | "isolatedModules": true, 18 | "sourceMap": true, 19 | "declarationMap": true, 20 | "noImplicitReturns": false, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": false, 23 | "noFallthroughCasesInSwitch": true, 24 | "noEmitOnError": false, 25 | "noErrorTruncation": false, 26 | "allowJs": false, 27 | "checkJs": false, 28 | "forceConsistentCasingInFileNames": true, 29 | "noImplicitAny": true, 30 | "noImplicitThis": true, 31 | "noUncheckedIndexedAccess": false, 32 | "strictNullChecks": true, 33 | "baseUrl": ".", 34 | "target": "ES2022", 35 | "module": "NodeNext", 36 | "incremental": true, 37 | "removeComments": false, 38 | "plugins": [{ "name": "@effect/language-service" }] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @since 0.30.0 3 | */ 4 | import { Effect } from "effect" 5 | import * as Context_ from "effect/Context" 6 | import { revalidatePath, revalidateTag } from "next/cache.js" 7 | import { ContextWrapperService } from "./internal/async-context.js" 8 | 9 | /** 10 | * Revalidate a specific path. 11 | * 12 | * @since 0.30.0 13 | * @category cache 14 | */ 15 | export const RevalidatePath = ( 16 | ...args: Parameters 17 | ): Effect.Effect => 18 | Effect.flatMap( 19 | Effect.context(), 20 | (context) => { 21 | const wrapWithContext = Context_.unsafeGet(context, ContextWrapperService) 22 | const wrappedFn = wrapWithContext(revalidatePath) 23 | return Effect.sync(() => wrappedFn(...args)) 24 | } 25 | ) 26 | 27 | /** 28 | * Revalidate a cache tag. 29 | * 30 | * @since 0.30.0 31 | * @category cache 32 | */ 33 | export const RevalidateTag = ( 34 | ...args: Parameters 35 | ): Effect.Effect => 36 | Effect.flatMap( 37 | Effect.context(), 38 | (context) => { 39 | const wrapWithContext = Context_.unsafeGet(context, ContextWrapperService) 40 | const wrappedFn = wrapWithContext(revalidateTag) 41 | return Effect.sync(() => wrappedFn(...args)) 42 | } 43 | ) 44 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: [main] 7 | push: 8 | branches: [main] 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | build: 19 | name: Build 20 | runs-on: ubuntu-latest 21 | timeout-minutes: 10 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Install dependencies 25 | uses: ./.github/actions/setup 26 | - run: pnpm codegen 27 | - name: Check source state 28 | run: git add src && git diff-index --cached HEAD --exit-code src 29 | 30 | types: 31 | name: Types 32 | runs-on: ubuntu-latest 33 | timeout-minutes: 10 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Install dependencies 37 | uses: ./.github/actions/setup 38 | - run: pnpm check 39 | 40 | lint: 41 | name: Lint 42 | runs-on: ubuntu-latest 43 | timeout-minutes: 10 44 | steps: 45 | - uses: actions/checkout@v4 46 | - name: Install dependencies 47 | uses: ./.github/actions/setup 48 | - run: pnpm lint 49 | 50 | test: 51 | name: Test 52 | runs-on: ubuntu-latest 53 | timeout-minutes: 10 54 | steps: 55 | - uses: actions/checkout@v4 56 | - name: Install dependencies 57 | uses: ./.github/actions/setup 58 | - run: pnpm test 59 | -------------------------------------------------------------------------------- /test/Middleware.catch.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "@effect/vitest" 2 | import { strictEqual } from "@effect/vitest/utils" 3 | import * as Effect from "effect/Effect" 4 | import * as Layer from "effect/Layer" 5 | import * as Schema from "effect/Schema" 6 | import * as Next from "../src/Next.js" 7 | import * as NextMiddleware from "../src/NextMiddleware.js" 8 | 9 | describe("Middleware catches", () => { 10 | it.effect("wrapped middleware catches handler failure and returns the error (page)", () => 11 | Effect.gen(function*() { 12 | class Wrapped extends NextMiddleware.Tag()("Wrapped", { wrap: true, catches: Schema.String }) {} 13 | 14 | // Use Layer.succeed with TagClass.of to avoid type issues for tests 15 | const WrappedLive: Layer.Layer = Layer.succeed( 16 | Wrapped, 17 | Wrapped.of(({ next }) => 18 | Effect.gen(function*() { 19 | const result = yield* next.pipe( 20 | Effect.catchAll((error) => Effect.succeed("Catched: " + error)) 21 | ) 22 | return result 23 | }) 24 | ) 25 | ) 26 | 27 | const combined = Layer.mergeAll(WrappedLive) 28 | const page = Next.make("Base", combined) 29 | .middleware(Wrapped) 30 | 31 | const result = yield* Effect.promise(() => 32 | page.build(() => 33 | Effect.gen(function*() { 34 | return yield* Effect.fail("boom") 35 | }) 36 | )() 37 | ) 38 | 39 | strictEqual(result, "Catched: boom") 40 | })) 41 | }) 42 | -------------------------------------------------------------------------------- /test/Middlewares.ensure-execution.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@effect/vitest" 2 | import * as Context from "effect/Context" 3 | import * as Effect from "effect/Effect" 4 | import * as Layer from "effect/Layer" 5 | import * as Schema from "effect/Schema" 6 | import * as Next from "../src/Next.js" 7 | import * as NextMiddleware from "../src/NextMiddleware.js" 8 | 9 | describe("Next", () => { 10 | class Obj extends Context.Tag("Obj")() {} 11 | 12 | class MiddlewareFast extends NextMiddleware.Tag()( 13 | "MiddlewareFast", 14 | { provides: Obj, failure: Schema.String } 15 | ) {} 16 | 17 | let executed = 0 18 | 19 | const MiddlewareFastLive: Layer.Layer = Layer.succeed( 20 | MiddlewareFast, 21 | MiddlewareFast.of(() => 22 | Effect.log("MiddlewareFast").pipe( 23 | Effect.andThen(() => 24 | Effect.sync(() => { 25 | executed += 1 26 | }) 27 | ), 28 | Effect.tap(() => Effect.log("MiddlewareFast done")), 29 | Effect.as({ id: "1", name: "Object1" }) 30 | ) 31 | ) 32 | ) 33 | 34 | it.effect("The middleware effect should be executed even if the handler does not yield it", () => 35 | Effect.gen(function*() { 36 | const combined = Layer.mergeAll(MiddlewareFastLive) 37 | const pageFast = Next.make("Base", combined) 38 | .middleware(MiddlewareFast) 39 | .build(() => Effect.succeed("ok")) 40 | 41 | yield* Effect.promise(() => pageFast()) 42 | yield* Effect.sync(() => { 43 | expect(executed).toBe(1) 44 | }) 45 | })) 46 | }) 47 | -------------------------------------------------------------------------------- /src/internal/executor.ts: -------------------------------------------------------------------------------- 1 | import { Cause, Chunk, Effect, Exit } from "effect" 2 | import type * as ManagedRuntime from "effect/ManagedRuntime" 3 | import { unstable_rethrow } from "next/dist/client/components/unstable-rethrow.server.js" 4 | import { workAsyncStorage } from "next/dist/server/app-render/work-async-storage.external.js" 5 | import { workUnitAsyncStorage } from "next/dist/server/app-render/work-unit-async-storage.external.js" 6 | import * as AsyncContext from "./async-context.js" 7 | 8 | /** 9 | * @since 0.5.0 10 | * @category utils 11 | */ 12 | export const executeWithRuntime = async ( 13 | runtime: ManagedRuntime.ManagedRuntime | undefined, 14 | effect: Effect.Effect 15 | ): Promise => { 16 | let effect_ = effect as Effect.Effect 17 | 18 | const asyncStorageDeps: AsyncContext.AsyncStorageDeps = { 19 | workAsyncStorage, 20 | workUnitAsyncStorage 21 | } 22 | const capturedContext = AsyncContext.captureContext(asyncStorageDeps) 23 | const wrapWithContext = AsyncContext.createContextWrapper(capturedContext, asyncStorageDeps) 24 | 25 | effect_ = effect_.pipe(Effect.provideService(AsyncContext.ContextWrapperService, wrapWithContext)) 26 | 27 | const result = runtime 28 | ? await runtime.runPromiseExit(effect_) 29 | : await Effect.runPromiseExit(effect_) 30 | 31 | if (Exit.isFailure(result)) { 32 | const defects = Chunk.toArray(Cause.defects(result.cause)) 33 | if (defects.length === 1) { 34 | unstable_rethrow(defects[0]) 35 | } 36 | const errors = Cause.prettyErrors(result.cause) 37 | 38 | throw errors[0] 39 | } 40 | 41 | return result.value 42 | } 43 | -------------------------------------------------------------------------------- /src/Headers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @since 0.30.0 3 | */ 4 | import { Effect } from "effect" 5 | import * as Context_ from "effect/Context" 6 | import { cookies, draftMode, headers } from "next/headers.js" 7 | import { ContextWrapperService } from "./internal/async-context.js" 8 | 9 | /** 10 | * Access request cookies. 11 | * 12 | * @since 0.30.0 13 | * @category request 14 | */ 15 | export const Cookies: Effect.Effect>, never, never> = Effect.flatMap( 16 | Effect.context(), 17 | (context) => { 18 | const wrapWithContext = Context_.unsafeGet(context, ContextWrapperService) 19 | const wrappedFn = wrapWithContext(cookies) 20 | return Effect.promise(() => wrappedFn()) 21 | } 22 | ) 23 | 24 | /** 25 | * Access request headers. 26 | * 27 | * @since 0.30.0 28 | * @category request 29 | */ 30 | export const Headers: Effect.Effect>, never, never> = Effect.flatMap( 31 | Effect.context(), 32 | (context) => { 33 | const wrapWithContext = Context_.unsafeGet(context, ContextWrapperService) 34 | const wrappedFn = wrapWithContext(headers) 35 | return Effect.promise(() => wrappedFn()) 36 | } 37 | ) 38 | 39 | /** 40 | * Access draft mode helpers. 41 | * 42 | * @since 0.30.0 43 | * @category request 44 | */ 45 | export const DraftMode: Effect.Effect>, never, never> = Effect.flatMap( 46 | Effect.context(), 47 | (context) => { 48 | const wrapWithContext = Context_.unsafeGet(context, ContextWrapperService) 49 | const wrappedFn = wrapWithContext(draftMode) 50 | return Effect.promise(() => wrappedFn()) 51 | } 52 | ) 53 | -------------------------------------------------------------------------------- /example/Layout.ts: -------------------------------------------------------------------------------- 1 | import { Layer, Schema } from "effect" 2 | import * as Context from "effect/Context" 3 | import * as Effect from "effect/Effect" 4 | import { ParseError } from "effect/ParseResult" 5 | import * as Next from "../src/Next.js" 6 | import * as NextMiddleware from "../src/NextMiddleware.js" 7 | 8 | export class Theme extends Context.Tag("Theme")() {} 9 | 10 | export class ThemeMiddleware extends NextMiddleware.Tag()( 11 | "ThemeMiddleware", 12 | { provides: Theme, failure: Schema.String } 13 | ) {} 14 | 15 | const ThemeLive = Layer.succeed( 16 | ThemeMiddleware, 17 | ThemeMiddleware.of(() => Effect.succeed({ mode: "dark" })) 18 | ) 19 | 20 | export class CatchAll extends NextMiddleware.Tag()( 21 | "CatchAll", 22 | { 23 | catches: Schema.Union(Schema.String, Schema.instanceOf(ParseError)), 24 | wrap: true, 25 | returns: Schema.Struct({ success: Schema.Literal(false), error: Schema.String }) 26 | } 27 | ) {} 28 | 29 | const CatchAllLive = Layer.succeed( 30 | CatchAll, 31 | CatchAll.of(({ next }) => 32 | Effect.gen(function*() { 33 | return yield* next.pipe(Effect.catchAll((e) => Effect.succeed({ error: e }))) 34 | }) 35 | ) 36 | ) 37 | 38 | const app = Layer.mergeAll(CatchAllLive, ThemeLive) 39 | 40 | const BaseLayout = Next.make("Root", app) 41 | .middleware(ThemeMiddleware) 42 | .middleware(CatchAll) 43 | 44 | // In layout.tsx 45 | 46 | const RootLayout = Effect.fn("RootLayout")(function*({ children, params }) { 47 | const theme = yield* Theme 48 | return { theme, params, children } 49 | }) 50 | 51 | export default BaseLayout 52 | .build( 53 | RootLayout 54 | ) 55 | -------------------------------------------------------------------------------- /example/MiddlewareWithDep.ts: -------------------------------------------------------------------------------- 1 | import { Layer } from "effect" 2 | import * as Context from "effect/Context" 3 | import * as Effect from "effect/Effect" 4 | import * as Schema from "effect/Schema" 5 | import * as Next from "../src/Next.js" 6 | import * as NextMiddleware from "../src/NextMiddleware.js" 7 | 8 | export class Other extends Context.Tag("Other")() {} 9 | 10 | // A simple context tag for the current user 11 | export class CurrentUser extends Context.Tag("CurrentUser")() {} 12 | 13 | // Non-wrapped middleware: runs before and provides a service 14 | export class AuthMiddleware extends NextMiddleware.Tag()( 15 | "AuthMiddleware", 16 | { 17 | provides: CurrentUser, 18 | failure: Schema.String 19 | } 20 | ) {} 21 | 22 | // Implementation for non-wrapped middleware: compute value to provide 23 | const _AuthLive = Layer.effect( 24 | AuthMiddleware, 25 | Effect.gen(function*() { 26 | const other = yield* Other 27 | return AuthMiddleware.of(() => 28 | Effect.gen(function*() { 29 | return yield* Effect.succeed({ id: "123", name: other.name }) 30 | }) 31 | ) 32 | }) 33 | ) 34 | 35 | const ProdLive = Layer.mergeAll(_AuthLive.pipe(Layer.provide(Layer.succeed(Other, { id: "999", name: "Jane" })))) 36 | 37 | // In page.tsx 38 | 39 | const Page = ({ params }: { params: Promise<{ id: string }> }) => 40 | Effect.gen(function*() { 41 | const user = yield* CurrentUser 42 | yield* Effect.fail("error") 43 | return { user, params } 44 | }).pipe(Effect.catchAll((e) => Effect.succeed({ error: e }))) 45 | 46 | export default Next.make("Base", ProdLive) 47 | .middleware(AuthMiddleware) 48 | .build( 49 | Page 50 | ) 51 | -------------------------------------------------------------------------------- /example/Page.ts: -------------------------------------------------------------------------------- 1 | import { Layer, Schema } from "effect" 2 | import * as Context from "effect/Context" 3 | import * as Effect from "effect/Effect" 4 | import { ParseError } from "effect/ParseResult" 5 | import { decodeParamsUnknown } from "src/Params.js" 6 | import * as Next from "../src/Next.js" 7 | import * as NextMiddleware from "../src/NextMiddleware.js" 8 | 9 | export class CurrentUser extends Context.Tag("CurrentUser")() {} 10 | 11 | export class ProvideUser extends NextMiddleware.Tag()( 12 | "ProvideUser", 13 | { provides: CurrentUser, failure: Schema.String } 14 | ) {} 15 | 16 | const ProvideUserLive = Layer.succeed( 17 | ProvideUser, 18 | () => Effect.succeed({ id: "u-1", name: "Alice" }) 19 | ) 20 | 21 | export class CatchAll extends NextMiddleware.Tag()( 22 | "CatchAll", 23 | { 24 | catches: Schema.Union(Schema.String, Schema.instanceOf(ParseError)), 25 | wrap: true, 26 | returns: Schema.Struct({ success: Schema.Literal(false), error: Schema.String }) 27 | } 28 | ) {} 29 | 30 | const CatchAllLive = Layer.succeed( 31 | CatchAll, 32 | CatchAll.of(({ next }) => 33 | Effect.gen(function*() { 34 | return yield* next.pipe(Effect.catchAll((e) => Effect.succeed({ error: e }))) 35 | }) 36 | ) 37 | ) 38 | 39 | const app = Layer.mergeAll(CatchAllLive, ProvideUserLive) 40 | 41 | const BasePage = Next.make("Home", app) 42 | 43 | // In page.tsx 44 | 45 | const HomePage = Effect.fn("HomePage")(function*(props: { params: Promise> }) { 46 | const params = yield* decodeParamsUnknown(Schema.Struct({ id: Schema.String }))(props.params) 47 | return `Hello ${params.id}!` 48 | }) 49 | 50 | export default BasePage 51 | .middleware(ProvideUser) 52 | .middleware(CatchAll) 53 | .build( 54 | HomePage 55 | ) 56 | -------------------------------------------------------------------------------- /patches/@changesets__get-github-info@0.6.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/changesets-get-github-info.cjs.js b/dist/changesets-get-github-info.cjs.js 2 | index a74df59f8a5988f458a3476087399f5e6dfe4818..ce5e60ef9916eb0cb76ab1e9dd422abcad752bf6 100644 3 | --- a/dist/changesets-get-github-info.cjs.js 4 | +++ b/dist/changesets-get-github-info.cjs.js 5 | @@ -251,18 +251,13 @@ async function getInfo(request) { 6 | b = new Date(b.mergedAt); 7 | return a > b ? 1 : a < b ? -1 : 0; 8 | })[0] : null; 9 | - 10 | - if (associatedPullRequest) { 11 | - user = associatedPullRequest.author; 12 | - } 13 | - 14 | return { 15 | user: user ? user.login : null, 16 | pull: associatedPullRequest ? associatedPullRequest.number : null, 17 | links: { 18 | commit: `[\`${request.commit.slice(0, 7)}\`](${data.commitUrl})`, 19 | pull: associatedPullRequest ? `[#${associatedPullRequest.number}](${associatedPullRequest.url})` : null, 20 | - user: user ? `[@${user.login}](${user.url})` : null 21 | + user: user ? `@${user.login}` : null 22 | } 23 | }; 24 | } 25 | diff --git a/dist/changesets-get-github-info.esm.js b/dist/changesets-get-github-info.esm.js 26 | index 27e5c972ab1202ff16f5124b471f4bbcc46be2b5..3940a8fe86e10cb46d8ff6436dea1103b1839927 100644 27 | --- a/dist/changesets-get-github-info.esm.js 28 | +++ b/dist/changesets-get-github-info.esm.js 29 | @@ -242,18 +242,13 @@ async function getInfo(request) { 30 | b = new Date(b.mergedAt); 31 | return a > b ? 1 : a < b ? -1 : 0; 32 | })[0] : null; 33 | - 34 | - if (associatedPullRequest) { 35 | - user = associatedPullRequest.author; 36 | - } 37 | - 38 | return { 39 | user: user ? user.login : null, 40 | pull: associatedPullRequest ? associatedPullRequest.number : null, 41 | links: { 42 | commit: `[\`${request.commit.slice(0, 7)}\`](${data.commitUrl})`, 43 | pull: associatedPullRequest ? `[#${associatedPullRequest.number}](${associatedPullRequest.url})` : null, 44 | - user: user ? `[@${user.login}](${user.url})` : null 45 | + user: user ? `@${user.login}` : null 46 | } 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/internal/async-context.ts: -------------------------------------------------------------------------------- 1 | import * as Context_ from "effect/Context" 2 | import type { AsyncLocalStorage as AsyncLocalStorageType } from "node:async_hooks" 3 | 4 | export interface CapturedContext { 5 | readonly workStore: unknown 6 | readonly workUnitStore: unknown 7 | } 8 | 9 | export interface AsyncStorageDeps { 10 | readonly workAsyncStorage: AsyncLocalStorageType 11 | readonly workUnitAsyncStorage: AsyncLocalStorageType 12 | } 13 | 14 | /** 15 | * @since 0.5.0 16 | * @category utils 17 | */ 18 | export const captureContext = (deps: AsyncStorageDeps): CapturedContext => ({ 19 | workStore: deps.workAsyncStorage.getStore(), 20 | workUnitStore: deps.workUnitAsyncStorage.getStore() 21 | }) 22 | 23 | export const withRestoredContext = , R>( 24 | context: CapturedContext, 25 | deps: AsyncStorageDeps, 26 | fn: (...args: Args) => R 27 | ): (...args: Args) => R => { 28 | return (...args: Args): R => { 29 | const { workStore, workUnitStore } = context 30 | const { workAsyncStorage, workUnitAsyncStorage } = deps 31 | 32 | if (workStore !== undefined && workUnitStore !== undefined) { 33 | return workAsyncStorage.run(workStore, () => workUnitAsyncStorage.run(workUnitStore, () => fn(...args))) 34 | } 35 | 36 | if (workStore !== undefined) { 37 | return workAsyncStorage.run(workStore, () => fn(...args)) 38 | } 39 | 40 | return fn(...args) 41 | } 42 | } 43 | 44 | /** 45 | * @since 0.31.0 46 | * @category utils 47 | */ 48 | export const createContextWrapper = ( 49 | context: CapturedContext, 50 | deps: AsyncStorageDeps 51 | ) => { 52 | return , R>( 53 | fn: (...args: Args) => R 54 | ): (...args: Args) => R => { 55 | return withRestoredContext(context, deps, fn) 56 | } 57 | } 58 | 59 | export type ContextWrapper = ReturnType 60 | 61 | /** 62 | * @since 0.31.0 63 | * @category utils 64 | */ 65 | export class ContextWrapperService extends Context_.Tag("ContextWrapperService")< 66 | ContextWrapperService, 67 | ContextWrapper 68 | >() {} 69 | -------------------------------------------------------------------------------- /test/Middleware.provides-missing.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, it } from "@effect/vitest" 2 | import * as Context from "effect/Context" 3 | import * as Effect from "effect/Effect" 4 | import * as Layer from "effect/Layer" 5 | import * as Schema from "effect/Schema" 6 | import * as Next from "../src/Next.js" 7 | import * as NextMiddleware from "../src/NextMiddleware.js" 8 | 9 | describe("Middleware provides missing", () => { 10 | it.effect("accessing provided Tag without adding middleware fails", () => 11 | Effect.gen(function*() { 12 | class CurrentUser extends Context.Tag("CurrentUser")() {} 13 | 14 | class AuthMiddleware extends NextMiddleware.Tag()( 15 | "AuthMiddleware", 16 | { provides: CurrentUser, failure: Schema.String } 17 | ) {} 18 | 19 | const AuthLive: Layer.Layer = Layer.succeed( 20 | AuthMiddleware, 21 | AuthMiddleware.of(() => Effect.succeed({ id: "1", name: "Ada" })) 22 | ) 23 | 24 | // Provide the middleware implementation in the Layer, but DO NOT add it to the chain 25 | const page = Next.make("Base", Layer.mergeAll(AuthLive)) 26 | 27 | const either = yield* Effect.tryPromise({ 28 | try: () => 29 | page.build(() => 30 | // @ts-expect-error accessing CurrentUser without adding AuthMiddleware must be a type error 31 | Effect.gen(function*() { 32 | // Attempt to access the service that would be provided by the middleware 33 | const user = yield* CurrentUser 34 | return user.name 35 | }) 36 | )(), 37 | catch: (e) => e as Error 38 | }).pipe(Effect.either) 39 | 40 | if (either._tag === "Right") { 41 | assert.fail("Expected missing service error, got success") 42 | } else { 43 | // Message should indicate the missing service; check for tag key 44 | const msg = String(either.left) 45 | assert.ok(/CurrentUser/.test(msg), `Expected error mentioning CurrentUser, got: ${msg}`) 46 | } 47 | })) 48 | }) 49 | -------------------------------------------------------------------------------- /example/Middleware.ts: -------------------------------------------------------------------------------- 1 | import { Layer } from "effect" 2 | import * as Context from "effect/Context" 3 | import * as Effect from "effect/Effect" 4 | import * as Schema from "effect/Schema" 5 | import * as Next from "../src/Next.js" 6 | import * as NextMiddleware from "../src/NextMiddleware.js" 7 | 8 | // A simple context tag for the current user 9 | export class CurrentUser extends Context.Tag("CurrentUser")() {} 10 | 11 | // Wrapped middleware: can run before/after and provide a service 12 | export class WrappedMiddleware extends NextMiddleware.Tag()( 13 | "WrappedMiddleware", 14 | { 15 | provides: CurrentUser, 16 | failure: Schema.String, 17 | wrap: true 18 | } 19 | ) {} 20 | 21 | // Non-wrapped middleware: runs before and provides a service 22 | export class NotWrappedMiddleware extends NextMiddleware.Tag()( 23 | "NotWrappedMiddleware", 24 | { 25 | provides: CurrentUser, 26 | failure: Schema.String 27 | } 28 | ) {} 29 | 30 | // Implementation for wrapped middleware: decide when to run next and inject value 31 | const _WrappedLive = Layer.succeed( 32 | WrappedMiddleware, 33 | WrappedMiddleware.of(({ next }) => 34 | Effect.gen(function*() { 35 | return yield* Effect.provideService(next, CurrentUser, { id: "123", name: "other" }) 36 | }) 37 | ) 38 | ) 39 | 40 | // Implementation for non-wrapped middleware: compute value to provide 41 | const _NotWrappedLive = Layer.succeed( 42 | NotWrappedMiddleware, 43 | NotWrappedMiddleware.of(() => Effect.succeed({ id: "123", name: "other" })) 44 | ) 45 | 46 | const ProdLive = Layer.mergeAll(_WrappedLive, _NotWrappedLive) 47 | 48 | // In page.tsx 49 | 50 | const Page = ({ params }: { params: Promise<{ id: string }> }) => 51 | Effect.gen(function*() { 52 | const user = yield* CurrentUser 53 | yield* Effect.fail("error") 54 | return { user, params } 55 | }).pipe(Effect.catchAll((e) => Effect.succeed({ error: e }))) 56 | 57 | export default Next.make("Base", ProdLive) 58 | .middleware(WrappedMiddleware) 59 | .middleware(NotWrappedMiddleware) 60 | .build( 61 | Page 62 | ) 63 | -------------------------------------------------------------------------------- /test/Middlewares.timing.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "@effect/vitest" 2 | import { deepStrictEqual } from "@effect/vitest/utils" 3 | import * as Context from "effect/Context" 4 | import * as Effect from "effect/Effect" 5 | import * as Layer from "effect/Layer" 6 | import * as Schema from "effect/Schema" 7 | import * as Next from "../src/Next.js" 8 | import * as NextMiddleware from "../src/NextMiddleware.js" 9 | 10 | describe("Next", () => { 11 | class Obj extends Context.Tag("Obj")() {} 12 | 13 | class MiddlewareFast extends NextMiddleware.Tag()( 14 | "MiddlewareFast", 15 | { provides: Obj, failure: Schema.String } 16 | ) {} 17 | class MiddlewareSlow extends NextMiddleware.Tag()( 18 | "MiddlewareSlow", 19 | { provides: Obj, failure: Schema.String } 20 | ) {} 21 | 22 | const MiddlewareFastLive: Layer.Layer = Layer.succeed( 23 | MiddlewareFast, 24 | MiddlewareFast.of(() => 25 | Effect.log("MiddlewareFast").pipe( 26 | Effect.andThen(() => Effect.sleep(1000)), 27 | Effect.as({ id: "1", name: "Object1" }), 28 | Effect.tap(() => Effect.log("MiddlewareFast done")) 29 | ) 30 | ) 31 | ) 32 | const MiddlewareSlowLive: Layer.Layer = Layer.succeed( 33 | MiddlewareSlow, 34 | MiddlewareSlow.of(() => 35 | Effect.log("MiddlewareSlow").pipe( 36 | Effect.andThen(() => Effect.sleep(3000)), 37 | Effect.as({ id: "2", name: "Object2" }), 38 | Effect.tap(() => Effect.log("MiddlewareSlow done")) 39 | ) 40 | ) 41 | ) 42 | 43 | it.effect("The provided service implementation at request time should be isolated", () => 44 | Effect.gen(function*() { 45 | const combined = Layer.mergeAll(MiddlewareFastLive, MiddlewareSlowLive) 46 | const pageSlow = Next.make("Base", combined) 47 | .middleware(MiddlewareSlow).build(() => Obj) 48 | 49 | const pageFast = Next.make("Base", combined) 50 | .middleware(MiddlewareFast).build(() => Obj) 51 | 52 | const results = yield* Effect.all([ 53 | Effect.promise(() => pageSlow()), 54 | Effect.promise(() => pageFast()) 55 | ], { concurrency: "unbounded" }) 56 | 57 | deepStrictEqual(results[0], { id: "2", name: "Object2" }) 58 | deepStrictEqual(results[1], { id: "1", name: "Object1" }) 59 | })) 60 | }) 61 | -------------------------------------------------------------------------------- /test/Next.defect.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, it } from "@effect/vitest" 2 | import * as Context from "effect/Context" 3 | import * as Effect from "effect/Effect" 4 | import * as Layer from "effect/Layer" 5 | import { vi } from "vitest" 6 | import * as Next from "../src/Next.js" 7 | import * as NextMiddleware from "../src/NextMiddleware.js" 8 | 9 | describe("Next defects", () => { 10 | it.effect("logs die from handler", () => 11 | Effect.gen(function*() { 12 | class Dummy0 extends Context.Tag("Dummy0")() {} 13 | const page = Next.make("Base", Layer.succeed(Dummy0, {})) 14 | 15 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}) 16 | try { 17 | const result = yield* Effect.promise(() => 18 | page.build(() => 19 | Effect.die(new Error("boom-handler")).pipe( 20 | Effect.catchAllCause(Effect.logError), 21 | Effect.as("ok") 22 | ) 23 | )() 24 | ) 25 | 26 | assert.strictEqual(result, "ok") 27 | const output = logSpy.mock.calls.map((args) => args.join(" ")).join("\n") 28 | assert.ok(output.includes("level=ERROR")) 29 | assert.match(output, /boom-handler/) 30 | } finally { 31 | logSpy.mockRestore() 32 | } 33 | })) 34 | 35 | it.effect("logs die from middleware", () => 36 | Effect.gen(function*() { 37 | class Dummy extends Context.Tag("Dummy")() {} 38 | 39 | class DefectMiddleware extends NextMiddleware.Tag()( 40 | "DefectMiddleware" 41 | ) {} 42 | 43 | const DefectLive: Layer.Layer = Layer.succeed( 44 | DefectMiddleware, 45 | // Defer throwing to inside Effect to be caught/logged 46 | () => 47 | Effect.sync(() => { 48 | throw new Error("boom-middleware") 49 | }) 50 | ) 51 | 52 | const app = Layer.mergeAll(Layer.succeed(Dummy, {}), DefectLive) 53 | 54 | const page = Next.make("Base", app) 55 | .middleware(DefectMiddleware) 56 | 57 | const either = yield* Effect.tryPromise({ 58 | try: () => page.build(() => Effect.succeed("ok" as const))(), 59 | catch: (e) => e as Error 60 | }).pipe(Effect.either) 61 | 62 | if (either._tag === "Right") { 63 | assert.fail("Expected rejection, got success") 64 | } else { 65 | assert.match(either.left.message, /boom-middleware/) 66 | } 67 | })) 68 | }) 69 | -------------------------------------------------------------------------------- /test/Next.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "@effect/vitest" 2 | import { deepStrictEqual } from "@effect/vitest/utils" 3 | import * as Context from "effect/Context" 4 | import * as Effect from "effect/Effect" 5 | import * as Layer from "effect/Layer" 6 | import * as Schema from "effect/Schema" 7 | import * as Next from "../src/Next.js" 8 | import * as NextMiddleware from "../src/NextMiddleware.js" 9 | 10 | describe("Next", () => { 11 | class CurrentUser extends Context.Tag("CurrentUser")() {} 12 | class Other extends Context.Tag("Other")() {} 13 | 14 | class AuthMiddleware extends NextMiddleware.Tag()( 15 | "AuthMiddleware", 16 | { provides: CurrentUser, failure: Schema.String } 17 | ) {} 18 | class OtherMiddleware extends NextMiddleware.Tag()( 19 | "OtherMiddleware", 20 | { provides: Other, failure: Schema.String } 21 | ) {} 22 | 23 | const AuthLive: Layer.Layer = Layer.succeed( 24 | AuthMiddleware, 25 | AuthMiddleware.of(() => Effect.succeed({ id: "123", name: "John Doe" })) 26 | ) 27 | const OtherLive: Layer.Layer = Layer.succeed( 28 | OtherMiddleware, 29 | OtherMiddleware.of(() => Effect.succeed({ id: "456", name: "Jane Doe" })) 30 | ) 31 | 32 | it.effect("runs handler with provided services and params", () => 33 | Effect.gen(function*() { 34 | const combined = Layer.mergeAll(AuthLive, OtherLive) 35 | const page = Next.make("Base", combined) 36 | .middleware(AuthMiddleware) 37 | .middleware(OtherMiddleware) 38 | 39 | const result = yield* Effect.promise(() => 40 | page.build(( 41 | { params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ q: string }> } 42 | ) => 43 | Effect.gen(function*() { 44 | const user = yield* CurrentUser 45 | const other = yield* Other 46 | const awaitedParams = yield* Effect.promise(() => params) 47 | const awaitedSearchParams = yield* Effect.promise(() => searchParams) 48 | return { user, other, params: awaitedParams, searchParams: awaitedSearchParams } 49 | }).pipe(Effect.catchAll(() => Effect.succeed({ error: "error" }))) 50 | )({ params: Promise.resolve({ id: "p1" }), searchParams: Promise.resolve({ q: "hello" }) }) 51 | ) 52 | 53 | deepStrictEqual(result, { 54 | user: { id: "123", name: "John Doe" }, 55 | other: { id: "456", name: "Jane Doe" }, 56 | params: { id: "p1" }, 57 | searchParams: { q: "hello" } 58 | }) 59 | })) 60 | }) 61 | -------------------------------------------------------------------------------- /test/Middleware.failure-propagation.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, it } from "@effect/vitest" 2 | import * as Effect from "effect/Effect" 3 | import * as Layer from "effect/Layer" 4 | import * as Schema from "effect/Schema" 5 | import * as Next from "../src/Next.js" 6 | import * as NextMiddleware from "../src/NextMiddleware.js" 7 | 8 | describe("Middleware failure propagation", () => { 9 | it.effect("non-wrapped middleware failure is catchable by wrapped middleware", () => 10 | Effect.gen(function*() { 11 | // Non-wrapped middleware that fails with a string (typed by failure schema) 12 | class Failing extends NextMiddleware.Tag()("Failing", { 13 | failure: Schema.String 14 | }) {} 15 | 16 | const FailingLive: Layer.Layer = Layer.succeed( 17 | Failing, 18 | // Fail immediately in the middleware phase 19 | Failing.of(() => Effect.fail("mw-fail" as const)) 20 | ) 21 | 22 | // Wrapped middleware that catches string errors from `next` 23 | class Catcher extends NextMiddleware.Tag()("Catcher", { 24 | wrap: true, 25 | catches: Schema.String 26 | }) {} 27 | 28 | const CatcherLive: Layer.Layer = Layer.succeed( 29 | Catcher, 30 | Catcher.of(({ next }) => next.pipe(Effect.catchAll(() => Effect.succeed("recovered" as const)))) 31 | ) 32 | 33 | const app = Layer.mergeAll(FailingLive, CatcherLive) 34 | const page = Next.make("FailurePropagation", app) 35 | .middleware(Catcher) 36 | .middleware(Failing) 37 | 38 | const result = yield* Effect.promise(() => page.build(() => Effect.succeed("ok" as const))()) 39 | assert.strictEqual(result, "recovered") 40 | })) 41 | 42 | it.effect("non-wrapped middleware failure bubbles when not caught", () => 43 | Effect.gen(function*() { 44 | class Failing extends NextMiddleware.Tag()("Failing", { 45 | failure: Schema.String 46 | }) {} 47 | 48 | const FailingLive: Layer.Layer = Layer.succeed( 49 | Failing, 50 | Failing.of(() => Effect.fail("mw-fail" as const)) 51 | ) 52 | 53 | const page = Next.make("FailureBubble", FailingLive) 54 | .middleware(Failing) 55 | 56 | const either = yield* Effect.tryPromise({ 57 | try: () => page.build(() => Effect.succeed("ok" as const))(), 58 | catch: (e) => e as Error 59 | }).pipe(Effect.either) 60 | 61 | if (either._tag === "Right") { 62 | assert.fail("Expected failure from middleware, got success") 63 | } else { 64 | assert.match(String(either.left), /mw-fail/) 65 | } 66 | })) 67 | }) 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mcrovero/effect-nextjs", 3 | "version": "0.31.0", 4 | "type": "module", 5 | "packageManager": "pnpm@9.10.0", 6 | "license": "MIT", 7 | "description": "A library to work with Next.js in Effect", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/mcrovero/effect-nextjs" 11 | }, 12 | "publishConfig": { 13 | "access": "public", 14 | "directory": "dist" 15 | }, 16 | "scripts": { 17 | "codegen": "build-utils prepare-v2", 18 | "build": "pnpm build-esm && pnpm build-annotate && pnpm build-cjs && build-utils pack-v2", 19 | "build-esm": "tsc -b tsconfig.build.json", 20 | "build-cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", 21 | "build-annotate": "babel build/esm --plugins annotate-pure-calls --out-dir build/esm --source-maps", 22 | "check": "tsc -b tsconfig.json", 23 | "lint": "eslint \"**/{src,test,examples,scripts,dtslint}/**/*.{ts,mjs}\"", 24 | "lint-fix": "pnpm lint --fix", 25 | "test": "vitest", 26 | "coverage": "vitest --coverage", 27 | "changeset-version": "changeset version", 28 | "changeset-publish": "pnpm build && TEST_DIST= pnpm vitest && changeset publish" 29 | }, 30 | "peerDependencies": { 31 | "effect": "^3", 32 | "next": "^15 || ^16" 33 | }, 34 | "devDependencies": { 35 | "@babel/cli": "^7.24.8", 36 | "@babel/core": "^7.25.2", 37 | "@babel/plugin-transform-export-namespace-from": "^7.24.7", 38 | "@babel/plugin-transform-modules-commonjs": "^7.24.8", 39 | "@changesets/changelog-github": "^0.5.0", 40 | "@changesets/cli": "^2.27.8", 41 | "@effect/build-utils": "^0.7.7", 42 | "@effect/eslint-plugin": "^0.2.0", 43 | "@effect/language-service": "^0.1.0", 44 | "@effect/vitest": "latest", 45 | "@eslint/compat": "1.1.1", 46 | "@eslint/eslintrc": "3.1.0", 47 | "@eslint/js": "9.10.0", 48 | "@types/node": "^22.5.2", 49 | "@typescript-eslint/eslint-plugin": "^8.4.0", 50 | "@typescript-eslint/parser": "^8.4.0", 51 | "babel-plugin-annotate-pure-calls": "^0.4.0", 52 | "effect": "^3.0.0", 53 | "eslint": "^9.10.0", 54 | "eslint-import-resolver-typescript": "^3.6.3", 55 | "eslint-plugin-codegen": "^0.28.0", 56 | "eslint-plugin-import": "^2.30.0", 57 | "eslint-plugin-simple-import-sort": "^12.1.1", 58 | "eslint-plugin-sort-destructure-keys": "^2.0.0", 59 | "tsx": "^4.17.0", 60 | "typescript": "^5.6.2", 61 | "vitest": "^3.2.0" 62 | }, 63 | "effect": { 64 | "generateExports": { 65 | "include": [ 66 | "**/*.ts" 67 | ] 68 | }, 69 | "generateIndex": { 70 | "include": [ 71 | "**/*.ts" 72 | ] 73 | } 74 | }, 75 | "pnpm": { 76 | "patchedDependencies": { 77 | "@changesets/get-github-info@0.6.0": "patches/@changesets__get-github-info@0.6.0.patch", 78 | "babel-plugin-annotate-pure-calls@0.4.0": "patches/babel-plugin-annotate-pure-calls@0.4.0.patch" 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /test/Next.defect-propagation.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, it } from "@effect/vitest" 2 | import * as Effect from "effect/Effect" 3 | import * as Layer from "effect/Layer" 4 | import * as Schema from "effect/Schema" 5 | import { RedirectType } from "next/dist/client/components/redirect-error.js" 6 | import { getRedirectError } from "next/dist/client/components/redirect.js" 7 | import { notFound } from "next/navigation.js" 8 | import * as Next from "../src/Next.js" 9 | import * as NextMiddleware from "../src/NextMiddleware.js" 10 | 11 | describe("Wrapper defect propagation", () => { 12 | it.effect("rethrows Next notFound control-flow error (unchanged)", () => 13 | Effect.gen(function*() { 14 | class Catcher extends NextMiddleware.Tag()("Catcher", { 15 | wrap: true, 16 | // catches only typed failures, not defects 17 | catches: Schema.String 18 | }) {} 19 | 20 | const CatcherLive: Layer.Layer = Layer.succeed( 21 | Catcher, 22 | // Even if we try to catch failures from `next`, defects must escape untouched 23 | Catcher.of(({ next }) => next.pipe(Effect.catchAll(() => Effect.succeed("caught" as const)))) 24 | ) 25 | 26 | const page = Next.make("WrapperDefect", CatcherLive).middleware(Catcher) 27 | 28 | const either = yield* Effect.tryPromise({ 29 | try: () => 30 | page.build(() => 31 | Effect.sync(() => { 32 | notFound() 33 | }) 34 | )(), 35 | catch: (e) => e as Error 36 | }).pipe(Effect.either) 37 | 38 | if (either._tag === "Right") { 39 | assert.fail("Expected notFound error to escape as rejection") 40 | } else { 41 | // Must be the exact same instance that was thrown 42 | assert.match((either.left as Error).message, /NEXT_HTTP_ERROR_FALLBACK;404/) 43 | } 44 | })) 45 | 46 | it.effect("rethrows Next redirect control-flow error (unchanged)", () => 47 | Effect.gen(function*() { 48 | class Catcher extends NextMiddleware.Tag()("Catcher", { 49 | wrap: true, 50 | catches: Schema.String 51 | }) {} 52 | 53 | const CatcherLive: Layer.Layer = Layer.succeed( 54 | Catcher, 55 | Catcher.of(({ next }) => next.pipe(Effect.catchAll(() => Effect.succeed("caught" as const)))) 56 | ) 57 | 58 | const page = Next.make("WrapperDefectRedirect", CatcherLive).middleware(Catcher) 59 | 60 | // Create a redirect control-flow error instance without throwing (so we can assert identity) 61 | const redirectError = getRedirectError("/somewhere", RedirectType.replace) as Error 62 | 63 | const either = yield* Effect.tryPromise({ 64 | try: () => 65 | page.build(() => 66 | Effect.sync(() => { 67 | throw redirectError 68 | }) 69 | )(), 70 | catch: (e) => e as Error 71 | }).pipe(Effect.either) 72 | 73 | if (either._tag === "Right") { 74 | assert.fail("Expected redirect error to escape as rejection") 75 | } else { 76 | // Must be the exact same instance that was thrown 77 | assert.ok(either.left === redirectError) 78 | assert.match((either.left as Error).message, /NEXT_REDIRECT/) 79 | } 80 | })) 81 | }) 82 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { fixupPluginRules } from "@eslint/compat" 2 | import { FlatCompat } from "@eslint/eslintrc" 3 | import js from "@eslint/js" 4 | import tsParser from "@typescript-eslint/parser" 5 | import codegen from "eslint-plugin-codegen" 6 | import _import from "eslint-plugin-import" 7 | import simpleImportSort from "eslint-plugin-simple-import-sort" 8 | import sortDestructureKeys from "eslint-plugin-sort-destructure-keys" 9 | import path from "node:path" 10 | import { fileURLToPath } from "node:url" 11 | 12 | const __filename = fileURLToPath(import.meta.url) 13 | const __dirname = path.dirname(__filename) 14 | const compat = new FlatCompat({ 15 | baseDirectory: __dirname, 16 | recommendedConfig: js.configs.recommended, 17 | allConfig: js.configs.all 18 | }) 19 | 20 | export default [ 21 | { 22 | ignores: ["**/dist", "**/build", "**/docs", "**/*.md"] 23 | }, 24 | ...compat.extends( 25 | "eslint:recommended", 26 | "plugin:@typescript-eslint/eslint-recommended", 27 | "plugin:@typescript-eslint/recommended", 28 | "plugin:@effect/recommended" 29 | ), 30 | { 31 | plugins: { 32 | import: fixupPluginRules(_import), 33 | "sort-destructure-keys": sortDestructureKeys, 34 | "simple-import-sort": simpleImportSort, 35 | codegen 36 | }, 37 | 38 | languageOptions: { 39 | parser: tsParser, 40 | ecmaVersion: 2018, 41 | sourceType: "module" 42 | }, 43 | 44 | settings: { 45 | "import/parsers": { 46 | "@typescript-eslint/parser": [".ts", ".tsx"] 47 | }, 48 | 49 | "import/resolver": { 50 | typescript: { 51 | alwaysTryTypes: true 52 | } 53 | } 54 | }, 55 | 56 | rules: { 57 | "codegen/codegen": "error", 58 | "no-fallthrough": "off", 59 | "no-irregular-whitespace": "off", 60 | "object-shorthand": "error", 61 | "prefer-destructuring": "off", 62 | "sort-imports": "off", 63 | 64 | "no-restricted-syntax": ["error", { 65 | selector: "CallExpression[callee.property.name='push'] > SpreadElement.arguments", 66 | message: "Do not use spread arguments in Array.push" 67 | }], 68 | 69 | "no-unused-vars": "off", 70 | "prefer-rest-params": "off", 71 | "prefer-spread": "off", 72 | "import/first": "error", 73 | "import/newline-after-import": "error", 74 | "import/no-duplicates": "error", 75 | "import/no-unresolved": "off", 76 | "import/order": "off", 77 | "simple-import-sort/imports": "off", 78 | "sort-destructure-keys/sort-destructure-keys": "error", 79 | "deprecation/deprecation": "off", 80 | 81 | "@typescript-eslint/array-type": ["warn", { 82 | default: "generic", 83 | readonly: "generic" 84 | }], 85 | 86 | "@typescript-eslint/member-delimiter-style": 0, 87 | "@typescript-eslint/no-non-null-assertion": "off", 88 | "@typescript-eslint/ban-types": "off", 89 | "@typescript-eslint/no-explicit-any": "off", 90 | "@typescript-eslint/no-empty-interface": "off", 91 | "@typescript-eslint/consistent-type-imports": "warn", 92 | 93 | "@typescript-eslint/no-unused-vars": ["error", { 94 | argsIgnorePattern: "^_", 95 | varsIgnorePattern: "^_" 96 | }], 97 | 98 | "@typescript-eslint/ban-ts-comment": "off", 99 | "@typescript-eslint/camelcase": "off", 100 | "@typescript-eslint/explicit-function-return-type": "off", 101 | "@typescript-eslint/explicit-module-boundary-types": "off", 102 | "@typescript-eslint/interface-name-prefix": "off", 103 | "@typescript-eslint/no-array-constructor": "off", 104 | "@typescript-eslint/no-use-before-define": "off", 105 | "@typescript-eslint/no-namespace": "off", 106 | 107 | "@effect/dprint": ["error", { 108 | config: { 109 | indentWidth: 2, 110 | lineWidth: 120, 111 | semiColons: "asi", 112 | quoteStyle: "alwaysDouble", 113 | trailingCommas: "never", 114 | operatorPosition: "maintain", 115 | "arrowFunction.useParentheses": "force" 116 | } 117 | }] 118 | } 119 | } 120 | ] 121 | -------------------------------------------------------------------------------- /test/Cache.revalidation.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, it } from "@effect/vitest" 2 | import { Layer } from "effect" 3 | import * as Effect from "effect/Effect" 4 | import { vi } from "vitest" 5 | import * as Cache from "../src/Cache.js" 6 | import * as Next from "../src/Next.js" 7 | 8 | // Mock Next.js cache functions and AsyncLocalStorage 9 | const mockRevalidatePath = vi.fn() 10 | const mockRevalidateTag = vi.fn() 11 | 12 | // Mock workStore and workUnitStore 13 | const mockWorkStore = { 14 | route: "/test", 15 | incrementalCache: {}, 16 | pendingRevalidatedTags: [], 17 | pathWasRevalidated: false 18 | } 19 | 20 | const mockWorkUnitStore = { 21 | phase: "request" as const, 22 | type: "request" as const 23 | } 24 | 25 | vi.mock("next/cache.js", () => ({ 26 | revalidatePath: (...args: Array) => mockRevalidatePath(...args), 27 | revalidateTag: (...args: Array) => mockRevalidateTag(...args) 28 | })) 29 | 30 | vi.mock("next/dist/server/app-render/work-async-storage.external.js", () => ({ 31 | workAsyncStorage: { 32 | getStore: () => mockWorkStore, 33 | run: (store: any, fn: () => void) => fn() 34 | } 35 | })) 36 | 37 | vi.mock("next/dist/server/app-render/work-unit-async-storage.external.js", () => ({ 38 | workUnitAsyncStorage: { 39 | getStore: () => mockWorkUnitStore, 40 | run: (store: any, fn: () => void) => fn() 41 | } 42 | })) 43 | 44 | describe("Cache revalidation", () => { 45 | it.effect("revalidations execute immediately in order", () => 46 | Effect.gen(function*() { 47 | mockRevalidatePath.mockClear() 48 | mockRevalidateTag.mockClear() 49 | 50 | const callOrder: Array = [] 51 | 52 | const page = Next.make("CacheTest", Layer.empty) 53 | 54 | const handler = Effect.gen(function*() { 55 | callOrder.push("start") 56 | yield* Cache.RevalidatePath("/path1") 57 | callOrder.push("after-path1") 58 | yield* Cache.RevalidateTag("tag1") 59 | callOrder.push("after-tag1") 60 | yield* Cache.RevalidatePath("/path2") 61 | callOrder.push("after-path2") 62 | return "done" 63 | }) 64 | 65 | const result = yield* Effect.promise(() => page.build(() => handler)()) 66 | 67 | assert.strictEqual(result, "done") 68 | assert.deepStrictEqual(callOrder, [ 69 | "start", 70 | "after-path1", 71 | "after-tag1", 72 | "after-path2" 73 | ]) 74 | 75 | // Verify revalidations were called 76 | assert.strictEqual(mockRevalidatePath.mock.calls.length, 2) 77 | assert.strictEqual(mockRevalidateTag.mock.calls.length, 1) 78 | assert.deepStrictEqual(mockRevalidatePath.mock.calls[0], ["/path1"]) 79 | assert.deepStrictEqual(mockRevalidateTag.mock.calls[0], ["tag1"]) 80 | assert.deepStrictEqual(mockRevalidatePath.mock.calls[1], ["/path2"]) 81 | })) 82 | 83 | it.effect("revalidations don't execute if Effect fails before them", () => 84 | Effect.gen(function*() { 85 | mockRevalidatePath.mockClear() 86 | mockRevalidateTag.mockClear() 87 | 88 | const page = Next.make("CacheTestFail", Layer.empty) 89 | 90 | const handler = Effect.gen(function*() { 91 | yield* Cache.RevalidatePath("/should-execute") 92 | yield* Effect.fail(new Error("Expected failure")) 93 | yield* Cache.RevalidatePath("/should-not-execute") 94 | return "done" 95 | }) 96 | 97 | // Use tryPromise to handle the rejection 98 | const result = yield* Effect.tryPromise({ 99 | try: () => page.build(() => handler as Effect.Effect)(), 100 | catch: (error) => error 101 | }).pipe(Effect.flip) 102 | 103 | // Verify the error occurred 104 | assert.ok(result instanceof Error || typeof result === "object") 105 | 106 | // Verify only the first revalidation was called 107 | assert.strictEqual(mockRevalidatePath.mock.calls.length, 1) 108 | assert.deepStrictEqual(mockRevalidatePath.mock.calls[0], ["/should-execute"]) 109 | })) 110 | 111 | it.effect("revalidations with type parameter work correctly", () => 112 | Effect.gen(function*() { 113 | mockRevalidatePath.mockClear() 114 | 115 | const page = Next.make("CacheTestTypes", Layer.empty) 116 | 117 | const handler = Effect.gen(function*() { 118 | yield* Cache.RevalidatePath("/page-path", "page") 119 | yield* Cache.RevalidatePath("/layout-path", "layout") 120 | return "done" 121 | }) 122 | 123 | const result = yield* Effect.promise(() => page.build(() => handler)()) 124 | 125 | assert.strictEqual(result, "done") 126 | assert.strictEqual(mockRevalidatePath.mock.calls.length, 2) 127 | assert.deepStrictEqual(mockRevalidatePath.mock.calls[0], ["/page-path", "page"]) 128 | assert.deepStrictEqual(mockRevalidatePath.mock.calls[1], ["/layout-path", "layout"]) 129 | })) 130 | 131 | it.effect("multiple revalidations of same path execute in order", () => 132 | Effect.gen(function*() { 133 | mockRevalidatePath.mockClear() 134 | 135 | const page = Next.make("CacheTestDuplicates", Layer.empty) 136 | 137 | const handler = Effect.gen(function*() { 138 | yield* Cache.RevalidatePath("/same-path") 139 | yield* Cache.RevalidatePath("/same-path") 140 | yield* Cache.RevalidatePath("/same-path") 141 | return "done" 142 | }) 143 | 144 | const result = yield* Effect.promise(() => page.build(() => handler)()) 145 | 146 | assert.strictEqual(result, "done") 147 | // All three calls should execute (no deduplication) 148 | assert.strictEqual(mockRevalidatePath.mock.calls.length, 3) 149 | assert.deepStrictEqual(mockRevalidatePath.mock.calls[0], ["/same-path"]) 150 | assert.deepStrictEqual(mockRevalidatePath.mock.calls[1], ["/same-path"]) 151 | assert.deepStrictEqual(mockRevalidatePath.mock.calls[2], ["/same-path"]) 152 | })) 153 | 154 | it.effect("revalidations interleaved with other effects execute in correct order", () => 155 | Effect.gen(function*() { 156 | mockRevalidatePath.mockClear() 157 | mockRevalidateTag.mockClear() 158 | 159 | const executionOrder: Array = [] 160 | 161 | const page = Next.make("CacheTestInterleaved", Layer.empty) 162 | 163 | const handler = Effect.gen(function*() { 164 | executionOrder.push("step-1") 165 | 166 | yield* Cache.RevalidatePath("/path") 167 | executionOrder.push("step-2") 168 | 169 | yield* Effect.sync(() => {/* no-op */}) 170 | executionOrder.push("step-3") 171 | 172 | yield* Cache.RevalidateTag("tag") 173 | executionOrder.push("step-4") 174 | 175 | return "done" 176 | }) 177 | 178 | const result = yield* Effect.promise(() => page.build(() => handler)()) 179 | 180 | assert.strictEqual(result, "done") 181 | assert.deepStrictEqual(executionOrder, [ 182 | "step-1", 183 | "step-2", 184 | "step-3", 185 | "step-4" 186 | ]) 187 | 188 | assert.strictEqual(mockRevalidatePath.mock.calls.length, 1) 189 | assert.strictEqual(mockRevalidateTag.mock.calls.length, 1) 190 | })) 191 | }) 192 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @mcrovero/effect-nextjs 2 | 3 | ## 0.31.0 4 | 5 | ### Minor Changes 6 | 7 | - [#46](https://github.com/mcrovero/effect-nextjs/pull/46) [`86f98cd`](https://github.com/mcrovero/effect-nextjs/commit/86f98cdd8b264758e62a45f3403c276cd4e85228) Thanks @mcrovero! - Added support for AsyncLocalStorage from Next.js to properly capture and restore context for cache revalidation. The library now imports `workAsyncStorage` and `workUnitAsyncStorage` from Next.js internal modules and uses them to maintain context across async boundaries, ensuring proper cache revalidation behavior. 8 | 9 | ## 0.30.0 10 | 11 | ### Minor Changes 12 | 13 | - [#44](https://github.com/mcrovero/effect-nextjs/pull/44) [`66b4496`](https://github.com/mcrovero/effect-nextjs/commit/66b4496e3b12160025f1cc71b7d1e8bc30f287fc) Thanks @mcrovero! - Now Nextjs is a peer dependency to be able to use the unstable_throw and effect versions of the Nextjs control-flow redirect, notFound etc. 14 | Removed runtime registry and related options, you should now only pass stateless Services to Next. 15 | 16 | ## 0.21.0 17 | 18 | ### Minor Changes 19 | 20 | - [#42](https://github.com/mcrovero/effect-nextjs/pull/42) [`8af5aa5`](https://github.com/mcrovero/effect-nextjs/commit/8af5aa54944cc7857270c303cef50ae7e3ae110a) Thanks @mcrovero! - - feat: Add option to pass a `ManagedRuntime` using `Next.makeWithRuntime(tag, runtime)`, in addition to the existing `Next.make(tag, layer)`. 21 | - When a runtime is provided explicitly, it is used as-is and is not registered in the HMR runtime registry; lifecycle is user-managed. 22 | - breaking: Remove `NextMiddleware.layer` utility. 23 | 24 | ## 0.20.0 25 | 26 | ### Minor Changes 27 | 28 | - [#38](https://github.com/mcrovero/effect-nextjs/pull/38) [`b1860e7`](https://github.com/mcrovero/effect-nextjs/commit/b1860e77c6d849a6cd5f4729d9dc028864094835) Thanks @mcrovero! - ! Breaking: Deprecating NextAction and removed utils to decode params and search params 29 | 30 | ## 0.12.0 31 | 32 | ### Minor Changes 33 | 34 | - [#36](https://github.com/mcrovero/effect-nextjs/pull/36) [`d6d7633`](https://github.com/mcrovero/effect-nextjs/commit/d6d76333fa912eae137b4944fa1d4a1473c2d6fb) Thanks @mcrovero! - Added support for Next.js routes. Now Next accepts a list of arguments and middlewares receive props from calling component 35 | 36 | ## 0.11.0 37 | 38 | ### Minor Changes 39 | 40 | - [#34](https://github.com/mcrovero/effect-nextjs/pull/34) [`04322b2`](https://github.com/mcrovero/effect-nextjs/commit/04322b25390e1df73721b0de4e7a24fae5126b83) Thanks @mcrovero! - Moved to a single builder for Pages, Layouts and Server components 41 | 42 | ## 0.10.0 43 | 44 | ### Minor Changes 45 | 46 | - [#32](https://github.com/mcrovero/effect-nextjs/pull/32) [`e40f93c`](https://github.com/mcrovero/effect-nextjs/commit/e40f93ccad6143dd734ea3b4b620727e98db384e) Thanks @mcrovero! - This version changes the API to use the library, there is no longer a global Next.make(Layer) that exposes .page()/.layout()/.action()/.component() methods. You now need to use: NextPage.make("page_key", Layer), NextLayout.make("layout_key", Layer), etc. 47 | The keys must be unique across the same type of components. 48 | There are no more `.setParamsSchema(...)`, `.setSearchParamsSchema(...)`, and `.setInputSchema(...)`. 49 | You can now use the new helpers inside your handler: 50 | 51 | - `yield* Next.decodeParams(schema)(props)` 52 | - `yield* Next.decodeSearchParams(schema)(props)` 53 | 54 | The actions API has changed, there is no more .build() look at the examples for the new API but .run() waiting to unify the API with the other handlers. 55 | 56 | Read at the bottom of the README for more details for the decisions behind the new API. 57 | 58 | ## 0.6.0 59 | 60 | ### Minor Changes 61 | 62 | - [`024bdc0`](https://github.com/mcrovero/effect-nextjs/commit/024bdc03682591d527f2c104cc67f48819cbfd8d) Thanks @mcrovero! - Now uses ManagedRuntime to prevent layers from being provided multiple times 63 | 64 | ## 0.5.0 65 | 66 | ### Minor Changes 67 | 68 | - [`f64d06a`](https://github.com/mcrovero/effect-nextjs/commit/f64d06a9e34ef287c30501473bd2db2fad03b037) Thanks @mcrovero! - Added automatic trace spans and effect stacktrace 69 | 70 | ## 0.4.1 71 | 72 | ### Patch Changes 73 | 74 | - [`6f27463`](https://github.com/mcrovero/effect-nextjs/commit/6f27463e2ebf9e8a581e4a2fafa6ec7a20b11b3a) Thanks @mcrovero! - moved deps to peer dependencies 75 | 76 | ## 0.4.0 77 | 78 | ### Minor Changes 79 | 80 | - [`7b795a7`](https://github.com/mcrovero/effect-nextjs/commit/7b795a7367251477a76e42538ba172f9c8ebad62) Thanks @mcrovero! - Removed optional middlewares and added catches/returns in wrap middlewares 81 | 82 | ## 0.3.0 83 | 84 | ### Minor Changes 85 | 86 | - [`256f09a`](https://github.com/mcrovero/effect-nextjs/commit/256f09a4d7d5cd6d57faf30819016a1c172690ae) Thanks @mcrovero! - breaking: removed onError and improved error management 87 | 88 | ## 0.2.0 89 | 90 | ### Minor Changes 91 | 92 | - [#19](https://github.com/mcrovero/effect-nextjs/pull/19) [`4468531`](https://github.com/mcrovero/effect-nextjs/commit/4468531eeb5aeaea403d400bed0ac6f09b492b84) Thanks @mcrovero! - - Add props-aware overloads to `NextServerComponent.build` so components can accept typed props and return a callable with matching parameter types. 93 | - Forward `props` at runtime and preserve middleware chaining and error mapping. 94 | - Update `example/ServerComponent.ts` to demonstrate the new API and adjust `README.md` with usage notes and examples for both props and no-props cases. 95 | 96 | ## 0.1.4 97 | 98 | ### Patch Changes 99 | 100 | - [#15](https://github.com/mcrovero/effect-nextjs/pull/15) [`4d03690`](https://github.com/mcrovero/effect-nextjs/commit/4d03690e6a9918f15c7633cbde6c1d2548f84ed4) Thanks @mcrovero! - Fix encoded/decoded type actions 101 | 102 | ## 0.1.3 103 | 104 | ### Patch Changes 105 | 106 | - [`4a20402`](https://github.com/mcrovero/effect-nextjs/commit/4a20402088c3ca6cb44119f68bb07599f91a288d) Thanks @mcrovero! - Fixed symbol page 107 | 108 | ## 0.1.2 109 | 110 | ### Patch Changes 111 | 112 | - [`40be3b1`](https://github.com/mcrovero/effect-nextjs/commit/40be3b1edc6e0d621485c3efae6b4932024fefef) Thanks @mcrovero! - fix type searchparams and params 113 | 114 | ## 0.1.1 115 | 116 | ### Patch Changes 117 | 118 | - [`0a9f733`](https://github.com/mcrovero/effect-nextjs/commit/0a9f73343003f3f725a3c922b2bf3aceb165bb1f) Thanks @mcrovero! - Unify parameter handling across Layout, Page, and Middleware 119 | 120 | ## 0.1.0 121 | 122 | ### Minor Changes 123 | 124 | - [#10](https://github.com/mcrovero/effect-nextjs/pull/10) [`755ff4a`](https://github.com/mcrovero/effect-nextjs/commit/755ff4a73f1f5e44cf20ffd3802aee976ad60522) Thanks @mcrovero! - Now params and search params are passed as raw values and then it has been added parsedSearchParams and parsedParams that return effects 125 | 126 | ## 0.0.3 127 | 128 | ### Patch Changes 129 | 130 | - [`5a57ce4`](https://github.com/mcrovero/effect-nextjs/commit/5a57ce431f6abc6854428ebc6b5c6757f6fc65c5) Thanks @mcrovero! - Added github repository 131 | 132 | ## 0.0.2 133 | 134 | ### Patch Changes 135 | 136 | - [#7](https://github.com/mcrovero/effect-nextjs/pull/7) [`e72537e`](https://github.com/mcrovero/effect-nextjs/commit/e72537e0e2e3d0ebc0ebf61055aa3c703612a5dc) Thanks @mcrovero! - alpha version 137 | -------------------------------------------------------------------------------- /src/NextMiddleware.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @since 0.5.0 3 | */ 4 | import * as Context from "effect/Context" 5 | import type * as Effect from "effect/Effect" 6 | import * as Schema from "effect/Schema" 7 | import type { Mutable } from "effect/Types" 8 | 9 | /** 10 | * @since 0.5.0 11 | * @category type ids 12 | */ 13 | export const TypeId: unique symbol = Symbol.for("@mcrovero/effect-nextjs/Middleware") 14 | 15 | /** 16 | * @since 0.5.0 17 | * @category type ids 18 | */ 19 | export type TypeId = typeof TypeId 20 | 21 | type MiddlewareOptions = { 22 | props: unknown 23 | } 24 | 25 | /** 26 | * @since 0.5.0 27 | * @category models 28 | */ 29 | export interface NextMiddleware { 30 | (options: MiddlewareOptions): Effect.Effect 31 | } 32 | 33 | /** 34 | * @since 0.5.0 35 | * @category models 36 | */ 37 | export interface NextMiddlewareWrap { 38 | ( 39 | options: MiddlewareOptions & { readonly next: Effect.Effect } 40 | ): Effect.Effect 41 | } 42 | 43 | /** 44 | * @since 0.5.0 45 | * @category models 46 | */ 47 | export type TagClass = TagClass.Base< 48 | Self, 49 | Name, 50 | Options, 51 | TagClass.Wrap extends true 52 | ? NextMiddlewareWrap, TagClass.CatchesValue, R> 53 | : NextMiddleware, TagClass.FailureService, R> 54 | > 55 | 56 | /** 57 | * @since 0.5.0 58 | * @category models 59 | */ 60 | export declare namespace TagClass { 61 | /** 62 | * @since 0.5.0 63 | * @category models 64 | */ 65 | export type Provides = Options extends { 66 | readonly provides: Context.Tag 67 | } ? Context.Tag.Identifier 68 | : never 69 | 70 | /** 71 | * @since 0.5.0 72 | * @category models 73 | */ 74 | export type Service = Options extends { 75 | readonly provides: Context.Tag 76 | } ? Context.Tag.Service 77 | : void 78 | 79 | /** 80 | * @since 0.5.0 81 | * @category models 82 | */ 83 | export type FailureSchema = Options extends { 84 | readonly failure: Schema.Schema.All 85 | } ? Options["failure"] 86 | : typeof Schema.Never 87 | 88 | /** 89 | * @since 0.5.0 90 | * @category models 91 | */ 92 | export type Failure = Options extends { 93 | readonly failure: Schema.Schema 94 | } ? _A 95 | : never 96 | 97 | /** 98 | * @since 0.5.0 99 | * @category models 100 | */ 101 | export type FailureContext = Schema.Schema.Context> 102 | 103 | /** 104 | * @since 0.5.0 105 | * @category models 106 | */ 107 | export type FailureService = Failure 108 | 109 | /** 110 | * @since 0.5.0 111 | * @category models 112 | */ 113 | export type Wrap = Options extends { readonly wrap: true } ? true : false 114 | 115 | /** 116 | * @since 0.5.0 117 | * @category models 118 | */ 119 | export type CatchesSchema = Wrap extends true 120 | ? Options extends { readonly catches: Schema.Schema.All } ? Options["catches"] : typeof Schema.Never 121 | : typeof Schema.Never 122 | 123 | /** 124 | * @since 0.5.0 125 | * @category models 126 | */ 127 | export type CatchesValue = CatchesSchema extends Schema.Schema ? A : never 128 | 129 | /** 130 | * @since 0.5.0 131 | * @category models 132 | */ 133 | export type ReturnsSchema = Wrap extends true 134 | ? Options extends { readonly returns: Schema.Schema.All } ? Options["returns"] : typeof Schema.Never 135 | : typeof Schema.Never 136 | 137 | /** 138 | * @since 0.5.0 139 | * @category models 140 | */ 141 | export interface Base extends Context.Tag { 142 | new(_: never): Context.TagClassShape 143 | readonly [TypeId]: TypeId 144 | readonly failure: FailureSchema 145 | readonly catches: CatchesSchema 146 | readonly provides: Options extends { readonly provides: Context.Tag } ? Options["provides"] : undefined 147 | readonly wrap: Wrap 148 | readonly returns: ReturnsSchema 149 | } 150 | } 151 | 152 | /** 153 | * @since 0.5.0 154 | * @category models 155 | */ 156 | export interface TagClassAny extends Context.Tag { 157 | readonly [TypeId]: TypeId 158 | readonly provides?: Context.Tag | undefined 159 | readonly failure: Schema.Schema.All 160 | readonly catches: Schema.Schema.All 161 | readonly wrap: boolean 162 | readonly returns: Schema.Schema.All 163 | } 164 | 165 | /** 166 | * @since 0.5.0 167 | * @category models 168 | */ 169 | export interface TagClassAnyWithProps 170 | extends Context.Tag | NextMiddlewareWrap> 171 | { 172 | readonly [TypeId]: TypeId 173 | readonly provides?: Context.Tag | undefined 174 | readonly failure: Schema.Schema.All 175 | readonly catches: Schema.Schema.All 176 | readonly wrap: boolean 177 | readonly returns: Schema.Schema.All 178 | } 179 | 180 | /** 181 | * @since 0.5.0 182 | * @category tags 183 | */ 184 | export const Tag = (): < 185 | const Name extends string, 186 | const Options extends ( 187 | | { 188 | readonly wrap: true 189 | readonly failure?: Schema.Schema.All 190 | readonly provides?: Context.Tag 191 | readonly catches?: Schema.Schema.All 192 | readonly returns?: Schema.Schema.All 193 | } 194 | | { 195 | readonly wrap?: false 196 | readonly failure?: Schema.Schema.All 197 | readonly provides?: Context.Tag 198 | readonly catches?: undefined 199 | } 200 | ) 201 | >( 202 | id: Name, 203 | options?: Options | undefined 204 | ) => TagClass => 205 | ( 206 | id: string, 207 | options?: any 208 | ) => { 209 | const Err = globalThis.Error as any 210 | const limit = Err.stackTraceLimit 211 | Err.stackTraceLimit = 2 212 | const creationError = new Err() 213 | Err.stackTraceLimit = limit 214 | 215 | function TagClass() {} 216 | const TagClass_ = TagClass as any as Mutable 217 | Object.setPrototypeOf(TagClass, Object.getPrototypeOf(Context.GenericTag(id))) 218 | TagClass.key = id 219 | Object.defineProperty(TagClass, "stack", { 220 | get() { 221 | return creationError.stack 222 | } 223 | }) 224 | TagClass_[TypeId] = TypeId 225 | TagClass_.failure = options?.failure === undefined ? Schema.Never : options.failure 226 | ;(TagClass_ as any).catches = options && (options as any).wrap === true && (options as any).catches !== undefined 227 | ? (options as any).catches 228 | : Schema.Never 229 | if (options?.provides) { 230 | TagClass_.provides = options.provides 231 | } 232 | TagClass_.wrap = options?.wrap ?? false 233 | ;(TagClass_ as any).returns = options && (options as any).wrap === true && (options as any).returns !== undefined 234 | ? (options as any).returns 235 | : Schema.Never 236 | return TagClass as any 237 | } 238 | -------------------------------------------------------------------------------- /test/Middleware.ordering.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "@effect/vitest" 2 | import { deepStrictEqual, strictEqual } from "@effect/vitest/utils" 3 | import * as Effect from "effect/Effect" 4 | import * as Layer from "effect/Layer" 5 | import * as Next from "../src/Next.js" 6 | import * as NextMiddleware from "../src/NextMiddleware.js" 7 | 8 | describe("Middleware ordering", () => { 9 | it.effect("non-wrapped then wrapped (page)", () => 10 | Effect.gen(function*() { 11 | const order: Array = [] 12 | 13 | class Wrapped extends NextMiddleware.Tag()("Wrapped", { wrap: true }) {} 14 | class NonWrapped extends NextMiddleware.Tag()("NonWrapped") {} 15 | 16 | // Use Layer.succeed with TagClass.of to avoid type issues for tests 17 | const WrappedLive: Layer.Layer = Layer.succeed( 18 | Wrapped, 19 | Wrapped.of(({ next }) => 20 | Effect.gen(function*() { 21 | yield* Effect.sync(() => order.push("wrap:start")) 22 | const result = yield* next 23 | yield* Effect.sync(() => order.push("wrap:end")) 24 | return result 25 | }) 26 | ) 27 | ) 28 | 29 | const NonWrappedLive: Layer.Layer = Layer.succeed( 30 | NonWrapped, 31 | NonWrapped.of(() => 32 | Effect.sync(() => { 33 | order.push("nonwrap") 34 | }) 35 | ) 36 | ) 37 | 38 | const combined = Layer.mergeAll(WrappedLive, NonWrappedLive) 39 | const page = Next.make("Base", combined) 40 | .middleware(Wrapped) 41 | .middleware(NonWrapped) 42 | .middleware(Wrapped) 43 | .middleware(NonWrapped) 44 | 45 | const result = yield* Effect.promise(() => 46 | page.build(() => 47 | Effect.gen(function*() { 48 | yield* Effect.sync(() => order.push("handler")) 49 | return "ok" 50 | }) 51 | )() 52 | ) 53 | 54 | strictEqual(result, "ok") 55 | deepStrictEqual(order, ["wrap:start", "nonwrap", "wrap:start", "nonwrap", "handler", "wrap:end", "wrap:end"]) 56 | })) 57 | it.effect("non-wrapped then wrapped (layout)", () => 58 | Effect.gen(function*() { 59 | const order: Array = [] 60 | 61 | class Wrapped extends NextMiddleware.Tag()("Wrapped", { wrap: true }) {} 62 | class NonWrapped extends NextMiddleware.Tag()("NonWrapped") {} 63 | 64 | const WrappedLive: Layer.Layer = Layer.succeed( 65 | Wrapped, 66 | Wrapped.of(({ next }) => 67 | Effect.gen(function*() { 68 | yield* Effect.sync(() => order.push("wrap:start")) 69 | const result = yield* next 70 | yield* Effect.sync(() => order.push("wrap:end")) 71 | return result 72 | }) 73 | ) 74 | ) 75 | 76 | const NonWrappedLive: Layer.Layer = Layer.succeed( 77 | NonWrapped, 78 | NonWrapped.of(() => 79 | Effect.sync(() => { 80 | order.push("nonwrap") 81 | }) 82 | ) 83 | ) 84 | 85 | const combined = Layer.mergeAll(WrappedLive, NonWrappedLive) 86 | const layout = Next.make("Base", combined) 87 | .middleware(Wrapped) 88 | .middleware(NonWrapped) 89 | .middleware(Wrapped) 90 | .middleware(NonWrapped) 91 | 92 | const result = yield* Effect.promise(() => 93 | layout.build(() => 94 | Effect.gen(function*() { 95 | yield* Effect.sync(() => order.push("handler")) 96 | return "ok" 97 | }) 98 | )() 99 | ) 100 | 101 | strictEqual(result, "ok") 102 | deepStrictEqual(order, ["wrap:start", "nonwrap", "wrap:start", "nonwrap", "handler", "wrap:end", "wrap:end"]) 103 | })) 104 | 105 | it.effect("non-wrapped then wrapped (action)", () => 106 | Effect.gen(function*() { 107 | const order: Array = [] 108 | 109 | class Wrapped extends NextMiddleware.Tag()("Wrapped", { wrap: true }) {} 110 | class NonWrapped extends NextMiddleware.Tag()("NonWrapped") {} 111 | 112 | const WrappedLive: Layer.Layer = Layer.succeed( 113 | Wrapped, 114 | Wrapped.of(({ next }) => 115 | Effect.gen(function*() { 116 | yield* Effect.sync(() => order.push("wrap:start")) 117 | const result = yield* next 118 | yield* Effect.sync(() => order.push("wrap:end")) 119 | return result 120 | }) 121 | ) 122 | ) 123 | 124 | const NonWrappedLive: Layer.Layer = Layer.succeed( 125 | NonWrapped, 126 | NonWrapped.of(() => 127 | Effect.gen(function*() { 128 | yield* Effect.sync(() => order.push("nonwrap")) 129 | return { _: Symbol("ok") } as any 130 | }) 131 | ) 132 | ) 133 | 134 | const combined = Layer.mergeAll(WrappedLive, NonWrappedLive) 135 | const action = Next.make("Base", combined) 136 | .middleware(Wrapped) 137 | .middleware(NonWrapped) 138 | .middleware(Wrapped) 139 | .middleware(NonWrapped) 140 | 141 | const result = yield* Effect.promise( 142 | action.build(() => 143 | Effect.gen(function*() { 144 | yield* Effect.sync(() => order.push("handler")) 145 | return "ok" 146 | }) 147 | ) 148 | ) 149 | 150 | strictEqual(result, "ok") 151 | deepStrictEqual(order, ["wrap:start", "nonwrap", "wrap:start", "nonwrap", "handler", "wrap:end", "wrap:end"]) 152 | })) 153 | 154 | it.effect("non-wrapped then wrapped (server component)", () => 155 | Effect.gen(function*() { 156 | const order: Array = [] 157 | 158 | class Wrapped extends NextMiddleware.Tag()("Wrapped", { wrap: true }) {} 159 | class NonWrapped extends NextMiddleware.Tag()("NonWrapped") {} 160 | 161 | const WrappedLive: Layer.Layer = Layer.succeed( 162 | Wrapped, 163 | Wrapped.of(({ next }) => 164 | Effect.gen(function*() { 165 | yield* Effect.sync(() => order.push("wrap:start")) 166 | const result = yield* next 167 | yield* Effect.sync(() => order.push("wrap:end")) 168 | return result 169 | }) 170 | ) 171 | ) 172 | 173 | const NonWrappedLive: Layer.Layer = Layer.succeed( 174 | NonWrapped, 175 | NonWrapped.of(() => 176 | Effect.sync(() => { 177 | order.push("nonwrap") 178 | }) 179 | ) 180 | ) 181 | 182 | const combined = Layer.mergeAll(WrappedLive, NonWrappedLive) 183 | const component = Next.make("Base", combined) 184 | .middleware(Wrapped) 185 | .middleware(NonWrapped) 186 | .middleware(Wrapped) 187 | .middleware(NonWrapped) 188 | 189 | const result = yield* Effect.promise(() => 190 | component.build(() => 191 | Effect.gen(function*() { 192 | yield* Effect.sync(() => order.push("handler")) 193 | return "ok" 194 | }) 195 | )() 196 | ) 197 | 198 | strictEqual(result, "ok") 199 | deepStrictEqual(order, ["wrap:start", "nonwrap", "wrap:start", "nonwrap", "handler", "wrap:end", "wrap:end"]) 200 | })) 201 | }) 202 | -------------------------------------------------------------------------------- /test/AsyncContext.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest" 2 | import * as AsyncContext from "../src/internal/async-context.js" 3 | 4 | describe("AsyncContext", () => { 5 | describe("captureContext", () => { 6 | it("captures both workStore and workUnitStore when available", () => { 7 | const mockWorkStore = { route: "/test", incrementalCache: {} } 8 | const mockWorkUnitStore = { phase: "request" as const, type: "request" as const } 9 | 10 | const deps: AsyncContext.AsyncStorageDeps = { 11 | workAsyncStorage: { 12 | getStore: () => mockWorkStore 13 | } as any, 14 | workUnitAsyncStorage: { 15 | getStore: () => mockWorkUnitStore 16 | } as any 17 | } 18 | 19 | const context = AsyncContext.captureContext(deps) 20 | 21 | expect(context.workStore).toBe(mockWorkStore) 22 | expect(context.workUnitStore).toBe(mockWorkUnitStore) 23 | }) 24 | 25 | it("captures undefined when stores are not available", () => { 26 | const deps: AsyncContext.AsyncStorageDeps = { 27 | workAsyncStorage: { 28 | getStore: () => undefined 29 | } as any, 30 | workUnitAsyncStorage: { 31 | getStore: () => undefined 32 | } as any 33 | } 34 | 35 | const context = AsyncContext.captureContext(deps) 36 | 37 | expect(context.workStore).toBeUndefined() 38 | expect(context.workUnitStore).toBeUndefined() 39 | }) 40 | }) 41 | 42 | describe("withRestoredContext", () => { 43 | it("restores both stores when both are available", () => { 44 | const mockWorkStore = { route: "/test" } 45 | const mockWorkUnitStore = { phase: "request" } 46 | const executionTrace: Array = [] 47 | 48 | const mockWorkAsyncStorage = { 49 | getStore: vi.fn(), 50 | run: vi.fn((store, fn) => { 51 | executionTrace.push(`workAsyncStorage.run with store ${store === mockWorkStore}`) 52 | return fn() 53 | }) 54 | } as any 55 | 56 | const mockWorkUnitAsyncStorage = { 57 | getStore: vi.fn(), 58 | run: vi.fn((store, fn) => { 59 | executionTrace.push(`workUnitAsyncStorage.run with store ${store === mockWorkUnitStore}`) 60 | return fn() 61 | }) 62 | } as any 63 | 64 | const context: AsyncContext.CapturedContext = { 65 | workStore: mockWorkStore, 66 | workUnitStore: mockWorkUnitStore 67 | } 68 | 69 | const deps: AsyncContext.AsyncStorageDeps = { 70 | workAsyncStorage: mockWorkAsyncStorage, 71 | workUnitAsyncStorage: mockWorkUnitAsyncStorage 72 | } 73 | 74 | const testFn = vi.fn((a: number, b: number) => { 75 | executionTrace.push("testFn executed") 76 | return a + b 77 | }) 78 | 79 | const wrapped = AsyncContext.withRestoredContext(context, deps, testFn) 80 | const result = wrapped(1, 2) 81 | 82 | expect(result).toBe(3) 83 | expect(testFn).toHaveBeenCalledWith(1, 2) 84 | expect(mockWorkAsyncStorage.run).toHaveBeenCalledWith(mockWorkStore, expect.any(Function)) 85 | expect(mockWorkUnitAsyncStorage.run).toHaveBeenCalledWith(mockWorkUnitStore, expect.any(Function)) 86 | expect(executionTrace).toEqual([ 87 | "workAsyncStorage.run with store true", 88 | "workUnitAsyncStorage.run with store true", 89 | "testFn executed" 90 | ]) 91 | }) 92 | 93 | it("restores only workStore when workUnitStore is undefined", () => { 94 | const mockWorkStore = { route: "/test" } 95 | const executionTrace: Array = [] 96 | 97 | const mockWorkAsyncStorage = { 98 | run: vi.fn((store, fn) => { 99 | executionTrace.push(`workAsyncStorage.run`) 100 | return fn() 101 | }) 102 | } as any 103 | 104 | const mockWorkUnitAsyncStorage = { 105 | run: vi.fn() 106 | } as any 107 | 108 | const context: AsyncContext.CapturedContext = { 109 | workStore: mockWorkStore, 110 | workUnitStore: undefined 111 | } 112 | 113 | const deps: AsyncContext.AsyncStorageDeps = { 114 | workAsyncStorage: mockWorkAsyncStorage, 115 | workUnitAsyncStorage: mockWorkUnitAsyncStorage 116 | } 117 | 118 | const testFn = vi.fn(() => { 119 | executionTrace.push("testFn executed") 120 | return "result" 121 | }) 122 | 123 | const wrapped = AsyncContext.withRestoredContext(context, deps, testFn) 124 | const result = wrapped() 125 | 126 | expect(result).toBe("result") 127 | expect(mockWorkAsyncStorage.run).toHaveBeenCalledWith(mockWorkStore, expect.any(Function)) 128 | expect(mockWorkUnitAsyncStorage.run).not.toHaveBeenCalled() 129 | expect(executionTrace).toEqual(["workAsyncStorage.run", "testFn executed"]) 130 | }) 131 | 132 | it("calls function directly when no stores are available", () => { 133 | const mockWorkAsyncStorage = { 134 | run: vi.fn() 135 | } as any 136 | 137 | const mockWorkUnitAsyncStorage = { 138 | run: vi.fn() 139 | } as any 140 | 141 | const context: AsyncContext.CapturedContext = { 142 | workStore: undefined, 143 | workUnitStore: undefined 144 | } 145 | 146 | const deps: AsyncContext.AsyncStorageDeps = { 147 | workAsyncStorage: mockWorkAsyncStorage, 148 | workUnitAsyncStorage: mockWorkUnitAsyncStorage 149 | } 150 | 151 | const testFn = vi.fn(() => "direct result") 152 | 153 | const wrapped = AsyncContext.withRestoredContext(context, deps, testFn) 154 | const result = wrapped() 155 | 156 | expect(result).toBe("direct result") 157 | expect(mockWorkAsyncStorage.run).not.toHaveBeenCalled() 158 | expect(mockWorkUnitAsyncStorage.run).not.toHaveBeenCalled() 159 | }) 160 | 161 | it("preserves function arguments and return values", () => { 162 | const context: AsyncContext.CapturedContext = { 163 | workStore: undefined, 164 | workUnitStore: undefined 165 | } 166 | 167 | const deps: AsyncContext.AsyncStorageDeps = { 168 | workAsyncStorage: { run: vi.fn() } as any, 169 | workUnitAsyncStorage: { run: vi.fn() } as any 170 | } 171 | 172 | const complexFn = (obj: { a: number }, arr: Array, num: number) => { 173 | return { result: obj.a + arr.length + num } 174 | } 175 | 176 | const wrapped = AsyncContext.withRestoredContext(context, deps, complexFn) 177 | const result = wrapped({ a: 5 }, ["x", "y"], 3) 178 | 179 | expect(result).toEqual({ result: 10 }) 180 | }) 181 | 182 | it("handles functions that throw errors", () => { 183 | const mockWorkStore = { route: "/test" } 184 | 185 | const context: AsyncContext.CapturedContext = { 186 | workStore: mockWorkStore, 187 | workUnitStore: undefined 188 | } 189 | 190 | const deps: AsyncContext.AsyncStorageDeps = { 191 | workAsyncStorage: { 192 | run: (store: any, fn: () => any) => fn() 193 | } as any, 194 | workUnitAsyncStorage: { run: vi.fn() } as any 195 | } 196 | 197 | const throwingFn = () => { 198 | throw new Error("Test error") 199 | } 200 | 201 | const wrapped = AsyncContext.withRestoredContext(context, deps, throwingFn) 202 | 203 | expect(() => wrapped()).toThrow("Test error") 204 | }) 205 | }) 206 | }) 207 | -------------------------------------------------------------------------------- /src/Next.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @since 0.5.0 3 | */ 4 | import { Effect, Option } from "effect" 5 | import * as Context_ from "effect/Context" 6 | import * as Layer from "effect/Layer" 7 | import * as ManagedRuntime from "effect/ManagedRuntime" 8 | import type { Pipeable } from "effect/Pipeable" 9 | import { pipeArguments } from "effect/Pipeable" 10 | import type * as Schema from "effect/Schema" 11 | import type * as AST from "effect/SchemaAST" 12 | import { executeWithRuntime } from "./internal/executor.js" 13 | import { createMiddlewareChain } from "./internal/middleware-chain.js" 14 | import type * as NextMiddleware from "./NextMiddleware.js" 15 | 16 | /** 17 | * @since 0.5.0 18 | * @category constants 19 | */ 20 | const NextSymbolKey = "@mcrovero/effect-nextjs/Next" 21 | 22 | /** 23 | * @since 0.5.0 24 | * @category type ids 25 | */ 26 | export const TypeId: unique symbol = Symbol.for(NextSymbolKey) 27 | 28 | /** 29 | * @since 0.5.0 30 | * @category type ids 31 | */ 32 | export type TypeId = typeof TypeId 33 | 34 | interface Any extends Pipeable { 35 | readonly [TypeId]: TypeId 36 | readonly _tag: string 37 | readonly key: string 38 | } 39 | 40 | interface AnyWithProps { 41 | readonly [TypeId]: TypeId 42 | readonly _tag: string 43 | readonly key: string 44 | readonly middlewares: ReadonlyArray 45 | readonly runtime?: ManagedRuntime.ManagedRuntime 46 | } 47 | 48 | /** 49 | * Extracts the provided environment from a `Layer`. 50 | */ 51 | type LayerSuccess = L extends Layer.Layer ? ROut : never 52 | 53 | /** 54 | * @since 0.5.0 55 | * @category models 56 | */ 57 | export interface Next< 58 | in out Tag extends string, 59 | out L extends Layer.Layer | undefined, 60 | out Middleware extends NextMiddleware.TagClassAny = never 61 | > extends Pipeable { 62 | new(_: never): object 63 | 64 | readonly [TypeId]: TypeId 65 | readonly _tag: Tag 66 | readonly key: string 67 | readonly middlewares: ReadonlyArray 68 | readonly runtime?: ManagedRuntime.ManagedRuntime 69 | readonly paramsSchema?: AnySchema 70 | readonly searchParamsSchema?: AnySchema 71 | 72 | /** 73 | * Adds a middleware tag to this handler. The middleware must be satisfied by 74 | * the environment provided by `L`. 75 | */ 76 | middleware( 77 | middleware: Context_.Tag.Identifier extends LayerSuccess ? M : never 78 | ): Next 79 | 80 | /** 81 | * Finalizes the handler by supplying an Effect-based implementation and 82 | * returns an async function compatible with Next.js. 83 | */ 84 | build< 85 | A extends Array, 86 | O 87 | >( 88 | handler: BuildHandler, A, O> 89 | ): ( 90 | ...args: A 91 | ) => Promise< 92 | ReturnType, A, O>> extends Effect.Effect ? 93 | _A | WrappedReturns : 94 | never 95 | > 96 | } 97 | 98 | const Proto = { 99 | [TypeId]: TypeId, 100 | pipe() { 101 | return pipeArguments(this, arguments) 102 | }, 103 | middleware(this: AnyWithProps, middleware: NextMiddleware.TagClassAny) { 104 | if (this.runtime) { 105 | return makeProto({ 106 | _tag: this._tag, 107 | runtime: this.runtime, 108 | middlewares: [...this.middlewares, middleware] 109 | } as any) 110 | } 111 | return makeProto({ 112 | _tag: this._tag, 113 | middlewares: [...this.middlewares, middleware] 114 | } as any) 115 | }, 116 | build< 117 | A extends Array, 118 | O 119 | >( 120 | this: AnyWithProps, 121 | handler: ( 122 | ...args: A 123 | ) => Effect.Effect 124 | ) { 125 | const runtime = this.runtime 126 | return async (...args: A) => { 127 | const middlewares = this.middlewares 128 | 129 | const program = Effect.gen(function*() { 130 | const context = yield* Effect.context() 131 | 132 | let handlerEffect = handler(...args) 133 | 134 | if (middlewares.length > 0) { 135 | const tags = middlewares 136 | handlerEffect = createMiddlewareChain( 137 | tags, 138 | (tag) => Context_.unsafeGet(context, tag), 139 | handlerEffect, 140 | { props: args } 141 | ) 142 | } 143 | return yield* handlerEffect 144 | }) 145 | if (runtime) { 146 | return executeWithRuntime(runtime, program as Effect.Effect) 147 | } 148 | return executeWithRuntime(undefined, program as Effect.Effect) 149 | } 150 | } 151 | } 152 | 153 | const makeProto = < 154 | const Tag extends string, 155 | const L extends Layer.Layer | undefined, 156 | Middleware extends NextMiddleware.TagClassAny 157 | >(options: { 158 | readonly _tag: Tag 159 | readonly runtime?: ManagedRuntime.ManagedRuntime 160 | readonly middlewares: ReadonlyArray 161 | readonly paramsSchema?: AnySchema 162 | readonly searchParamsSchema?: AnySchema 163 | }): Next => { 164 | function Next() {} 165 | Object.setPrototypeOf(Next, Proto) 166 | Object.assign(Next, options) 167 | Next.key = `${NextSymbolKey}/${options._tag}` 168 | return Next as any 169 | } 170 | 171 | /** 172 | * @since 0.5.0 173 | * @category constructors 174 | */ 175 | export function make< 176 | const Tag extends string, 177 | const R, 178 | const E 179 | >( 180 | tag: Tag, 181 | layer: Layer.Layer 182 | ): Next> { 183 | const runtime = ManagedRuntime.make( 184 | // We disable the unhandled error log level to clutter the console with 404, redirect, notFound, etc. 185 | Layer.mergeAll(layer, Layer.setUnhandledErrorLogLevel(Option.none())) 186 | ) 187 | 188 | return makeProto({ 189 | _tag: tag as any, 190 | runtime, 191 | middlewares: [] as Array 192 | }) 193 | } 194 | 195 | /** 196 | * @since 0.5.0 197 | * @category constructors 198 | */ 199 | export function makeWithRuntime< 200 | const Tag extends string, 201 | R, 202 | E 203 | >( 204 | tag: Tag, 205 | runtime: ManagedRuntime.ManagedRuntime 206 | ): Next { 207 | return makeProto({ 208 | _tag: tag as any, 209 | runtime, 210 | middlewares: [] as Array 211 | }) 212 | } 213 | 214 | /** 215 | * Computes the environment required by a `Next` handler: the environment 216 | * provided by its `Layer` plus any environments declared by middleware tags. 217 | */ 218 | type ExtractProvides = R extends Next< 219 | infer _Tag, 220 | infer _Layer, 221 | infer _Middleware 222 | > ? 223 | | LayerSuccess<_Layer> 224 | | (_Middleware extends { readonly provides: Context_.Tag } ? _I : never) 225 | : never 226 | 227 | /** 228 | * Signature of the effectful handler accepted by `build`. 229 | */ 230 | type BuildHandler

, O> = P extends 231 | Next ? ( 232 | ...args: A 233 | ) => Effect.Effect, ExtractProvides

> : 234 | never 235 | 236 | /** 237 | * Computes the wrapped return type produced by middleware implementing the 238 | * `wrap` protocol. When no wrapper is present, yields `never`. 239 | */ 240 | type WrappedReturns = M extends { readonly wrap: true } 241 | ? Schema.Schema.Type 242 | : never 243 | 244 | /** Extracts the union of error types that middleware can catch. */ 245 | type CatchesFromMiddleware = M extends { readonly catches: Schema.Schema } ? A : never 246 | 247 | interface AnySchema extends Pipeable { 248 | readonly [Schema.TypeId]: any 249 | readonly Type: any 250 | readonly Encoded: any 251 | readonly Context: any 252 | readonly make?: (params: any, ...rest: ReadonlyArray) => any 253 | readonly ast: AST.AST 254 | } 255 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @mcrovero/effect-nextjs 2 | 3 | [![npm version](https://img.shields.io/npm/v/%40mcrovero%2Feffect-nextjs.svg?logo=npm&label=npm)](https://www.npmjs.com/package/@mcrovero/effect-nextjs) 4 | [![license: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE) 5 | 6 | Write your Next.js App Router pages, layouts, server components, routes, and actions with Effect without losing the Next.js developer experience. 7 | 8 | - **End-to-end Effect**: Write your app logic as Effect while keeping familiar Next.js ergonomics. 9 | - **Composable middlewares**: Add auth and other cross‑cutting concerns in a clear, reusable way. 10 | - **Works with Next.js**: `redirect`, `notFound`, and other control‑flow behaviors just work. Also provides Effect versions of the utilities. 11 | - **Safe routing**: Decode route params and search params with Effect Schema for safer handlers. 12 | - **Cache‑ready**: Plays well with `@mcrovero/effect-react-cache` (react-cache wrapper) across pages, layouts, and components. 13 | 14 | > [!WARNING] 15 | > This library is in early alpha and is not ready for production use. 16 | 17 | ### Getting Started 18 | 19 | 1. Install effect and the library in an existing Next.js 15+ application 20 | 21 | ```sh 22 | pnpm add @mcrovero/effect-nextjs effect 23 | ``` 24 | 25 | or create a new Next.js application first: 26 | 27 | ```sh 28 | pnpx create-next-app@latest 29 | ``` 30 | 31 | 2. Define Next effect runtime 32 | 33 | ```ts 34 | // lib/runtime.ts 35 | import { Next } from "@mcrovero/effect-nextjs" 36 | import { Layer } from "effect" 37 | 38 | const AppLive = Layer.empty // Your stateless layers 39 | export const BasePage = Next.make("BasePage", AppLive) 40 | ``` 41 | 42 | > [!WARNING] 43 | > It is important that all layers passed to the runtime are stateless. If you need to use a stateful layer like a database connection read below. (see [Stateful layers](#stateful-layers)) 44 | 45 | 3. Write your first page 46 | 47 | ```ts 48 | // app/page.tsx 49 | import { BasePage } from "@/lib/runtime" 50 | import { Effect } from "effect" 51 | 52 | const HomePage = Effect.fn("HomePage")(function* () { 53 | return

54 | }) 55 | 56 | export default BasePage.build(HomePage) 57 | ``` 58 | 59 | When using Effect.fn you'll get automatic telemetry spans for the page load and better stack traces. 60 | 61 | 4. Define a middleware 62 | 63 | ```ts 64 | // lib/auth-runtime.ts 65 | import { Next, NextMiddleware } from "@mcrovero/effect-nextjs" 66 | import { Layer, Schema } from "effect" 67 | import * as Context from "effect/Context" 68 | import * as Effect from "effect/Effect" 69 | 70 | export class CurrentUser extends Context.Tag("CurrentUser")() {} 71 | 72 | // Middleware that provides CurrentUser and can fail with a string 73 | export class AuthMiddleware extends NextMiddleware.Tag()("AuthMiddleware", { 74 | provides: CurrentUser, 75 | failure: Schema.String 76 | }) {} 77 | 78 | // Live implementation for the middleware 79 | export const AuthLive = Layer.succeed( 80 | AuthMiddleware, 81 | AuthMiddleware.of(() => Effect.succeed({ id: "123", name: "Ada" })) 82 | ) 83 | 84 | // Create a typed page handler 85 | export const AuthenticatedPage = Next.make("BasePage", AuthLive).middleware(AuthMiddleware) 86 | ``` 87 | 88 | 5. Use the middleware in a page and get the CurrentUser value 89 | 90 | ```ts 91 | // app/page.tsx 92 | import { AuthenticatedPage, CurrentUser } from "@/lib/auth-runtime" // wherever you defined it 93 | import { Effect } from "effect" 94 | 95 | const HomePage = () => 96 | Effect.gen(function* () { 97 | const user = yield* CurrentUser 98 | return
Hello {user.name}
99 | }) 100 | 101 | export default AuthenticatedPage.build(HomePage) 102 | ``` 103 | 104 | You can provide as many middlewares as you want. 105 | 106 | ```ts 107 | const HomePage = AuthenticatedPage.middleware(LocaleMiddleware).middleware(TimezoneMiddleware).build(HomePage) 108 | ``` 109 | 110 | > [!WARNING] 111 | > The middleware order is important. The middleware will be executed in the order they are provided from left to right. 112 | 113 | ### Effect Next.js utilities 114 | 115 | When you need to use nextjs utilities like redirect, notFound, etc. you need to call them using Effect.sync. Code with side effects should always be lazy in Effect. 116 | 117 | ```ts 118 | import { Effect } from "effect" 119 | import { redirect } from "next/navigation" 120 | 121 | const HomePage = Effect.fn("HomePage")(function* () { 122 | yield* Effect.sync(() => redirect("/somewhere")) 123 | }) 124 | export default BasePage.build(HomePage) 125 | ``` 126 | 127 | Or you can use the Effect version of the utility functions like `Redirect` or `NotFound`. 128 | 129 | ```ts 130 | import { Effect } from "effect" 131 | import { Redirect } from "@mcrovero/effect-nextjs/Navigation" 132 | 133 | const HomePage = Effect.fn("HomePage")(function* () { 134 | yield* Redirect("/somewhere") 135 | }) 136 | export default BasePage.build(HomePage) 137 | ``` 138 | 139 | Navigation: 140 | 141 | ```ts 142 | import { Redirect, PermanentRedirect, NotFound } from "@mcrovero/effect-nextjs/Navigation" 143 | 144 | const HomePage = Effect.fn("HomePage")(function* () { 145 | yield* Redirect("/somewhere") 146 | yield* PermanentRedirect("/somewhere") 147 | yield* NotFound 148 | }) 149 | ``` 150 | 151 | Cache: 152 | 153 | ```ts 154 | import { RevalidatePath, RevalidateTag } from "@mcrovero/effect-nextjs/Cache" 155 | 156 | const HomePage = Effect.fn("HomePage")(function* () { 157 | yield* RevalidatePath("/") 158 | yield* RevalidateTag("tag") 159 | }) 160 | ``` 161 | 162 | Headers: 163 | 164 | ```ts 165 | import { Headers, Cookies, DraftMode } from "@mcrovero/effect-nextjs/Headers" 166 | Ø 167 | const HomePage = Effect.fn("HomePage")(function* () { 168 | const headers = yield* Headers 169 | const cookies = yield* Cookies 170 | const draftMode = yield* DraftMode 171 | }) 172 | ``` 173 | 174 | ### Params and Search Params 175 | 176 | You should always validate the params and search params with Effect Schema. 177 | 178 | ```ts 179 | import { BasePage } from "@/lib/runtime" 180 | import { decodeParamsUnknown, decodeSearchParamsUnknown } from "@mcrovero/effect-nextjs/Params" 181 | import { Effect, Schema } from "effect" 182 | 183 | const HomePage = Effect.fn("HomePage")((props) => 184 | Effect.all([ 185 | decodeParamsUnknown(Schema.Struct({ id: Schema.optional(Schema.String) }))(props.params), 186 | decodeSearchParamsUnknown(Schema.Struct({ name: Schema.optional(Schema.String) }))(props.searchParams) 187 | ]).pipe( 188 | Effect.map(([params, searchParams]) => ( 189 |
190 | Id: {params.id} Name: {searchParams.name} 191 |
192 | )), 193 | Effect.catchTag("ParseError", () => Effect.succeed(
Error decoding params
)) 194 | ) 195 | ) 196 | 197 | export default BasePage.build(HomePage) 198 | ``` 199 | 200 | ### Wrapped middlewares 201 | 202 | You can use wrapped middlewares (`wrap: true`) to run before and after the handler. 203 | 204 | ```ts 205 | import * as Context from "effect/Context" 206 | import * as Effect from "effect/Effect" 207 | import { Layer, Schema } from "effect" 208 | import { Next, NextMiddleware } from "@mcrovero/effect-nextjs" 209 | 210 | export class CurrentUser extends Context.Tag("CurrentUser")() {} 211 | 212 | export class Wrapped extends NextMiddleware.Tag()("Wrapped", { 213 | provides: CurrentUser, 214 | failure: Schema.String, 215 | wrap: true 216 | }) {} 217 | 218 | const WrappedLive = Layer.succeed( 219 | Wrapped, 220 | Wrapped.of(({ next }) => 221 | Effect.gen(function* () { 222 | yield* Effect.log("before") 223 | // pre logic... 224 | const out = yield* Effect.provideService(next, CurrentUser, { id: "u1", name: "Ada" }) 225 | // post logic... 226 | yield* Effect.log("after") 227 | return out 228 | }) 229 | ) 230 | ) 231 | 232 | const AppLive = Layer.mergeAll(WrappedLive) 233 | const Page = Next.make("Home", AppLive).middleware(Wrapped) 234 | ``` 235 | 236 | ### Stateful layers 237 | 238 | When using a stateful layer there is no clean way to dispose it safely on HMR in development. You should define the Next runtime globally using `globalValue` from `effect/GlobalValue`. 239 | 240 | ```ts 241 | import { Next } from "@mcrovero/effect-nextjs" 242 | import { Effect, ManagedRuntime } from "effect" 243 | import { globalValue } from "effect/GlobalValue" 244 | 245 | export class StatefulService extends Effect.Service()("app/StatefulService", { 246 | scoped: Effect.gen(function* () { 247 | yield* Effect.log("StatefulService scoped") 248 | yield* Effect.addFinalizer(() => Effect.log("StatefulService finalizer")) 249 | return {} 250 | }) 251 | }) {} 252 | 253 | export const statefulRuntime = globalValue("BasePage", () => { 254 | const managedRuntime = ManagedRuntime.make(StatefulService.Default) 255 | process.on("SIGINT", () => { 256 | managedRuntime.dispose() 257 | }) 258 | process.on("SIGTERM", () => { 259 | managedRuntime.dispose() 260 | }) 261 | return managedRuntime 262 | }) 263 | ``` 264 | 265 | Then you can use it directly using `Next.makeWithRuntime`. 266 | 267 | ```ts 268 | export const BasePage = Next.makeWithRuntime("BasePage", statefulRuntime) 269 | ``` 270 | 271 | Or you can extract the context you need from the stateful runtime and using it in a stateless layer. 272 | This way you'll get HMR for the stateless layer and clean disposal of the stateful runtime. 273 | 274 | ```ts 275 | const EphemeralLayer = Layer.effectContext(statefulRuntime.runtimeEffect.pipe(Effect.map((runtime) => runtime.context))) 276 | 277 | export const BasePage = Next.make("BasePage", EphemeralLayer) 278 | ``` 279 | 280 | ### Next.js Route Props Helpers Integration 281 | 282 | With Next.js 15.5, you can now use the globally available `PageProps` and `LayoutProps` types for fully typed route parameters without manual definitions. You can use them with this library as follows: 283 | 284 | ```ts 285 | import * as Effect from "effect/Effect" 286 | import { Next } from "@mcrovero/effect-nextjs" 287 | 288 | // Page with typed route parameters 289 | const BlogPage = Effect.fn("BlogHandler")(function* (props: PageProps<"/blog/[slug]">) { 290 | const { slug } = yield* Effect.promise(() => props.params) 291 | return ( 292 |
293 |

Blog Post: {slug}

294 |

Content for {slug}

295 |
296 | ) 297 | }) 298 | 299 | export default Next.make("BlogPage", AppLive).build(BlØogPage) 300 | 301 | // Layout with parallel routes support 302 | const DashboardLayout = Effect.fn("DashboardLayout")(function* (props: LayoutProps<"/dashboard">) { 303 | // Fully typed parallel route slots 304 | return ( 305 |
306 | {props.children} 307 | {props.analytics} {/* Fully typed */} 308 | {props.team} {/* Fully typed */} 309 |
310 | ) 311 | }) 312 | export default Next.make("DashboardLayout", AppLive).build(DashboardLayout) 313 | ``` 314 | 315 | See the official documentation: - [Next.js 15.5 – Route Props Helpers](https://nextjs.org/docs/app/getting-started/layouts-and-pages#route-props-helpers) 316 | 317 | ### OpenTelemetry 318 | 319 | Setup nextjs telemetry following official documentation: - [OpenTelemetry](https://nextjs.org/docs/app/guides/open-telemetry) 320 | 321 | Then install @effect/opentelemetry 322 | 323 | ```sh 324 | pnpm add @effect/opentelemetry 325 | ``` 326 | 327 | Create the tracer layer 328 | 329 | ```ts 330 | import { Tracer as OtelTracer, Resource } from "@effect/opentelemetry" 331 | import { Effect, Layer, Option } from "effect" 332 | 333 | export const layerTracer = OtelTracer.layerGlobal.pipe( 334 | Layer.provide( 335 | Layer.unwrapEffect( 336 | Effect.gen(function* () { 337 | const resource = yield* Effect.serviceOption(Resource.Resource) 338 | if (Option.isSome(resource)) { 339 | return Layer.succeed(Resource.Resource, resource.value) 340 | } 341 | return Resource.layerFromEnv() 342 | }) 343 | ) 344 | ) 345 | ) 346 | ``` 347 | 348 | and provide it to the Next runtime 349 | 350 | ```ts 351 | export const AppLiveWithTracer = AppLive.pipe(Layer.provideMerge(layerTracer)) 352 | ``` 353 | 354 | ```ts 355 | export const BasePage = Next.make("BasePage", AppLiveWithTracer) 356 | ``` 357 | --------------------------------------------------------------------------------