From 971618cc0a59be558f73db425c332844c2cd92e1 Mon Sep 17 00:00:00 2001 From: Maximiliano Salvatti <40447063+msalvatti@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:29:00 -0300 Subject: [PATCH] fix(nextjs): lazy-load edge WASM and make next/server resolvable when externalized The `/nextjs` subpath eagerly imported the WASM glue at module load, whose top-level `__wbindgen_start` a Next build's page-data collection cannot run, and imported `next/server` as an extensionless subpath, which Node's native ESM resolver cannot resolve because `next` ships no `exports` map (so an externalized `serverExternalPackages` consumer failed `next build`). - Defer the WASM to a memoized dynamic `import()` on first use inside the jwt helpers; importing the module (or the barrel) now has no WASM side effect. `decodeJwtToken` becomes async to match `verifyJwtToken`. - Import `next/server.js` (fully specified) in the proxy and route handlers so the built ESM/CJS resolve under Node's raw loader; unchanged under bundlers/Vitest. Keep `next/server.js` external in the tsup build. --- packages/rust-auth/src/nextjs/handlers.ts | 7 ++++-- packages/rust-auth/src/nextjs/jwt.ts | 26 +++++++++++++++++++++-- packages/rust-auth/src/nextjs/proxy.ts | 9 ++++++-- packages/rust-auth/tests/nextjs.test.ts | 10 ++++----- packages/rust-auth/tsup.config.ts | 1 + 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/rust-auth/src/nextjs/handlers.ts b/packages/rust-auth/src/nextjs/handlers.ts index 2213741..bbcd8ce 100644 --- a/packages/rust-auth/src/nextjs/handlers.ts +++ b/packages/rust-auth/src/nextjs/handlers.ts @@ -6,8 +6,11 @@ * @layer nextjs-server */ -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; +// The explicit `.js` extension keeps `next/server` resolvable when the built package is +// externalized and loaded by Node's native ESM resolver (see the note in `proxy.ts`): `next` +// ships no `exports` map, so Node ESM cannot resolve the extensionless subpath. +import { NextResponse } from "next/server.js"; +import type { NextRequest } from "next/server.js"; import { AUTH_ACCESS_COOKIE_NAME, diff --git a/packages/rust-auth/src/nextjs/jwt.ts b/packages/rust-auth/src/nextjs/jwt.ts index 5c61ba1..3028c26 100644 --- a/packages/rust-auth/src/nextjs/jwt.ts +++ b/packages/rust-auth/src/nextjs/jwt.ts @@ -2,7 +2,6 @@ // browser bundle. The `server-only` import makes a Client-Component import a build error. import "server-only"; -import { decode_jwt, verify_jwt_hs256 } from "../../wasm/bymax_auth_wasm.js"; import type { DashboardJwtPayload, MfaTempPayload, @@ -13,9 +12,30 @@ import type { * @fileoverview Edge JWT helpers backed by the WASM verifier. `verifyJwtToken` runs the exact * Rust HS256 codec the backend signs with (authoritative); `decodeJwtToken` and the * decode-only fallback never check a signature and must never gate an authorization decision. + * + * The WASM glue self-initializes on import (its top-level `__wbindgen_start`), so it is loaded + * lazily via a memoized dynamic `import()` on first use rather than at module load: importing + * this module — or the `/nextjs` barrel — must have NO WASM side effect, so a Next build's + * page-data collection (which cannot instantiate the edge WASM) can evaluate the barrel. * @layer nextjs-server */ +/** The edge codec surface this module consumes from the bundled `bymax-auth-wasm` glue. */ +type EdgeWasm = typeof import("../../wasm/bymax_auth_wasm.js"); + +/** The memoized in-flight (then resolved) WASM import; `undefined` until first use. */ +let edgeWasm: Promise | undefined; + +/** + * Load the edge WASM codec lazily and at most once. The dynamic `import()` defers the glue's + * self-initialization to first use and caches the module namespace, so repeated calls share a + * single wasm-init instance and importing this module stays side-effect-free. + */ +function loadEdgeWasm(): Promise { + edgeWasm ??= import("../../wasm/bymax_auth_wasm.js"); + return edgeWasm; +} + /** The three claim shapes the backend issues, discriminated by their `type` field. */ export type AuthJwtPayload = DashboardJwtPayload | PlatformJwtPayload | MfaTempPayload; @@ -55,8 +75,9 @@ interface DecodedHeaderPayload { * @param token - The compact JWS to decode. * @returns `{ isValid: true, header, payload }` when decodable, else `{ isValid: false }`. */ -export function decodeJwtToken(token: string): DecodedToken { +export async function decodeJwtToken(token: string): Promise { try { + const { decode_jwt } = await loadEdgeWasm(); const raw = decode_jwt(token); if (raw === undefined) return { isValid: false }; const { header, payload } = JSON.parse(raw) as DecodedHeaderPayload; @@ -82,6 +103,7 @@ export async function verifyJwtToken( secret?: string | null, ): Promise { try { + const { decode_jwt, verify_jwt_hs256 } = await loadEdgeWasm(); if (typeof secret === "string" && secret.length > 0) { const raw = verify_jwt_hs256(token, secret); if (raw === undefined) return { isValid: false }; diff --git a/packages/rust-auth/src/nextjs/proxy.ts b/packages/rust-auth/src/nextjs/proxy.ts index ecd992c..17b1463 100644 --- a/packages/rust-auth/src/nextjs/proxy.ts +++ b/packages/rust-auth/src/nextjs/proxy.ts @@ -7,8 +7,13 @@ * @layer nextjs-server */ -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; +// Imported with the explicit `.js` extension: when this package is installed by a Next 16 +// consumer and externalized (`serverExternalPackages`), the built `dist/nextjs` is loaded by +// Node's native ESM resolver, which cannot resolve the bare subpath `next/server` because +// `next` ships no `exports` map (extensionless subpaths require a fully-specified path). The +// `.js` specifier resolves identically under bundlers and Vitest. +import { NextResponse } from "next/server.js"; +import type { NextRequest } from "next/server.js"; import { AUTH_ACCESS_COOKIE_NAME, diff --git a/packages/rust-auth/tests/nextjs.test.ts b/packages/rust-auth/tests/nextjs.test.ts index 511edff..d707188 100644 --- a/packages/rust-auth/tests/nextjs.test.ts +++ b/packages/rust-auth/tests/nextjs.test.ts @@ -129,16 +129,16 @@ describe("verifyJwtToken — decode-only fallback is non-authoritative", () => { }); describe("decodeJwtToken", () => { - it("returns the header and payload without verifying the signature", () => { - const decoded = decodeJwtToken(dashboardToken()); + it("returns the header and payload without verifying the signature", async () => { + const decoded = await decodeJwtToken(dashboardToken()); expect(decoded.isValid).toBe(true); expect(decoded.header?.alg).toBe("HS256"); expect(getUserId(decoded)).toBe("u_1"); }); - it("returns { isValid: false } for a malformed token and never throws", () => { - expect(decodeJwtToken("not-a-token").isValid).toBe(false); - expect(getTenantId(decodeJwtToken("not-a-token"))).toBeUndefined(); + it("returns { isValid: false } for a malformed token and never throws", async () => { + expect((await decodeJwtToken("not-a-token")).isValid).toBe(false); + expect(getTenantId(await decodeJwtToken("not-a-token"))).toBeUndefined(); }); }); diff --git a/packages/rust-auth/tsup.config.ts b/packages/rust-auth/tsup.config.ts index c01ae11..07adbbe 100644 --- a/packages/rust-auth/tsup.config.ts +++ b/packages/rust-auth/tsup.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ "react-dom", "next", "next/server", + "next/server.js", "server-only", /^\.\.\/wasm\//, /bymax_auth_wasm/,