From 5f72d2287efa85d0a724bdc297bf79586d59a608 Mon Sep 17 00:00:00 2001 From: Jarle Mathiesen Date: Fri, 19 Apr 2024 17:48:42 +0200 Subject: [PATCH] commit session early for remix requests --- package-lock.json | 13 +++- packages/adapter/bin/test.ts | 3 + packages/adapter/package.json | 5 +- packages/adapter/src/debug.ts | 3 + packages/adapter/src/remix_adapter.ts | 9 ++- packages/adapter/tests/cookie.ts | 65 +++++++++++++++++ packages/adapter/tests/http_server.ts | 71 +++++++++++++++++-- packages/adapter/tests/session.spec.ts | 29 ++++++++ .../resources/remix_app/routes/login.tsx | 29 ++++++++ 9 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 packages/adapter/src/debug.ts create mode 100644 packages/adapter/tests/cookie.ts create mode 100644 packages/adapter/tests/session.spec.ts create mode 100644 packages/reference-app/resources/remix_app/routes/login.tsx diff --git a/package-lock.json b/package-lock.json index 41d556b..bca65e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3872,6 +3872,14 @@ "@types/node": "*" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.7.tgz", + "integrity": "sha512-+ge/loa0oTozxip6zmhRIk8Z/boU51wl9Q6QdLZcokIGMzY5lFXYy/x7Htj2HTC6/KZP1hUbZ1ekx8DYXICvWg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -20807,13 +20815,15 @@ "version": "0.0.24", "license": "MIT", "dependencies": { - "@poppinss/utils": "^6.7.3" + "@poppinss/utils": "^6.7.3", + "@types/set-cookie-parser": "^2.4.7" }, "devDependencies": { "@adonisjs/core": "^6.7.0", "@adonisjs/eslint-config": "^1.3.0", "@adonisjs/http-server": "^7.1.0", "@adonisjs/prettier-config": "^1.3.0", + "@adonisjs/session": "^7.4.0", "@adonisjs/tsconfig": "^1.3.0", "@adonisjs/vite": "^3.0.0-8", "@japa/assert": "^2.1.0", @@ -20830,6 +20840,7 @@ "node-mocks-http": "^1.14.1", "np": "^10.0.3", "prettier": "^3.2.5", + "set-cookie-parser": "^2.6.0", "supertest": "^6.3.4", "ts-node": "^10.9.2", "typescript": "^5.4.5", diff --git a/packages/adapter/bin/test.ts b/packages/adapter/bin/test.ts index 8ae0fbc..63e508d 100644 --- a/packages/adapter/bin/test.ts +++ b/packages/adapter/bin/test.ts @@ -19,6 +19,9 @@ processCLIArgs(process.argv.slice(2)) configure({ files: ['tests/**/*.spec.ts'], plugins: [assert(), expect()], + filters: { + tags: ['@active'], + }, }) /* diff --git a/packages/adapter/package.json b/packages/adapter/package.json index cb504ec..b554484 100644 --- a/packages/adapter/package.json +++ b/packages/adapter/package.json @@ -39,7 +39,7 @@ "prepublishOnly": "npm run build", "quick:test": "node --enable-source-maps --loader=ts-node/esm bin/test.ts", "release": "npm version patch --force && npm publish", - "test": "node --loader ts-node/esm --enable-source-maps bin/test.ts", + "test": "NODE_DEBUG=adonisjs:*,matstack:* NODE_ENV=test node --loader ts-node/esm --enable-source-maps bin/test.ts", "typecheck": "tsc --noEmit", "version": "npm run build" }, @@ -51,6 +51,7 @@ "@adonisjs/eslint-config": "^1.3.0", "@adonisjs/http-server": "^7.1.0", "@adonisjs/prettier-config": "^1.3.0", + "@adonisjs/session": "^7.4.0", "@adonisjs/tsconfig": "^1.3.0", "@adonisjs/vite": "^3.0.0-8", "@japa/assert": "^2.1.0", @@ -59,6 +60,7 @@ "@swc/core": "^1.4.14", "@types/cloneable-readable": "^2.0.3", "@types/node": "^20.12.7", + "@types/set-cookie-parser": "^2.4.7", "@types/supertest": "^6.0.2", "c8": "^9.1.0", "copyfiles": "^2.4.1", @@ -67,6 +69,7 @@ "node-mocks-http": "^1.14.1", "np": "^10.0.3", "prettier": "^3.2.5", + "set-cookie-parser": "^2.6.0", "supertest": "^6.3.4", "ts-node": "^10.9.2", "typescript": "^5.4.5", diff --git a/packages/adapter/src/debug.ts b/packages/adapter/src/debug.ts new file mode 100644 index 0000000..9ac4465 --- /dev/null +++ b/packages/adapter/src/debug.ts @@ -0,0 +1,3 @@ +import { debuglog } from 'node:util' + +export default debuglog('matstack:remix-adonisjs') diff --git a/packages/adapter/src/remix_adapter.ts b/packages/adapter/src/remix_adapter.ts index 2eb8861..2aaa5b6 100644 --- a/packages/adapter/src/remix_adapter.ts +++ b/packages/adapter/src/remix_adapter.ts @@ -10,6 +10,7 @@ import { createReadableStreamFromReadable, createRequestHandler as createRemixRequestHandler, } from '@remix-run/node' +import debug from './debug.js' import { ReadableWebToNodeStream } from './stream_conversion.js' export type HandlerContext = { @@ -41,12 +42,13 @@ export function createRequestHandler({ let handleRequest = createRemixRequestHandler(build, mode) return async (context: HandlerContext) => { + debug(`Creating remix request for ${context.http.request.parsedUrl}`) const request = createRemixRequest(context.http.request, context.http.response) const loadContext = getLoadContext(context) const response = await handleRequest(request, loadContext) - sendRemixResponse(context.http.response, response) + sendRemixResponse(context.http, response) } } @@ -98,10 +100,13 @@ export function createRemixRequest(req: AdonisRequest, res: AdonisResponse): Req return new Request(url.href, init) } -export async function sendRemixResponse(res: AdonisResponse, webResponse: Response) { +export async function sendRemixResponse(ctx: HttpContext, webResponse: Response) { + const res = ctx.response res.response.statusMessage = webResponse.statusText res.status(webResponse.status) + debug('Commit session early for remix response') + ctx.session?.commit() webResponse.headers.forEach((value, key) => res.append(key, value)) if (webResponse.body) { diff --git a/packages/adapter/tests/cookie.ts b/packages/adapter/tests/cookie.ts new file mode 100644 index 0000000..7817fab --- /dev/null +++ b/packages/adapter/tests/cookie.ts @@ -0,0 +1,65 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { CookieOptions } from '@adonisjs/core/types/http' + +import { SessionData, SessionStoreContract } from '@adonisjs/session/types' +import debug from '../src/debug.js' + +/** + * Cookie store stores the session data inside an encrypted + * cookie. + */ +export class CookieStore implements SessionStoreContract { + #ctx: HttpContext + #config: Partial + + constructor(config: Partial, ctx: HttpContext) { + this.#config = config + this.#ctx = ctx + debug('initiating cookie store %O', this.#config) + } + + /** + * Read session value from the cookie + */ + read(sessionId: string): SessionData | null { + debug('cookie store: reading session data %s', sessionId) + + const cookieValue = this.#ctx.request.encryptedCookie(sessionId) + if (typeof cookieValue !== 'object') { + return null + } + + return cookieValue + } + + /** + * Write session values to the cookie + */ + write(sessionId: string, values: SessionData): void { + debug('cookie store: writing session data %s: %O', sessionId, values) + this.#ctx.response.encryptedCookie(sessionId, values, this.#config) + } + + /** + * Removes the session cookie + */ + destroy(sessionId: string): void { + debug('cookie store: destroying session data %s', sessionId) + if (this.#ctx.request.cookiesList()[sessionId]) { + this.#ctx.response.clearCookie(sessionId) + } + } + + /** + * Updates the cookie with existing cookie values + */ + touch(sessionId: string): void { + const value = this.read(sessionId) + debug('cookie store: touching session data %s', sessionId) + if (!value) { + return + } + + this.write(sessionId, value) + } +} diff --git a/packages/adapter/tests/http_server.ts b/packages/adapter/tests/http_server.ts index 80071ad..f795cfc 100644 --- a/packages/adapter/tests/http_server.ts +++ b/packages/adapter/tests/http_server.ts @@ -1,8 +1,19 @@ -import { RequestFactory, ResponseFactory } from '@adonisjs/http-server/factories' +import { EncryptionFactory } from '@adonisjs/core/factories/encryption' +import { CookieClient } from '@adonisjs/http-server' +import { + HttpContextFactory, + RequestFactory, + ResponseFactory, +} from '@adonisjs/http-server/factories' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' +import { SessionConfig } from '@adonisjs/session/types' import { getActiveTest } from '@japa/runner' +import { RequestHandler } from '@remix-run/node' import getPort from 'get-port' import { IncomingMessage, Server, ServerResponse, createServer } from 'node:http' -import { createRemixRequest } from '../src/remix_adapter.js' +import debug from '../src/debug.js' +import { createRemixRequest, sendRemixResponse } from '../src/remix_adapter.js' +import { CookieStore } from './cookie.js' export const httpServer = { async create(handler: (req: IncomingMessage, res: ServerResponse) => any | Promise) { @@ -21,8 +32,54 @@ export const httpServer = { }, } -export const remixHandler = createServer((req, res) => { - const request = new RequestFactory().merge({ req, res }).create() - const response = new ResponseFactory().merge({ req, res }).create() - return createRemixRequest(request, response) -}) +export const encryption = new EncryptionFactory().create() +export const cookieClient = new CookieClient(encryption) +const sessionConfig: SessionConfig = { + enabled: true, + age: '2 hours', + clearWithBrowser: false, + cookieName: 'adonis_session', + cookie: {}, +} + +export const remixHandler = (requestHandler: RequestHandler) => + createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const middleware = await new SessionMiddlewareFactory() + .merge({ + config: Object.assign( + { + store: 'cookie', + stores: { + cookie: () => new CookieStore(sessionConfig.cookie, ctx), + }, + }, + sessionConfig + ), + }) + .create() + + ctx.containerResolver.bindValue('session', middleware) + + await middleware.handle(ctx, async () => { + debug('Creating remix request') + const remixRequest = createRemixRequest(request, response) + + debug('Creating request handler') + const foo = await requestHandler(remixRequest, { + http: ctx, + make: ctx.containerResolver.make, + }) + + ctx.session.commit() + + debug('Sending remix response') + await sendRemixResponse(ctx, foo) + }) + + debug('Finishing request') + response.finish() + }) diff --git a/packages/adapter/tests/session.spec.ts b/packages/adapter/tests/session.spec.ts new file mode 100644 index 0000000..8812430 --- /dev/null +++ b/packages/adapter/tests/session.spec.ts @@ -0,0 +1,29 @@ +import { test } from '@japa/runner' +import { redirect } from '@remix-run/node' +import setCookieParser from 'set-cookie-parser' +import supertest from 'supertest' +import { cookieClient, remixHandler } from './http_server.js' + +test.group('Session', () => { + test('commit session early for remix request', async ({ assert }) => { + let sessionId: string | undefined + + const remixServer = remixHandler(async (_request, context) => { + const session = context?.http.session + session?.flash('status', 'Completed') + session?.put('username', 'jarle') + sessionId = session?.sessionId + return redirect('/admin') + }) + + const { headers } = await supertest(remixServer).get('/') + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + username: 'jarle', + __flash__: { + status: 'Completed', + }, + }) + }) +}) diff --git a/packages/reference-app/resources/remix_app/routes/login.tsx b/packages/reference-app/resources/remix_app/routes/login.tsx new file mode 100644 index 0000000..2687f8c --- /dev/null +++ b/packages/reference-app/resources/remix_app/routes/login.tsx @@ -0,0 +1,29 @@ +import { ActionFunctionArgs, redirect } from '@remix-run/node' +import { Form } from '@remix-run/react' + +export const action = async ({ context }: ActionFunctionArgs) => { + const { http } = context + + http.session.put('login', 'true') + + return redirect('/dashboard') +} + +export default function Page() { + return ( +
+

Log in

+
+ + + +
+
+ ) +}