diff --git a/.commands/test-eslint.sh b/.commands/test-lint.sh similarity index 100% rename from .commands/test-eslint.sh rename to .commands/test-lint.sh diff --git a/.commands/test-types.sh b/.commands/test-types.sh index f2f3c27..9c16941 100755 --- a/.commands/test-types.sh +++ b/.commands/test-types.sh @@ -16,6 +16,7 @@ tsc -p tests/plugins/openApiGenerator/tsconfig.json tsc -p tests/plugins/cacheController/tsconfig.json tsc -p tests/plugins/static/tsconfig.json tsc -p tests/plugins/cors/tsconfig.json +tsc -p tests/plugins/cookie/tsconfig.json # integration npm -w integration run test:types # documentation diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 805f4b6..0fbcf10 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -117,6 +117,9 @@ export default pipe( "// @filename: @duplojs/http/cacheController.ts", `export * from "@v${namedGroups?.version ?? ""}/cacheController";`, + "// @filename: @duplojs/http/cookie.ts", + `export * from "@v${namedGroups?.version ?? ""}/cookie";`, + "// @filename: index.ts", "// ---cut---", ], @@ -145,6 +148,7 @@ export default pipe( "@v0/static": ["libs/v0/plugins/static/index"], "@v0/cors": ["libs/v0/plugins/cors/index"], "@v0/cacheController": ["libs/v0/plugins/cacheController/index"], + "@v0/cookie": ["libs/v0/plugins/cookie/index"], }, }, }, @@ -288,6 +292,10 @@ export default pipe( text: "Contrôle du cache", link: "/fr/v0/guide/plugins/cacheController", }, + { + text: "Gestion Cookie", + link: "/fr/v0/guide/plugins/cookie", + }, ], }, { @@ -434,15 +442,19 @@ export default pipe( }, { text: "Create a static entry point", - link: "/fr/v0/guide/plugins/static", + link: "/en/v0/guide/plugins/static", }, { text: "CORS Management", - link: "/fr/v0/guide/plugins/cors", + link: "/en/v0/guide/plugins/cors", }, { text: "Cache Control", - link: "/fr/v0/guide/plugins/cacheController", + link: "/en/v0/guide/plugins/cacheController", + }, + { + text: "Cookie Management", + link: "/en/v0/guide/plugins/cookie", }, ], }, diff --git a/docs/en/v0/guide/features/formData.md b/docs/en/v0/guide/features/formData.md index ca127eb..31ba2ad 100644 --- a/docs/en/v0/guide/features/formData.md +++ b/docs/en/v0/guide/features/formData.md @@ -1,8 +1,8 @@ --- description: "Handle complex FormData payloads." prev: - text: "Cache control" - link: "/en/v0/guide/plugins/cacheController" + text: "Cookie handling" + link: "/en/v0/guide/plugins/cookie" next: text: "Server-Sent Events (SSE)" link: "/en/v0/guide/features/SSE" diff --git a/docs/en/v0/guide/plugins/cacheController.md b/docs/en/v0/guide/plugins/cacheController.md index 72bbd77..71bb99b 100644 --- a/docs/en/v0/guide/plugins/cacheController.md +++ b/docs/en/v0/guide/plugins/cacheController.md @@ -4,8 +4,8 @@ prev: text: "CORS handling" link: "/en/v0/guide/plugins/cors" next: - text: "Advanced FormData" - link: "/en/v0/guide/features/formData" + text: "Cookie handling" + link: "/en/v0/guide/plugins/cookie" --- # Cache control diff --git a/docs/en/v0/guide/plugins/cookie.md b/docs/en/v0/guide/plugins/cookie.md new file mode 100644 index 0000000..13063b8 --- /dev/null +++ b/docs/en/v0/guide/plugins/cookie.md @@ -0,0 +1,68 @@ +--- +description: "Parse and serialize HTTP cookies" +prev: + text: "Cache control" + link: "/en/v0/guide/plugins/cacheController" +next: + text: "Advanced FormData" + link: "/en/v0/guide/features/formData" +--- + +# Cookie handling + +`@duplojs/http/cookie` lets you read cookies sent by the client and send them back easily in responses. + +It is useful when you want to: + +- read a value from incoming cookies +- set a new cookie in a response +- ask the client to delete an existing cookie + +## With `cookiePlugin` + +```ts twoslash {2,7-9,22-24} +// @version: 0 + +``` + +In this example: + +- the plugin is registered once on the `Hub` +- every route registered in this `Hub` then benefits from input parsing and output serialization +- the route extracts `session` directly from `cookies` +- the handler also sends back a new cookie with `setCookie` + +This is the simplest approach when cookie support should be available across your whole application. + +You can also pass your own `parser` and `serializer` to the plugin. +This can be useful for signed cookies, custom encoding rules, or any project-specific format. + +If you use `cookiePlugin` globally, you can exclude a specific route with `IgnoreRouteCookieMetadata`. +This lets you keep the plugin enabled everywhere while automatically removing cookie hooks from some routes. + +## With hooks directly on one route + +On a route, there are three ways to register cookie hooks: + +- let `cookiePlugin` add them automatically +- add `cookieHooks` to get input parsing and output serialization at once +- add `parseRequestCookieHook` and `serializeResponseCookieHook` separately if you want more targeted behavior + +If you want both behaviors directly on a route without using the plugin, `cookieHooks` is the simplest form. + +```ts twoslash {2,6-9} +// @version: 0 + +``` + +Here, the route gets the standard plugin behavior, but only for itself. +This is often the most practical form when you want to enable cookies route by route. + +## With the two hooks separately on one route +```ts twoslash {2,5-12} +// @version: 0 + +``` + +This form is the most flexible. +It is useful when you only want one hook, or when input parsing and output serialization should be handled separately. diff --git a/docs/examples/v0/guide/plugins/cookie/cookieHooks.ts b/docs/examples/v0/guide/plugins/cookie/cookieHooks.ts new file mode 100644 index 0000000..90cac7b --- /dev/null +++ b/docs/examples/v0/guide/plugins/cookie/cookieHooks.ts @@ -0,0 +1,35 @@ +import { createHub, ResponseContract, useRouteBuilder } from "@duplojs/http"; +import { cookieHooks } from "@duplojs/http/cookie"; +import { DPE } from "@duplojs/utils"; + +const route = useRouteBuilder("GET", "/admin/session", { + hooks: [ + // defaultParser and defaultSerializer are use if nothing is given + cookieHooks(), + ], +}) + .extract({ + cookies: { + session: DPE.string(), + }, + }) + .handler( + ResponseContract.ok( + "admin.session.read", + DPE.object({ + session: DPE.string(), + }), + ), + ({ session }, { response }) => response( + "admin.session.read", + { + session, + }, + ).setCookie("admin-session", session, { + httpOnly: true, + path: "/admin", + }), + ); + +const hub = createHub({ environment: "DEV" }) + .register(route); diff --git a/docs/examples/v0/guide/plugins/cookie/plugin.ts b/docs/examples/v0/guide/plugins/cookie/plugin.ts new file mode 100644 index 0000000..9bb003a --- /dev/null +++ b/docs/examples/v0/guide/plugins/cookie/plugin.ts @@ -0,0 +1,25 @@ +import { createHub, ResponseContract, useRouteBuilder } from "@duplojs/http"; +import { cookiePlugin } from "@duplojs/http/cookie"; +import { DPE } from "@duplojs/utils"; + +const route = useRouteBuilder("GET", "/profile") + .extract({ + cookies: { + session: DPE.string(), + }, + }) + .handler( + ResponseContract.noContent("profile.read"), + (floor, { response }) => response("profile.read") + .setCookie("last-route", "profile", { + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ); + +const hub = createHub({ environment: "DEV" }) + .plug( + cookiePlugin(), + ) + .register(route); diff --git a/docs/examples/v0/guide/plugins/cookie/routeHooks.ts b/docs/examples/v0/guide/plugins/cookie/routeHooks.ts new file mode 100644 index 0000000..6a73ebf --- /dev/null +++ b/docs/examples/v0/guide/plugins/cookie/routeHooks.ts @@ -0,0 +1,13 @@ +import { useRouteBuilder } from "@duplojs/http"; +import { defaultParser, defaultSerializer, parseRequestCookieHook, serializeResponseCookieHook } from "@duplojs/http/cookie"; + +const route = useRouteBuilder("GET", "/admin/session", { + hooks: [ + parseRequestCookieHook({ + parser: defaultParser, // or custom parser + }), + serializeResponseCookieHook({ + serializer: defaultSerializer, // or custom serializer + }), + ], +}); // ... diff --git a/docs/fr/v0/guide/features/formData.md b/docs/fr/v0/guide/features/formData.md index 4cc8c7c..9a5835e 100644 --- a/docs/fr/v0/guide/features/formData.md +++ b/docs/fr/v0/guide/features/formData.md @@ -1,8 +1,8 @@ --- description: "Gérer des FormData à structure complexe." prev: - text: "Contrôle du cache" - link: "/fr/v0/guide/plugins/cacheController" + text: "Gestion cookie" + link: "/fr/v0/guide/plugins/cookie" next: text: "Server-Sent Events (SSE)" link: "/fr/v0/guide/features/SSE" diff --git a/docs/fr/v0/guide/plugins/cacheController.md b/docs/fr/v0/guide/plugins/cacheController.md index 8a4662c..0e4ac85 100644 --- a/docs/fr/v0/guide/plugins/cacheController.md +++ b/docs/fr/v0/guide/plugins/cacheController.md @@ -4,8 +4,8 @@ prev: text: "Gestion CORS" link: "/fr/v0/guide/plugins/cors" next: - text: "FormData avancés" - link: "/fr/v0/guide/features/formData" + text: "Gestion cookie" + link: "/fr/v0/guide/plugins/cookie" --- # Contrôle du cache diff --git a/docs/fr/v0/guide/plugins/cookie.md b/docs/fr/v0/guide/plugins/cookie.md new file mode 100644 index 0000000..bf49249 --- /dev/null +++ b/docs/fr/v0/guide/plugins/cookie.md @@ -0,0 +1,68 @@ +--- +description: "Parser et sérialiser les cookies HTTP" +prev: + text: "Contrôle du cache" + link: "/fr/v0/guide/plugins/cacheController" +next: + text: "FormData avancés" + link: "/fr/v0/guide/features/formData" +--- + +# Gestion cookie + +`@duplojs/http/cookie` permet de lire les cookies envoyés par le client et d'en renvoyer facilement dans les réponses. + +Il est utile si vous voulez : + +- récupérer une valeur depuis les cookies d'entrée +- poser un nouveau cookie dans une réponse +- demander au client de supprimer un cookie existant + +## Avec `cookiePlugin` + +```ts twoslash {2,7-9,22-24} +// @version: 0 + +``` + +Dans cet exemple : + +- le plugin est branché une fois sur le `Hub` +- toutes les routes enregistrées dans ce `Hub` bénéficient alors du parsing d'entrée et de la sérialisation de sortie +- la route extrait directement `session` depuis `cookies` +- le handler renvoie aussi un nouveau cookie avec `setCookie` + +Cette approche est la plus simple si le support des cookies doit être disponible partout dans votre application. + +Vous pouvez aussi passer votre propre `parser` et votre propre `serializer` au plugin. +Cela permet par exemple de gérer des cookies signés, de centraliser une logique d'encodage particulière, ou d'adapter le format à une contrainte métier. + +Si vous utilisez `cookiePlugin` globalement, vous pouvez exclure une route précise avec `IgnoreRouteCookieMetadata`. +Cela permet de garder le plugin activé partout, tout en retirant automatiquement les hooks cookie sur certaines routes. + +## Avec les hooks directement sur une route + +Sur une route, il existe trois façons de brancher les hooks cookie : + +- laisser `cookiePlugin` les ajouter automatiquement +- ajouter `cookieHooks` pour récupérer le parsing d'entrée et la sérialisation de sortie d'un coup +- ajouter `parseRequestCookieHook` et `serializeResponseCookieHook` séparément si vous voulez un comportement plus ciblé + +Si vous voulez brancher les deux comportements directement sur une route sans passer par le plugin, `cookieHooks` est la forme la plus simple. + +```ts twoslash {2,6-9} +// @version: 0 + +``` + +Ici, la route récupère le comportement standard du plugin, mais seulement pour elle. +C'est souvent la forme la plus pratique si vous voulez activer les cookies route par route. + +## Avec les deux hooks séparés sur une route +```ts twoslash {2,5-12} +// @version: 0 + +``` + +Cette forme est la plus flexible. +Elle est utile si vous voulez n'ajouter qu'un seul hook, ou traiter séparément le parsing d'entrée et la sérialisation de sortie. diff --git a/docs/libs/v0/core/functionsBuilders/router/create.d.ts b/docs/libs/v0/core/functionsBuilders/router/create.d.ts index 34d4f2d..aec6ad7 100644 --- a/docs/libs/v0/core/functionsBuilders/router/create.d.ts +++ b/docs/libs/v0/core/functionsBuilders/router/create.d.ts @@ -1,9 +1,9 @@ -import { MaybePromise } from "@duplojs/utils"; import { type Request } from "../../request"; import { type BuildedRouter } from "../../router"; import { type RouterElementSystem } from "../../router/types/routerElementSystem"; import { type RouterElementWrapper } from "../../router/types/routerElementWrapper"; import { type Environment } from "../../types"; +import { type MaybePromise } from "@duplojs/utils"; export interface RouterFunctionBuilderParams { readonly environment: Environment; readonly routerElementWrapper: RouterElementWrapper; diff --git a/docs/libs/v0/plugins/cookie/hooks/cookieHooks.cjs b/docs/libs/v0/plugins/cookie/hooks/cookieHooks.cjs new file mode 100644 index 0000000..0d26af4 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/hooks/cookieHooks.cjs @@ -0,0 +1,15 @@ +'use strict'; + +var parser = require('../parser.cjs'); +var serialize = require('../serialize.cjs'); +var parseRequestCookie = require('./parseRequestCookie.cjs'); +var serializeResponseCookie = require('./serializeResponseCookie.cjs'); + +function cookieHooks({ parser: parser$1 = parser.defaultParser, serializer = serialize.defaultSerializer, } = {}) { + return { + ...parseRequestCookie.parseRequestCookieHook({ parser: parser$1 }), + ...serializeResponseCookie.serializeResponseCookieHook({ serializer }), + }; +} + +exports.cookieHooks = cookieHooks; diff --git a/docs/libs/v0/plugins/cookie/hooks/cookieHooks.d.ts b/docs/libs/v0/plugins/cookie/hooks/cookieHooks.d.ts new file mode 100644 index 0000000..0dde466 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/hooks/cookieHooks.d.ts @@ -0,0 +1,11 @@ +import { type Parser } from "../parser"; +import { type Serializer } from "../serialize"; +interface CookieHooksParams { + parser?: Parser; + serializer?: Serializer; +} +export declare function cookieHooks({ parser, serializer, }?: CookieHooksParams): { + beforeSendResponse: ({ currentResponse, next }: import("../../../core/route").RouteHookParamsAfter) => import("../../../core/route").RouteHookNext; + beforeRouteExecution: ({ request, next }: import("../../../core/route").RouteHookParams) => import("../../../core/route").RouteHookNext; +}; +export {}; diff --git a/docs/libs/v0/plugins/cookie/hooks/cookieHooks.mjs b/docs/libs/v0/plugins/cookie/hooks/cookieHooks.mjs new file mode 100644 index 0000000..a0e3652 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/hooks/cookieHooks.mjs @@ -0,0 +1,13 @@ +import { defaultParser } from '../parser.mjs'; +import { defaultSerializer } from '../serialize.mjs'; +import { parseRequestCookieHook } from './parseRequestCookie.mjs'; +import { serializeResponseCookieHook } from './serializeResponseCookie.mjs'; + +function cookieHooks({ parser = defaultParser, serializer = defaultSerializer, } = {}) { + return { + ...parseRequestCookieHook({ parser }), + ...serializeResponseCookieHook({ serializer }), + }; +} + +export { cookieHooks }; diff --git a/docs/libs/v0/plugins/cookie/hooks/index.cjs b/docs/libs/v0/plugins/cookie/hooks/index.cjs new file mode 100644 index 0000000..940bddf --- /dev/null +++ b/docs/libs/v0/plugins/cookie/hooks/index.cjs @@ -0,0 +1,11 @@ +'use strict'; + +var parseRequestCookie = require('./parseRequestCookie.cjs'); +var serializeResponseCookie = require('./serializeResponseCookie.cjs'); +var cookieHooks = require('./cookieHooks.cjs'); + + + +exports.parseRequestCookieHook = parseRequestCookie.parseRequestCookieHook; +exports.serializeResponseCookieHook = serializeResponseCookie.serializeResponseCookieHook; +exports.cookieHooks = cookieHooks.cookieHooks; diff --git a/docs/libs/v0/plugins/cookie/hooks/index.d.ts b/docs/libs/v0/plugins/cookie/hooks/index.d.ts new file mode 100644 index 0000000..a63fb79 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/hooks/index.d.ts @@ -0,0 +1,3 @@ +export * from "./parseRequestCookie"; +export * from "./serializeResponseCookie"; +export * from "./cookieHooks"; diff --git a/docs/libs/v0/plugins/cookie/hooks/index.mjs b/docs/libs/v0/plugins/cookie/hooks/index.mjs new file mode 100644 index 0000000..2982ba6 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/hooks/index.mjs @@ -0,0 +1,3 @@ +export { parseRequestCookieHook } from './parseRequestCookie.mjs'; +export { serializeResponseCookieHook } from './serializeResponseCookie.mjs'; +export { cookieHooks } from './cookieHooks.mjs'; diff --git a/docs/libs/v0/plugins/cookie/hooks/parseRequestCookie.cjs b/docs/libs/v0/plugins/cookie/hooks/parseRequestCookie.cjs new file mode 100644 index 0000000..ad13a88 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/hooks/parseRequestCookie.cjs @@ -0,0 +1,20 @@ +'use strict'; + +require('../../../core/route/index.cjs'); +var hooks = require('../../../core/route/hooks.cjs'); + +function parseRequestCookieHook(params) { + return hooks.createHookRouteLifeCycle({ + beforeRouteExecution: ({ request, next }) => { + if (request.headers.cookie) { + const cookieValue = Array.isArray(request.headers.cookie) + ? request.headers.cookie.join("; ") + : request.headers.cookie; + request.cookies = params.parser(cookieValue); + } + return next(); + }, + }); +} + +exports.parseRequestCookieHook = parseRequestCookieHook; diff --git a/docs/libs/v0/plugins/cookie/hooks/parseRequestCookie.d.ts b/docs/libs/v0/plugins/cookie/hooks/parseRequestCookie.d.ts new file mode 100644 index 0000000..3bcdaf3 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/hooks/parseRequestCookie.d.ts @@ -0,0 +1,8 @@ +import { type Parser } from "../parser"; +interface ParseRequestCookieHookParams { + parser: Parser; +} +export declare function parseRequestCookieHook(params: ParseRequestCookieHookParams): { + readonly beforeRouteExecution: ({ request, next }: import("../../../core/route").RouteHookParams) => import("../../../core/route").RouteHookNext; +}; +export {}; diff --git a/docs/libs/v0/plugins/cookie/hooks/parseRequestCookie.mjs b/docs/libs/v0/plugins/cookie/hooks/parseRequestCookie.mjs new file mode 100644 index 0000000..ad03c5c --- /dev/null +++ b/docs/libs/v0/plugins/cookie/hooks/parseRequestCookie.mjs @@ -0,0 +1,18 @@ +import '../../../core/route/index.mjs'; +import { createHookRouteLifeCycle } from '../../../core/route/hooks.mjs'; + +function parseRequestCookieHook(params) { + return createHookRouteLifeCycle({ + beforeRouteExecution: ({ request, next }) => { + if (request.headers.cookie) { + const cookieValue = Array.isArray(request.headers.cookie) + ? request.headers.cookie.join("; ") + : request.headers.cookie; + request.cookies = params.parser(cookieValue); + } + return next(); + }, + }); +} + +export { parseRequestCookieHook }; diff --git a/docs/libs/v0/plugins/cookie/hooks/serializeResponseCookie.cjs b/docs/libs/v0/plugins/cookie/hooks/serializeResponseCookie.cjs new file mode 100644 index 0000000..974bfc2 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/hooks/serializeResponseCookie.cjs @@ -0,0 +1,17 @@ +'use strict'; + +require('../../../core/route/index.cjs'); +var hooks = require('../../../core/route/hooks.cjs'); + +function serializeResponseCookieHook(params) { + return hooks.createHookRouteLifeCycle({ + beforeSendResponse: ({ currentResponse, next }) => { + if (currentResponse.cookie !== undefined && Object.keys(currentResponse.cookie).length !== 0) { + currentResponse.setHeader("set-cookie", Object.entries(currentResponse.cookie).map(([name, content]) => params.serializer(name, content.value, content.params))); + } + return next(); + }, + }); +} + +exports.serializeResponseCookieHook = serializeResponseCookieHook; diff --git a/docs/libs/v0/plugins/cookie/hooks/serializeResponseCookie.d.ts b/docs/libs/v0/plugins/cookie/hooks/serializeResponseCookie.d.ts new file mode 100644 index 0000000..6e20134 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/hooks/serializeResponseCookie.d.ts @@ -0,0 +1,7 @@ +import { type Serializer } from "../serialize"; +export interface SerializeResponseCookieHookParams { + serializer: Serializer; +} +export declare function serializeResponseCookieHook(params: SerializeResponseCookieHookParams): { + readonly beforeSendResponse: ({ currentResponse, next }: import("../../../core/route").RouteHookParamsAfter) => import("../../../core/route").RouteHookNext; +}; diff --git a/docs/libs/v0/plugins/cookie/hooks/serializeResponseCookie.mjs b/docs/libs/v0/plugins/cookie/hooks/serializeResponseCookie.mjs new file mode 100644 index 0000000..3c08355 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/hooks/serializeResponseCookie.mjs @@ -0,0 +1,15 @@ +import '../../../core/route/index.mjs'; +import { createHookRouteLifeCycle } from '../../../core/route/hooks.mjs'; + +function serializeResponseCookieHook(params) { + return createHookRouteLifeCycle({ + beforeSendResponse: ({ currentResponse, next }) => { + if (currentResponse.cookie !== undefined && Object.keys(currentResponse.cookie).length !== 0) { + currentResponse.setHeader("set-cookie", Object.entries(currentResponse.cookie).map(([name, content]) => params.serializer(name, content.value, content.params))); + } + return next(); + }, + }); +} + +export { serializeResponseCookieHook }; diff --git a/docs/libs/v0/plugins/cookie/index.cjs b/docs/libs/v0/plugins/cookie/index.cjs new file mode 100644 index 0000000..32a0ae6 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/index.cjs @@ -0,0 +1,25 @@ +'use strict'; + +var parser = require('./parser.cjs'); +var serialize = require('./serialize.cjs'); +require('./override.cjs'); +var metadata = require('./metadata.cjs'); +require('./hooks/index.cjs'); +var plugin = require('./plugin.cjs'); +var parseRequestCookie = require('./hooks/parseRequestCookie.cjs'); +var serializeResponseCookie = require('./hooks/serializeResponseCookie.cjs'); +var cookieHooks = require('./hooks/cookieHooks.cjs'); + + + +exports.decode = parser.decode; +exports.defaultParser = parser.defaultParser; +exports.findPairEndIndex = parser.findPairEndIndex; +exports.sliceAndTrimOws = parser.sliceAndTrimOws; +exports.SerializeCookieError = serialize.SerializeCookieError; +exports.defaultSerializer = serialize.defaultSerializer; +exports.IgnoreRouteCookieMetadata = metadata.IgnoreRouteCookieMetadata; +exports.cookiePlugin = plugin.cookiePlugin; +exports.parseRequestCookieHook = parseRequestCookie.parseRequestCookieHook; +exports.serializeResponseCookieHook = serializeResponseCookie.serializeResponseCookieHook; +exports.cookieHooks = cookieHooks.cookieHooks; diff --git a/docs/libs/v0/plugins/cookie/index.d.ts b/docs/libs/v0/plugins/cookie/index.d.ts new file mode 100644 index 0000000..e83c099 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/index.d.ts @@ -0,0 +1,6 @@ +export * from "./parser"; +export * from "./serialize"; +export * from "./override"; +export * from "./metadata"; +export * from "./hooks"; +export * from "./plugin"; diff --git a/docs/libs/v0/plugins/cookie/index.mjs b/docs/libs/v0/plugins/cookie/index.mjs new file mode 100644 index 0000000..5d12574 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/index.mjs @@ -0,0 +1,9 @@ +export { decode, defaultParser, findPairEndIndex, sliceAndTrimOws } from './parser.mjs'; +export { SerializeCookieError, defaultSerializer } from './serialize.mjs'; +import './override.mjs'; +export { IgnoreRouteCookieMetadata } from './metadata.mjs'; +import './hooks/index.mjs'; +export { cookiePlugin } from './plugin.mjs'; +export { parseRequestCookieHook } from './hooks/parseRequestCookie.mjs'; +export { serializeResponseCookieHook } from './hooks/serializeResponseCookie.mjs'; +export { cookieHooks } from './hooks/cookieHooks.mjs'; diff --git a/docs/libs/v0/plugins/cookie/kind.cjs b/docs/libs/v0/plugins/cookie/kind.cjs new file mode 100644 index 0000000..cb8afc0 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/kind.cjs @@ -0,0 +1,9 @@ +'use strict'; + +var utils = require('@duplojs/utils'); + +const createCookiePluginKind = utils.createKindNamespace( +// @ts-expect-error reserved kind namespace +"DuplojsCookiePlugin"); + +exports.createCookiePluginKind = createCookiePluginKind; diff --git a/docs/libs/v0/plugins/cookie/kind.d.ts b/docs/libs/v0/plugins/cookie/kind.d.ts new file mode 100644 index 0000000..fec5da6 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/kind.d.ts @@ -0,0 +1,6 @@ +declare module "@duplojs/utils" { + interface ReservedKindNamespace { + DuplojsCookiePlugin: true; + } +} +export declare const createCookiePluginKind: (name: GenericName & import("@duplojs/utils/string").ForbiddenString) => import("@duplojs/utils").KindHandler>; diff --git a/docs/libs/v0/plugins/cookie/kind.mjs b/docs/libs/v0/plugins/cookie/kind.mjs new file mode 100644 index 0000000..28dd914 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/kind.mjs @@ -0,0 +1,7 @@ +import { createKindNamespace } from '@duplojs/utils'; + +const createCookiePluginKind = createKindNamespace( +// @ts-expect-error reserved kind namespace +"DuplojsCookiePlugin"); + +export { createCookiePluginKind }; diff --git a/docs/libs/v0/plugins/cookie/metadata.cjs b/docs/libs/v0/plugins/cookie/metadata.cjs new file mode 100644 index 0000000..4db7356 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/metadata.cjs @@ -0,0 +1,8 @@ +'use strict'; + +require('../../core/metadata/index.cjs'); +var base = require('../../core/metadata/base.cjs'); + +const IgnoreRouteCookieMetadata = base.createMetadata("ignore-by-cookie"); + +exports.IgnoreRouteCookieMetadata = IgnoreRouteCookieMetadata; diff --git a/docs/libs/v0/plugins/cookie/metadata.d.ts b/docs/libs/v0/plugins/cookie/metadata.d.ts new file mode 100644 index 0000000..3b27359 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/metadata.d.ts @@ -0,0 +1 @@ +export declare const IgnoreRouteCookieMetadata: import("../../core/metadata").MetadataHandler<"ignore-by-cookie", unknown>; diff --git a/docs/libs/v0/plugins/cookie/metadata.mjs b/docs/libs/v0/plugins/cookie/metadata.mjs new file mode 100644 index 0000000..e555914 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/metadata.mjs @@ -0,0 +1,6 @@ +import '../../core/metadata/index.mjs'; +import { createMetadata } from '../../core/metadata/base.mjs'; + +const IgnoreRouteCookieMetadata = createMetadata("ignore-by-cookie"); + +export { IgnoreRouteCookieMetadata }; diff --git a/docs/libs/v0/plugins/cookie/override.cjs b/docs/libs/v0/plugins/cookie/override.cjs new file mode 100644 index 0000000..9d2a27e --- /dev/null +++ b/docs/libs/v0/plugins/cookie/override.cjs @@ -0,0 +1,30 @@ +'use strict'; + +var index = require('../../core/request/index.cjs'); +require('../../core/response/index.cjs'); +var base = require('../../core/response/base.cjs'); + +index.Request.prototype.cookies = undefined; +base.Response.prototype.cookie = undefined; +base.Response.prototype.setCookie = function (name, value, params) { + if (!this.cookie) { + this.cookie = {}; + } + this.cookie[name] = { + value, + params, + }; + return this; +}; +base.Response.prototype.dropCookie = function (name) { + if (!this.cookie) { + this.cookie = {}; + } + this.cookie[name] = { + value: "", + params: { + maxAge: 0, + }, + }; + return this; +}; diff --git a/docs/libs/v0/plugins/cookie/override.d.ts b/docs/libs/v0/plugins/cookie/override.d.ts new file mode 100644 index 0000000..3bbc2d3 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/override.d.ts @@ -0,0 +1,16 @@ +import type { SerializerParams } from "./serialize"; +declare module "../../core/request" { + interface Request { + cookies?: Partial>; + } +} +declare module "../../core/response" { + interface Response { + cookie?: Record; + setCookie(name: string, value: string, params?: SerializerParams): this; + dropCookie(name: string): this; + } +} diff --git a/docs/libs/v0/plugins/cookie/override.mjs b/docs/libs/v0/plugins/cookie/override.mjs new file mode 100644 index 0000000..5d74517 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/override.mjs @@ -0,0 +1,28 @@ +import { Request } from '../../core/request/index.mjs'; +import '../../core/response/index.mjs'; +import { Response } from '../../core/response/base.mjs'; + +Request.prototype.cookies = undefined; +Response.prototype.cookie = undefined; +Response.prototype.setCookie = function (name, value, params) { + if (!this.cookie) { + this.cookie = {}; + } + this.cookie[name] = { + value, + params, + }; + return this; +}; +Response.prototype.dropCookie = function (name) { + if (!this.cookie) { + this.cookie = {}; + } + this.cookie[name] = { + value: "", + params: { + maxAge: 0, + }, + }; + return this; +}; diff --git a/docs/libs/v0/plugins/cookie/parser.cjs b/docs/libs/v0/plugins/cookie/parser.cjs new file mode 100644 index 0000000..60f7434 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/parser.cjs @@ -0,0 +1,84 @@ +'use strict'; + +/** + * @internal + */ +function findPairEndIndex(value, start, len) { + const index = value.indexOf(";", start); + return index === -1 ? len : index; +} +/** + * @internal + */ +function sliceAndTrimOws(value, min, max) { + if (min === max) { + return ""; + } + let start = min; + let end = max; + do { + const code = value.charCodeAt(start); + if (code !== 32 /* */ && code !== 9 /* \t */) { + break; + } + } while (++start < end); + while (end > start) { + const code = value.charCodeAt(end - 1); + if (code !== 32 /* */ && code !== 9 /* \t */) { + break; + } + end--; + } + return value.slice(start, end); +} +/** + * @internal + */ +function decode(value) { + if (!value.includes("%")) { + return value; + } + try { + return decodeURIComponent(value); + } + catch { + return value; + } +} +function defaultParser(value) { + const result = {}; + const valueLength = value.length; + if (valueLength < 2) { + return result; + } + let index = 0; + do { + const equalCharIndex = value.indexOf("=", index); + if (equalCharIndex === -1) { + break; + } + const pairEndIndex = findPairEndIndex(value, index, valueLength); + if (equalCharIndex > pairEndIndex) { + index = value.lastIndexOf(";", equalCharIndex - 1) + 1; + continue; + } + const key = sliceAndTrimOws(value, index, equalCharIndex); + if (key === "" + || key === "__proto__" + || key === "constructor" + || key === "prototype") { + index = pairEndIndex + 1; + continue; + } + if (result[key] === undefined) { + result[key] = decode(sliceAndTrimOws(value, equalCharIndex + 1, pairEndIndex)); + } + index = pairEndIndex + 1; + } while (index < valueLength); + return result; +} + +exports.decode = decode; +exports.defaultParser = defaultParser; +exports.findPairEndIndex = findPairEndIndex; +exports.sliceAndTrimOws = sliceAndTrimOws; diff --git a/docs/libs/v0/plugins/cookie/parser.d.ts b/docs/libs/v0/plugins/cookie/parser.d.ts new file mode 100644 index 0000000..8446ade --- /dev/null +++ b/docs/libs/v0/plugins/cookie/parser.d.ts @@ -0,0 +1,2 @@ +export declare function defaultParser(value: string): Partial>; +export type Parser = typeof defaultParser; diff --git a/docs/libs/v0/plugins/cookie/parser.mjs b/docs/libs/v0/plugins/cookie/parser.mjs new file mode 100644 index 0000000..ecae1b3 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/parser.mjs @@ -0,0 +1,79 @@ +/** + * @internal + */ +function findPairEndIndex(value, start, len) { + const index = value.indexOf(";", start); + return index === -1 ? len : index; +} +/** + * @internal + */ +function sliceAndTrimOws(value, min, max) { + if (min === max) { + return ""; + } + let start = min; + let end = max; + do { + const code = value.charCodeAt(start); + if (code !== 32 /* */ && code !== 9 /* \t */) { + break; + } + } while (++start < end); + while (end > start) { + const code = value.charCodeAt(end - 1); + if (code !== 32 /* */ && code !== 9 /* \t */) { + break; + } + end--; + } + return value.slice(start, end); +} +/** + * @internal + */ +function decode(value) { + if (!value.includes("%")) { + return value; + } + try { + return decodeURIComponent(value); + } + catch { + return value; + } +} +function defaultParser(value) { + const result = {}; + const valueLength = value.length; + if (valueLength < 2) { + return result; + } + let index = 0; + do { + const equalCharIndex = value.indexOf("=", index); + if (equalCharIndex === -1) { + break; + } + const pairEndIndex = findPairEndIndex(value, index, valueLength); + if (equalCharIndex > pairEndIndex) { + index = value.lastIndexOf(";", equalCharIndex - 1) + 1; + continue; + } + const key = sliceAndTrimOws(value, index, equalCharIndex); + if (key === "" + || key === "__proto__" + || key === "constructor" + || key === "prototype") { + index = pairEndIndex + 1; + continue; + } + if (result[key] === undefined) { + result[key] = decode(sliceAndTrimOws(value, equalCharIndex + 1, pairEndIndex)); + } + index = pairEndIndex + 1; + } while (index < valueLength); + return result; +} + +export { decode, defaultParser, findPairEndIndex, sliceAndTrimOws }; diff --git a/docs/libs/v0/plugins/cookie/plugin.cjs b/docs/libs/v0/plugins/cookie/plugin.cjs new file mode 100644 index 0000000..7e03804 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/plugin.cjs @@ -0,0 +1,30 @@ +'use strict'; + +var utils = require('@duplojs/utils'); +require('./hooks/index.cjs'); +var metadata = require('./metadata.cjs'); +var cookieHooks = require('./hooks/cookieHooks.cjs'); + +function cookiePlugin(params) { + return () => ({ + name: "cookie-plugin", + hooksHubLifeCycle: [ + { + beforeBuildRoute: (route) => { + if (utils.A.some(route.definition.metadata, metadata.IgnoreRouteCookieMetadata.is)) { + return route; + } + return { + ...route, + definition: { + ...route.definition, + hooks: [...route.definition.hooks, cookieHooks.cookieHooks(params)], + }, + }; + }, + }, + ], + }); +} + +exports.cookiePlugin = cookiePlugin; diff --git a/docs/libs/v0/plugins/cookie/plugin.d.ts b/docs/libs/v0/plugins/cookie/plugin.d.ts new file mode 100644 index 0000000..6eb8ec4 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/plugin.d.ts @@ -0,0 +1,8 @@ +import type { HubPlugin } from "../../core/hub"; +import type { Parser } from "./parser"; +import type { Serializer } from "./serialize"; +export interface CookiePluginParams { + parser?: Parser; + serializer?: Serializer; +} +export declare function cookiePlugin(params?: CookiePluginParams): () => HubPlugin; diff --git a/docs/libs/v0/plugins/cookie/plugin.mjs b/docs/libs/v0/plugins/cookie/plugin.mjs new file mode 100644 index 0000000..a9db640 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/plugin.mjs @@ -0,0 +1,28 @@ +import { A } from '@duplojs/utils'; +import './hooks/index.mjs'; +import { IgnoreRouteCookieMetadata } from './metadata.mjs'; +import { cookieHooks } from './hooks/cookieHooks.mjs'; + +function cookiePlugin(params) { + return () => ({ + name: "cookie-plugin", + hooksHubLifeCycle: [ + { + beforeBuildRoute: (route) => { + if (A.some(route.definition.metadata, IgnoreRouteCookieMetadata.is)) { + return route; + } + return { + ...route, + definition: { + ...route.definition, + hooks: [...route.definition.hooks, cookieHooks(params)], + }, + }; + }, + }, + ], + }); +} + +export { cookiePlugin }; diff --git a/docs/libs/v0/plugins/cookie/serialize.cjs b/docs/libs/v0/plugins/cookie/serialize.cjs new file mode 100644 index 0000000..0215ec1 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/serialize.cjs @@ -0,0 +1,84 @@ +'use strict'; + +var utils = require('@duplojs/utils'); +var kind = require('./kind.cjs'); + +const nameRegex = /^[!#$%&'*+\-.^_`|~A-Za-z0-9]+$/; +const domainValueRegex = /^([.]?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)([.][a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i; +const pathValueRegex = /^[\u0020-\u003A\u003C-\u007E]*$/; +class SerializeCookieError extends utils.kindHeritage("serialize-cookie-error", kind.createCookiePluginKind("serialize-cookie-error"), Error) { + constructor(message) { + super({}, [message]); + } +} +function defaultSerializer(name, value, params) { + if (!nameRegex.test(name)) { + throw new SerializeCookieError(`argument name is invalid: ${name}`); + } + let encodedValue = ""; + try { + encodedValue = encodeURIComponent(value); + } + catch { + throw new SerializeCookieError(`argument value is invalid: ${value}`); + } + let setCookie = `${name}=${encodedValue}`; + if (params?.maxAge !== undefined) { + if (!Number.isInteger(params.maxAge)) { + throw new SerializeCookieError(`param maxAge is invalid: ${params.maxAge}`); + } + setCookie += `; Max-Age=${params.maxAge}`; + } + if (params?.domain) { + if (!domainValueRegex.test(params.domain)) { + throw new SerializeCookieError(`param domain is invalid: ${params.domain}`); + } + setCookie += `; Domain=${params.domain}`; + } + if (params?.path) { + if (!pathValueRegex.test(params.path)) { + throw new SerializeCookieError(`param path is invalid: ${params.path}`); + } + setCookie += `; Path=${params.path}`; + } + if (params?.expires && params?.expireIn) { + throw new SerializeCookieError("params expires and expireIn are mutually exclusive"); + } + if (params?.expires) { + setCookie += `; Expires=${params.expires.toUTCString()}`; + } + if (params?.expireIn !== undefined) { + setCookie += `; Expires=${utils.D.addTime(utils.D.now(), params.expireIn).toUTCString()}`; + } + if (params?.httpOnly) { + setCookie += "; HttpOnly"; + } + if (params?.secure) { + setCookie += "; Secure"; + } + if (params?.partitioned) { + setCookie += "; Partitioned"; + } + if (params?.priority === "high") { + setCookie += "; Priority=High"; + } + else if (params?.priority === "low") { + setCookie += "; Priority=Low"; + } + else if (params?.priority === "medium") { + setCookie += "; Priority=Medium"; + } + if (params?.sameSite === "strict") { + setCookie += "; SameSite=Strict"; + } + else if (params?.sameSite === "lax") { + setCookie += "; SameSite=Lax"; + } + else if (params?.sameSite === "none") { + setCookie += "; SameSite=None"; + } + return setCookie; +} + +exports.SerializeCookieError = SerializeCookieError; +exports.defaultSerializer = defaultSerializer; diff --git a/docs/libs/v0/plugins/cookie/serialize.d.ts b/docs/libs/v0/plugins/cookie/serialize.d.ts new file mode 100644 index 0000000..4db42f2 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/serialize.d.ts @@ -0,0 +1,29 @@ +import { D } from "@duplojs/utils"; +declare const SerializeCookieError_base: new (params: { + "@DuplojsCookiePlugin/serialize-cookie-error"?: unknown; +}, parentParams: readonly [message?: string | undefined, options?: ErrorOptions | undefined]) => import("@duplojs/utils").Kind, unknown> & import("@duplojs/utils").Kind, unknown> & Error; +export declare class SerializeCookieError extends SerializeCookieError_base { + constructor(message: string); +} +interface SerializerParamsBase { + maxAge?: number; + domain?: string; + path?: string; + httpOnly?: boolean; + secure?: boolean; + partitioned?: boolean; + priority?: "low" | "medium" | "high"; + sameSite?: "lax" | "strict" | "none"; +} +export interface SerializerParamsWithExpires extends SerializerParamsBase { + expires?: D.TheDate; + expireIn?: undefined; +} +export interface SerializerParamsWithExpireIn extends SerializerParamsBase { + expires?: undefined; + expireIn?: D.TheTime; +} +export type SerializerParams = SerializerParamsWithExpires | SerializerParamsWithExpireIn; +export declare function defaultSerializer(name: string, value: string, params?: SerializerParams): string; +export type Serializer = typeof defaultSerializer; +export {}; diff --git a/docs/libs/v0/plugins/cookie/serialize.mjs b/docs/libs/v0/plugins/cookie/serialize.mjs new file mode 100644 index 0000000..dfb2c13 --- /dev/null +++ b/docs/libs/v0/plugins/cookie/serialize.mjs @@ -0,0 +1,81 @@ +import { kindHeritage, D } from '@duplojs/utils'; +import { createCookiePluginKind } from './kind.mjs'; + +const nameRegex = /^[!#$%&'*+\-.^_`|~A-Za-z0-9]+$/; +const domainValueRegex = /^([.]?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)([.][a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i; +const pathValueRegex = /^[\u0020-\u003A\u003C-\u007E]*$/; +class SerializeCookieError extends kindHeritage("serialize-cookie-error", createCookiePluginKind("serialize-cookie-error"), Error) { + constructor(message) { + super({}, [message]); + } +} +function defaultSerializer(name, value, params) { + if (!nameRegex.test(name)) { + throw new SerializeCookieError(`argument name is invalid: ${name}`); + } + let encodedValue = ""; + try { + encodedValue = encodeURIComponent(value); + } + catch { + throw new SerializeCookieError(`argument value is invalid: ${value}`); + } + let setCookie = `${name}=${encodedValue}`; + if (params?.maxAge !== undefined) { + if (!Number.isInteger(params.maxAge)) { + throw new SerializeCookieError(`param maxAge is invalid: ${params.maxAge}`); + } + setCookie += `; Max-Age=${params.maxAge}`; + } + if (params?.domain) { + if (!domainValueRegex.test(params.domain)) { + throw new SerializeCookieError(`param domain is invalid: ${params.domain}`); + } + setCookie += `; Domain=${params.domain}`; + } + if (params?.path) { + if (!pathValueRegex.test(params.path)) { + throw new SerializeCookieError(`param path is invalid: ${params.path}`); + } + setCookie += `; Path=${params.path}`; + } + if (params?.expires && params?.expireIn) { + throw new SerializeCookieError("params expires and expireIn are mutually exclusive"); + } + if (params?.expires) { + setCookie += `; Expires=${params.expires.toUTCString()}`; + } + if (params?.expireIn !== undefined) { + setCookie += `; Expires=${D.addTime(D.now(), params.expireIn).toUTCString()}`; + } + if (params?.httpOnly) { + setCookie += "; HttpOnly"; + } + if (params?.secure) { + setCookie += "; Secure"; + } + if (params?.partitioned) { + setCookie += "; Partitioned"; + } + if (params?.priority === "high") { + setCookie += "; Priority=High"; + } + else if (params?.priority === "low") { + setCookie += "; Priority=Low"; + } + else if (params?.priority === "medium") { + setCookie += "; Priority=Medium"; + } + if (params?.sameSite === "strict") { + setCookie += "; SameSite=Strict"; + } + else if (params?.sameSite === "lax") { + setCookie += "; SameSite=Lax"; + } + else if (params?.sameSite === "none") { + setCookie += "; SameSite=None"; + } + return setCookie; +} + +export { SerializeCookieError, defaultSerializer }; diff --git a/docs/libs/v0/plugins/cors/headerFunctions/vary.cjs b/docs/libs/v0/plugins/cors/headerFunctions/vary.cjs index 2ad2d7d..f50047d 100644 --- a/docs/libs/v0/plugins/cors/headerFunctions/vary.cjs +++ b/docs/libs/v0/plugins/cors/headerFunctions/vary.cjs @@ -12,9 +12,9 @@ const varyFunction = { response.setHeader("vary", cachedVary); return; } - let varyValue = Array.isArray(response.headers?.Vary) - ? response.headers.Vary.join(", ") - : response.headers?.Vary; + let varyValue = Array.isArray(response.headers?.vary) + ? response.headers.vary.join(", ") + : response.headers?.vary; if (varyValue === undefined) { varyValue = "Origin"; } diff --git a/docs/libs/v0/plugins/cors/headerFunctions/vary.mjs b/docs/libs/v0/plugins/cors/headerFunctions/vary.mjs index 55e17bf..71571fd 100644 --- a/docs/libs/v0/plugins/cors/headerFunctions/vary.mjs +++ b/docs/libs/v0/plugins/cors/headerFunctions/vary.mjs @@ -10,9 +10,9 @@ const varyFunction = { response.setHeader("vary", cachedVary); return; } - let varyValue = Array.isArray(response.headers?.Vary) - ? response.headers.Vary.join(", ") - : response.headers?.Vary; + let varyValue = Array.isArray(response.headers?.vary) + ? response.headers.vary.join(", ") + : response.headers?.vary; if (varyValue === undefined) { varyValue = "Origin"; } diff --git a/docs/tsconfig.v0.json b/docs/tsconfig.v0.json index e0b650c..2e3ac40 100644 --- a/docs/tsconfig.v0.json +++ b/docs/tsconfig.v0.json @@ -11,6 +11,7 @@ "@duplojs/http/static": ["libs/v0/plugins/static/index"], "@duplojs/http/cors": ["libs/v0/plugins/cors/index"], "@duplojs/http/cacheController": ["libs/v0/plugins/cacheController/index"], + "@duplojs/http/cookie": ["libs/v0/plugins/cookie/index"], }, "types": ["web"] }, diff --git a/integration/.commands/test-types.sh b/integration/.commands/test-types.sh index c2cff9d..9dd5e34 100755 --- a/integration/.commands/test-types.sh +++ b/integration/.commands/test-types.sh @@ -13,4 +13,5 @@ tsc -p codeGenerator/tsconfig.json tsc -p openApiGenerator/tsconfig.json tsc -p static/tsconfig.json tsc -p cacheController/tsconfig.json -tsc -p cors/tsconfig.json \ No newline at end of file +tsc -p cors/tsconfig.json +tsc -p cookie/tsconfig.json \ No newline at end of file diff --git a/integration/codeGenerator/__snapshots__/index.test.ts.snap b/integration/codeGenerator/__snapshots__/index.test.ts.snap index 6c65805..e35e18e 100644 --- a/integration/codeGenerator/__snapshots__/index.test.ts.snap +++ b/integration/codeGenerator/__snapshots__/index.test.ts.snap @@ -122,6 +122,28 @@ export type Routes = { information: "close"; body?: undefined; }; +} | { + method: "GET"; + path: "/cookie-check"; + responses: { + code: "422"; + information: "extract-error"; + body?: undefined; + } | { + code: "200"; + information: "cookie.checked"; + body: { + session: string; + }; + }; +} | { + method: "GET"; + path: "/cookie-drop"; + responses: { + code: "204"; + information: "cookie.dropped"; + body?: undefined; + }; } | { method: "GET"; path: "/static-file"; diff --git a/integration/cookie/index.test.ts b/integration/cookie/index.test.ts new file mode 100644 index 0000000..5017100 --- /dev/null +++ b/integration/cookie/index.test.ts @@ -0,0 +1,66 @@ +import { hub } from "@core"; +import { createHttpServer } from "@duplojs/http/node"; + +describe("cookie plugin", async() => { + const server = await createHttpServer(hub, { + host: "0.0.0.0", + port: 8962, + }); + + afterAll(() => { + server.close(); + }); + + it("expect good", async() => { + await expect( + fetch("http://localhost:8962/cookie-check", { + method: "GET", + headers: { + Cookie: "session=abc%20123", + }, + }).then(async(response) => ({ + status: response.status, + body: await response.json(), + headers: [...response.headers.entries()], + })), + ).resolves.toStrictEqual({ + status: 200, + headers: expect.arrayContaining([ + [ + "information", + "cookie.checked", + ], + [ + "set-cookie", + "refresh=next-token; Path=/; HttpOnly; SameSite=Lax", + ], + ]), + body: { + session: "abc 123", + }, + }); + }); + + it("drops cookie", async() => { + await expect( + fetch("http://localhost:8962/cookie-drop", { + method: "GET", + }).then((response) => ({ + status: response.status, + headers: [...response.headers.entries()], + })), + ).resolves.toStrictEqual({ + status: 204, + headers: expect.arrayContaining([ + [ + "information", + "cookie.dropped", + ], + [ + "set-cookie", + "session=; Max-Age=0", + ], + ]), + }); + }); +}); diff --git a/integration/cookie/tsconfig.json b/integration/cookie/tsconfig.json new file mode 100644 index 0000000..fb12669 --- /dev/null +++ b/integration/cookie/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.test.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@core": ["../core/index.ts"], + "@utils": ["../_utils/index.ts"], + }, + "types": ["vitest/globals", "node"] + }, + "include": ["**/*.ts", "../core/**/*.ts", "../_utils/**/*.ts"], +} diff --git a/integration/core/index.ts b/integration/core/index.ts index ef56f72..42fbd13 100644 --- a/integration/core/index.ts +++ b/integration/core/index.ts @@ -3,6 +3,7 @@ import { setCurrentWorkingDirectory, SF } from "@duplojs/server-utils"; import { createHub, routeStore } from "@duplojs/http"; import { staticPlugin } from "@duplojs/http/static"; import { corsPlugin } from "@duplojs/http/cors"; +import { cookiePlugin } from "@duplojs/http/cookie"; import "./routes"; @@ -34,4 +35,7 @@ export const hub = createHub({ environment: "DEV" }) exposeHeaders: ["info"], maxAge: 0, }), + ) + .plug( + cookiePlugin(), ); diff --git a/integration/core/routes/cookie.ts b/integration/core/routes/cookie.ts new file mode 100644 index 0000000..680e2ed --- /dev/null +++ b/integration/core/routes/cookie.ts @@ -0,0 +1,33 @@ +import { ResponseContract, useRouteBuilder } from "@duplojs/http"; +import { DPE } from "@duplojs/utils"; + +useRouteBuilder("GET", "/cookie-check") + .extract({ + cookies: { + session: DPE.string(), + }, + }) + .handler( + ResponseContract.ok( + "cookie.checked", + DPE.object({ + session: DPE.string(), + }), + ), + ({ session }, { response }) => response( + "cookie.checked", + { + session, + }, + ).setCookie("refresh", "next-token", { + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ); + +useRouteBuilder("GET", "/cookie-drop") + .handler( + ResponseContract.noContent("cookie.dropped"), + (__, { response }) => response("cookie.dropped").dropCookie("session"), + ); diff --git a/integration/core/routes/index.ts b/integration/core/routes/index.ts index 79787f0..dd4fcd2 100644 --- a/integration/core/routes/index.ts +++ b/integration/core/routes/index.ts @@ -1,3 +1,4 @@ import "./users"; import "./document"; import "./serverSentEvent"; +import "./cookie"; diff --git a/integration/openApiGenerator/__snapshots__/index.test.ts.snap b/integration/openApiGenerator/__snapshots__/index.test.ts.snap index 562154f..1b64659 100644 --- a/integration/openApiGenerator/__snapshots__/index.test.ts.snap +++ b/integration/openApiGenerator/__snapshots__/index.test.ts.snap @@ -279,6 +279,60 @@ exports[`openApiGenerator > correct generate file 1`] = ` } } }, + "/cookie-check": { + "get": { + "parameters": [], + "responses": { + "200": { + "headers": { + "information": { + "schema": { + "const": "cookie.checked", + "type": "string" + }, + "description": "cookie.checked" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotIdentified19" + } + } + } + }, + "422": { + "headers": { + "information": { + "schema": { + "const": "extract-error", + "type": "string" + }, + "description": "extract-error" + } + } + } + } + } + }, + "/cookie-drop": { + "get": { + "parameters": [], + "responses": { + "204": { + "headers": { + "information": { + "schema": { + "const": "cookie.dropped", + "type": "string" + }, + "description": "cookie.dropped" + } + } + } + } + } + }, "/static-file": { "get": { "parameters": [], @@ -296,7 +350,7 @@ exports[`openApiGenerator > correct generate file 1`] = ` "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotIdentified18" + "$ref": "#/components/schemas/NotIdentified21" } } } @@ -332,7 +386,7 @@ exports[`openApiGenerator > correct generate file 1`] = ` "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotIdentified20" + "$ref": "#/components/schemas/NotIdentified23" } } } @@ -600,17 +654,30 @@ exports[`openApiGenerator > correct generate file 1`] = ` ] }, "NotIdentified17": {}, - "NotIdentified18": { + "NotIdentified18": {}, + "NotIdentified19": { + "type": "object", + "properties": { + "session": { + "type": "string" + } + }, + "required": [ + "session" + ] + }, + "NotIdentified20": {}, + "NotIdentified21": { "type": "string", "format": "binary" }, - "NotIdentified19": {}, - "NotIdentified20": { + "NotIdentified22": {}, + "NotIdentified23": { "type": "string", "format": "binary" }, - "NotIdentified21": {}, - "NotIdentified22": {} + "NotIdentified24": {}, + "NotIdentified25": {} } } }" diff --git a/package.json b/package.json index 286e307..e5aa905 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "test:tu:watch": "vitest --coverage --watch", "test:tu:update": "vitest --coverage --update", "test:types": "./.commands/test-types.sh", - "test:lint": "./.commands/test-eslint.sh", - "test:lint:fix": "./.commands/test-eslint.sh --fix", + "test:lint": "./.commands/test-lint.sh", + "test:lint:fix": "./.commands/test-lint.sh --fix", "prepare": "husky" }, "types": "./dist/core/index.d.ts", @@ -67,6 +67,11 @@ "require": "./dist/plugins/cacheController/index.cjs", "types": "./dist/plugins/cacheController/index.d.ts" }, + "./cookie": { + "import": "./dist/plugins/cookie/index.mjs", + "require": "./dist/plugins/cookie/index.cjs", + "types": "./dist/plugins/cookie/index.d.ts" + }, "./static": { "import": "./dist/plugins/static/index.mjs", "require": "./dist/plugins/static/index.cjs", diff --git a/rollup.config.js b/rollup.config.js index 98095cf..19c4097 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -130,6 +130,31 @@ export default defineConfig([ tscAlias({ configFile: "scripts/plugins/cors/tsconfig.build.json" }), ], }, + { + input: "scripts/plugins/cookie/index.ts", + output: [ + { + dir: "dist", + format: "esm", + preserveModules: true, + preserveModulesRoot: "scripts", + entryFileNames: "[name].mjs" + }, + { + dir: "dist", + format: "cjs", + preserveModules: true, + preserveModulesRoot: "scripts", + entryFileNames: "[name].cjs" + }, + ], + treeshake: false, + plugins: [ + del({ targets: "dist/plugins/cookie" }), + typescript({ tsconfig: "scripts/plugins/cookie/tsconfig.build.json" }), + tscAlias({ configFile: "scripts/plugins/cookie/tsconfig.build.json" }), + ], + }, // interfaces { diff --git a/scripts/plugins/cookie/hooks/cookieHooks.ts b/scripts/plugins/cookie/hooks/cookieHooks.ts new file mode 100644 index 0000000..c74ede0 --- /dev/null +++ b/scripts/plugins/cookie/hooks/cookieHooks.ts @@ -0,0 +1,21 @@ +import { type Parser, defaultParser } from "../parser"; +import { type Serializer, defaultSerializer } from "../serialize"; +import { parseRequestCookieHook } from "./parseRequestCookie"; +import { serializeResponseCookieHook } from "./serializeResponseCookie"; + +interface CookieHooksParams { + parser?: Parser; + serializer?: Serializer; +} + +export function cookieHooks( + { + parser = defaultParser, + serializer = defaultSerializer, + }: CookieHooksParams = {}, +) { + return { + ...parseRequestCookieHook({ parser }), + ...serializeResponseCookieHook({ serializer }), + }; +} diff --git a/scripts/plugins/cookie/hooks/index.ts b/scripts/plugins/cookie/hooks/index.ts new file mode 100644 index 0000000..a63fb79 --- /dev/null +++ b/scripts/plugins/cookie/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./parseRequestCookie"; +export * from "./serializeResponseCookie"; +export * from "./cookieHooks"; diff --git a/scripts/plugins/cookie/hooks/parseRequestCookie.ts b/scripts/plugins/cookie/hooks/parseRequestCookie.ts new file mode 100644 index 0000000..ca86313 --- /dev/null +++ b/scripts/plugins/cookie/hooks/parseRequestCookie.ts @@ -0,0 +1,22 @@ +import { createHookRouteLifeCycle } from "@core/route"; +import { type Parser } from "../parser"; + +interface ParseRequestCookieHookParams { + parser: Parser; +} + +export function parseRequestCookieHook(params: ParseRequestCookieHookParams) { + return createHookRouteLifeCycle({ + beforeRouteExecution: ({ request, next }) => { + if (request.headers.cookie) { + const cookieValue = Array.isArray(request.headers.cookie) + ? request.headers.cookie.join("; ") + : request.headers.cookie; + + request.cookies = params.parser(cookieValue); + } + + return next(); + }, + }); +} diff --git a/scripts/plugins/cookie/hooks/serializeResponseCookie.ts b/scripts/plugins/cookie/hooks/serializeResponseCookie.ts new file mode 100644 index 0000000..d4f8727 --- /dev/null +++ b/scripts/plugins/cookie/hooks/serializeResponseCookie.ts @@ -0,0 +1,23 @@ +import { createHookRouteLifeCycle } from "@core/route"; +import { type Serializer } from "../serialize"; + +export interface SerializeResponseCookieHookParams { + serializer: Serializer; +} + +export function serializeResponseCookieHook(params: SerializeResponseCookieHookParams) { + return createHookRouteLifeCycle({ + beforeSendResponse: ({ currentResponse, next }) => { + if (currentResponse.cookie !== undefined && Object.keys(currentResponse.cookie).length !== 0) { + currentResponse.setHeader( + "set-cookie", + Object.entries(currentResponse.cookie).map( + ([name, content]) => params.serializer(name, content.value, content.params), + ), + ); + } + + return next(); + }, + }); +} diff --git a/scripts/plugins/cookie/index.ts b/scripts/plugins/cookie/index.ts new file mode 100644 index 0000000..e83c099 --- /dev/null +++ b/scripts/plugins/cookie/index.ts @@ -0,0 +1,6 @@ +export * from "./parser"; +export * from "./serialize"; +export * from "./override"; +export * from "./metadata"; +export * from "./hooks"; +export * from "./plugin"; diff --git a/scripts/plugins/cookie/kind.ts b/scripts/plugins/cookie/kind.ts new file mode 100644 index 0000000..d1b0b08 --- /dev/null +++ b/scripts/plugins/cookie/kind.ts @@ -0,0 +1,12 @@ +import { createKindNamespace } from "@duplojs/utils"; + +declare module "@duplojs/utils" { + interface ReservedKindNamespace { + DuplojsCookiePlugin: true; + } +} + +export const createCookiePluginKind = createKindNamespace( + // @ts-expect-error reserved kind namespace + "DuplojsCookiePlugin", +); diff --git a/scripts/plugins/cookie/metadata.ts b/scripts/plugins/cookie/metadata.ts new file mode 100644 index 0000000..19492a4 --- /dev/null +++ b/scripts/plugins/cookie/metadata.ts @@ -0,0 +1,3 @@ +import { createMetadata } from "@core/metadata"; + +export const IgnoreRouteCookieMetadata = createMetadata("ignore-by-cookie"); diff --git a/scripts/plugins/cookie/override.ts b/scripts/plugins/cookie/override.ts new file mode 100644 index 0000000..62b9c06 --- /dev/null +++ b/scripts/plugins/cookie/override.ts @@ -0,0 +1,52 @@ +import { Request } from "@core/request"; +import { Response } from "@core/response"; +import type { SerializerParams } from "./serialize"; + +declare module "@core/request" { + interface Request { + cookies?: Partial>; + } +} + +declare module "@core/response" { + interface Response { + cookie?: Record; + setCookie(name: string, value: string, params?: SerializerParams): this; + dropCookie(name: string): this; + } +} + +Request.prototype.cookies = undefined; + +Response.prototype.cookie = undefined; + +Response.prototype.setCookie = function(name, value, params) { + if (!this.cookie) { + this.cookie = {}; + } + + this.cookie![name] = { + value, + params, + }; + + return this; +}; + +Response.prototype.dropCookie = function(name) { + if (!this.cookie) { + this.cookie = {}; + } + + this.cookie![name] = { + value: "", + params: { + maxAge: 0, + }, + }; + + return this; +}; diff --git a/scripts/plugins/cookie/parser.ts b/scripts/plugins/cookie/parser.ts new file mode 100644 index 0000000..9348933 --- /dev/null +++ b/scripts/plugins/cookie/parser.ts @@ -0,0 +1,98 @@ +/** + * @internal + */ +export function findPairEndIndex(value: string, start: number, len: number) { + const index = value.indexOf(";", start); + return index === -1 ? len : index; +} + +/** + * @internal + */ +export function sliceAndTrimOws(value: string, min: number, max: number) { + if (min === max) { + return ""; + } + let start = min; + let end = max; + + do { + const code = value.charCodeAt(start); + if (code !== 32 /* */ && code !== 9 /* \t */) { + break; + } + } while (++start < end); + + while (end > start) { + const code = value.charCodeAt(end - 1); + if (code !== 32 /* */ && code !== 9 /* \t */) { + break; + } + end--; + } + + return value.slice(start, end); +} + +/** + * @internal + */ +export function decode(value: string): string { + if (!value.includes("%")) { + return value; + } + + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export function defaultParser(value: string): Partial> { + const result: Partial> = {}; + const valueLength = value.length; + + if (valueLength < 2) { + return result; + } + + let index = 0; + + do { + const equalCharIndex = value.indexOf("=", index); + + if (equalCharIndex === -1) { + break; + } + + const pairEndIndex = findPairEndIndex(value, index, valueLength); + + if (equalCharIndex > pairEndIndex) { + index = value.lastIndexOf(";", equalCharIndex - 1) + 1; + continue; + } + + const key = sliceAndTrimOws(value, index, equalCharIndex); + + if ( + key === "" + || key === "__proto__" + || key === "constructor" + || key === "prototype" + ) { + index = pairEndIndex + 1; + continue; + } + + if (result[key] === undefined) { + result[key] = decode(sliceAndTrimOws(value, equalCharIndex + 1, pairEndIndex)); + } + + index = pairEndIndex + 1; + } while (index < valueLength); + + return result; +} + +export type Parser = typeof defaultParser; diff --git a/scripts/plugins/cookie/plugin.ts b/scripts/plugins/cookie/plugin.ts new file mode 100644 index 0000000..f343d3d --- /dev/null +++ b/scripts/plugins/cookie/plugin.ts @@ -0,0 +1,34 @@ +import { A } from "@duplojs/utils"; +import type { HubPlugin } from "@core/hub"; +import type { Parser } from "./parser"; +import type { Serializer } from "./serialize"; +import { cookieHooks } from "./hooks"; +import { IgnoreRouteCookieMetadata } from "./metadata"; + +export interface CookiePluginParams { + parser?: Parser; + serializer?: Serializer; +} + +export function cookiePlugin(params?: CookiePluginParams) { + return (): HubPlugin => ({ + name: "cookie-plugin", + hooksHubLifeCycle: [ + { + beforeBuildRoute: (route) => { + if (A.some(route.definition.metadata, IgnoreRouteCookieMetadata.is)) { + return route; + } + return { + ...route, + definition: { + ...route.definition, + hooks: [...route.definition.hooks, cookieHooks(params)], + }, + }; + }, + }, + ], + + }); +} diff --git a/scripts/plugins/cookie/serialize.ts b/scripts/plugins/cookie/serialize.ts new file mode 100644 index 0000000..a6a998a --- /dev/null +++ b/scripts/plugins/cookie/serialize.ts @@ -0,0 +1,129 @@ +import { D, kindHeritage } from "@duplojs/utils"; +import { createCookiePluginKind } from "./kind"; + +const nameRegex = /^[!#$%&'*+\-.^_`|~A-Za-z0-9]+$/; +const domainValueRegex = /^([.]?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)([.][a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i; +const pathValueRegex = /^[\u0020-\u003A\u003C-\u007E]*$/; + +export class SerializeCookieError extends kindHeritage( + "serialize-cookie-error", + createCookiePluginKind("serialize-cookie-error"), + Error, +) { + public constructor( + message: string, + ) { + super({}, [message]); + } +} + +interface SerializerParamsBase { + maxAge?: number; + domain?: string; + path?: string; + httpOnly?: boolean; + secure?: boolean; + partitioned?: boolean; + priority?: "low" | "medium" | "high"; + sameSite?: "lax" | "strict" | "none"; +} + +export interface SerializerParamsWithExpires extends SerializerParamsBase { + expires?: D.TheDate; + expireIn?: undefined; +} + +export interface SerializerParamsWithExpireIn extends SerializerParamsBase { + expires?: undefined; + expireIn?: D.TheTime; +} + +export type SerializerParams = SerializerParamsWithExpires | SerializerParamsWithExpireIn; + +export function defaultSerializer( + name: string, + value: string, + params?: SerializerParams, +): string { + if (!nameRegex.test(name)) { + throw new SerializeCookieError(`argument name is invalid: ${name}`); + } + + let encodedValue = ""; + + try { + encodedValue = encodeURIComponent(value); + } catch { + throw new SerializeCookieError(`argument value is invalid: ${value}`); + } + + let setCookie = `${name}=${encodedValue}`; + + if (params?.maxAge !== undefined) { + if (!Number.isInteger(params.maxAge)) { + throw new SerializeCookieError(`param maxAge is invalid: ${params.maxAge}`); + } + + setCookie += `; Max-Age=${params.maxAge}`; + } + + if (params?.domain) { + if (!domainValueRegex.test(params.domain)) { + throw new SerializeCookieError(`param domain is invalid: ${params.domain}`); + } + + setCookie += `; Domain=${params.domain}`; + } + + if (params?.path) { + if (!pathValueRegex.test(params.path)) { + throw new SerializeCookieError(`param path is invalid: ${params.path}`); + } + + setCookie += `; Path=${params.path}`; + } + + if (params?.expires && params?.expireIn) { + throw new SerializeCookieError("params expires and expireIn are mutually exclusive"); + } + + if (params?.expires) { + setCookie += `; Expires=${params.expires.toUTCString()}`; + } + + if (params?.expireIn !== undefined) { + setCookie += `; Expires=${D.addTime(D.now(), params.expireIn).toUTCString()}`; + } + + if (params?.httpOnly) { + setCookie += "; HttpOnly"; + } + + if (params?.secure) { + setCookie += "; Secure"; + } + + if (params?.partitioned) { + setCookie += "; Partitioned"; + } + + if (params?.priority === "high") { + setCookie += "; Priority=High"; + } else if (params?.priority === "low") { + setCookie += "; Priority=Low"; + } else if (params?.priority === "medium") { + setCookie += "; Priority=Medium"; + } + + if (params?.sameSite === "strict") { + setCookie += "; SameSite=Strict"; + } else if (params?.sameSite === "lax") { + setCookie += "; SameSite=Lax"; + } else if (params?.sameSite === "none") { + setCookie += "; SameSite=None"; + } + + return setCookie; +} + +export type Serializer = typeof defaultSerializer; diff --git a/scripts/plugins/cookie/tsconfig.build.json b/scripts/plugins/cookie/tsconfig.build.json new file mode 100644 index 0000000..640177f --- /dev/null +++ b/scripts/plugins/cookie/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist", + "noEmit": false, + "declaration": true, + "declarationDir": "../../../dist", + "types": null, + "stripInternal": true + }, +} \ No newline at end of file diff --git a/scripts/plugins/cookie/tsconfig.json b/scripts/plugins/cookie/tsconfig.json new file mode 100644 index 0000000..95c5bae --- /dev/null +++ b/scripts/plugins/cookie/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.app.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@core/*": ["../../core/*"], + "@plugin-cookie/*": ["./*"], + }, + }, + "include": ["**/*.ts", "../../core/**/*.ts"], +} diff --git a/tests/plugins/cookie/hooks.test.ts b/tests/plugins/cookie/hooks.test.ts new file mode 100644 index 0000000..29d41b9 --- /dev/null +++ b/tests/plugins/cookie/hooks.test.ts @@ -0,0 +1,236 @@ +import { Request, Response } from "@core"; +import "@plugin-cookie"; +import { cookieHooks, parseRequestCookieHook, serializeResponseCookieHook } from "@plugin-cookie/hooks"; +import { createBodyReader } from "@test-utils/bodyReader"; + +describe("cookie hooks", () => { + it("parseRequestCookieHook parses a string cookie header", () => { + const parser = vi.fn(() => ({ session: "value" })); + const request = new Request({ + method: "GET", + headers: { + cookie: "session=value", + }, + url: "http://localhost/test", + host: "localhost", + origin: "http://localhost", + matchedPath: null, + params: {}, + path: "/test", + query: {}, + bodyReader: createBodyReader(), + }); + const next = vi.fn(() => "next-value"); + const hook = parseRequestCookieHook({ parser }); + + const result = hook.beforeRouteExecution( + { + request, + next: next as never, + exit: () => null as never, + response: () => null as never, + }, + ); + + expect(result).toBe("next-value"); + expect(parser).toHaveBeenCalledExactlyOnceWith("session=value"); + expect(next).toHaveBeenCalledExactlyOnceWith(); + expect(request.cookies).toStrictEqual({ session: "value" }); + }); + + it("parseRequestCookieHook joins array headers and keeps cookies untouched when absent", () => { + const parser = vi.fn(() => ({ theme: "dark" })); + const requestWithArray = new Request({ + method: "GET", + headers: { + cookie: ["session=value", "theme=dark"], + }, + url: "http://localhost/test", + host: "localhost", + origin: "http://localhost", + matchedPath: null, + params: {}, + path: "/test", + query: {}, + bodyReader: createBodyReader(), + }); + const requestWithoutCookie = new Request({ + method: "GET", + headers: {}, + url: "http://localhost/test", + host: "localhost", + origin: "http://localhost", + matchedPath: null, + params: {}, + path: "/test", + query: {}, + bodyReader: createBodyReader(), + }); + const hook = parseRequestCookieHook({ parser }); + + hook.beforeRouteExecution!( + { + request: requestWithArray, + next: () => undefined as never, + exit: () => null as never, + response: () => null as never, + }, + ); + hook.beforeRouteExecution!( + { + request: requestWithoutCookie, + next: () => undefined as never, + exit: () => null as never, + response: () => null as never, + }, + ); + + expect(parser).toHaveBeenCalledExactlyOnceWith("session=value; theme=dark"); + expect(requestWithArray.cookies).toStrictEqual({ theme: "dark" }); + expect(requestWithoutCookie.cookies).toBeUndefined(); + }); + + it("serializeResponseCookieHook serializes stored cookies into the Set-Cookie header", () => { + const serializer = vi.fn((name: string) => `${name}=serialized`); + const response = new Response("200", "ok", undefined); + response.setCookie("session", "value"); + response.setCookie("theme", "dark"); + const next = vi.fn(() => "done"); + const hook = serializeResponseCookieHook({ serializer }); + + const result = hook.beforeSendResponse!( + { + request: undefined as never, + currentResponse: response, + next: next as never, + exit: () => null as never, + }, + ); + + expect(result).toBe("done"); + expect(serializer).toHaveBeenNthCalledWith( + 1, + "session", + "value", + undefined, + ); + expect(serializer).toHaveBeenNthCalledWith( + 2, + "theme", + "dark", + undefined, + ); + expect(response.headers?.["set-cookie"]).toStrictEqual([ + "session=serialized", + "theme=serialized", + ]); + expect(next).toHaveBeenCalledExactlyOnceWith(); + }); + + it("serializeResponseCookieHook skips header emission when no cookie is stored", () => { + const serializer = vi.fn(); + const response = new Response("200", "ok", undefined); + const next = vi.fn(() => undefined); + const hook = serializeResponseCookieHook({ serializer }); + + hook.beforeSendResponse!( + { + request: undefined as never, + currentResponse: response, + next: next as never, + exit: () => null as never, + }, + ); + + expect(serializer).not.toHaveBeenCalled(); + expect(response.headers?.["set-cookie"]).toBeUndefined(); + expect(next).toHaveBeenCalledExactlyOnceWith(); + }); + + it("cookieHooks combines request parsing and response serialization", () => { + const parser = vi.fn(() => ({ session: "parsed" })); + const serializer = vi.fn((name: string) => `${name}=serialized`); + const hook = cookieHooks({ + parser, + serializer, + }); + const request = new Request({ + method: "GET", + headers: { + cookie: "session=value", + }, + url: "http://localhost/test", + host: "localhost", + origin: "http://localhost", + matchedPath: null, + params: {}, + path: "/test", + query: {}, + bodyReader: createBodyReader(), + }); + const response = new Response("200", "ok", undefined); + response.setCookie("session", "value"); + + hook.beforeRouteExecution!( + { + request, + next: () => undefined as never, + exit: () => null as never, + response: () => null as never, + }, + ); + hook.beforeSendResponse!( + { + request, + currentResponse: response, + next: () => undefined as never, + exit: () => null as never, + }, + ); + + expect(parser).toHaveBeenCalledExactlyOnceWith("session=value"); + expect(request.cookies).toStrictEqual({ session: "parsed" }); + expect(serializer).toHaveBeenCalledExactlyOnceWith("session", "value", undefined); + expect(response.headers?.["set-cookie"]).toStrictEqual(["session=serialized"]); + }); + + it("cookieHooks uses default parser and serializer when params are omitted", () => { + const hook = cookieHooks(); + const request = new Request({ + method: "GET", + headers: { + cookie: "session=value", + }, + url: "http://localhost/test", + host: "localhost", + origin: "http://localhost", + matchedPath: null, + params: {}, + path: "/test", + query: {}, + bodyReader: createBodyReader(), + }); + const response = new Response("200", "ok", undefined); + response.setCookie("session", "value"); + + hook.beforeRouteExecution!( + { + request, + next: () => undefined as never, + exit: () => null as never, + response: () => null as never, + }, + ); + hook.beforeSendResponse!( + { + request, + currentResponse: response, + next: () => undefined as never, + exit: () => null as never, + }, + ); + + expect(request.cookies).toStrictEqual({ session: "value" }); + expect(response.headers?.["set-cookie"]).toStrictEqual(["session=value"]); + }); +}); diff --git a/tests/plugins/cookie/override.test.ts b/tests/plugins/cookie/override.test.ts new file mode 100644 index 0000000..ca2cc4a --- /dev/null +++ b/tests/plugins/cookie/override.test.ts @@ -0,0 +1,162 @@ +import "@plugin-cookie/override"; +import { Request } from "@core/request"; +import { Response } from "@core/response"; +import { D, type ExpectType } from "@duplojs/utils"; +import type { SerializerParams } from "@plugin-cookie"; +import { createBodyReader } from "@test-utils/bodyReader"; + +describe("cookie override", () => { + it("adds isolated cookies storage on Request instances", () => { + const firstRequest = new Request({ + method: "GET", + headers: {}, + url: "http://localhost/test", + host: "localhost", + origin: "http://localhost", + matchedPath: null, + params: {}, + path: "/test", + query: {}, + bodyReader: createBodyReader(), + }); + const secondRequest = new Request({ + method: "GET", + headers: {}, + url: "http://localhost/test", + host: "localhost", + origin: "http://localhost", + matchedPath: null, + params: {}, + path: "/test", + query: {}, + bodyReader: createBodyReader(), + }); + + type Check = ExpectType< + typeof firstRequest.cookies, + Partial> | undefined, + "strict" + >; + + expect(firstRequest.cookies).toBeUndefined(); + expect(secondRequest.cookies).toBeUndefined(); + + firstRequest.cookies = { + session: "first", + }; + + expect(firstRequest.cookies).toStrictEqual( + { + session: "first", + }, + ); + expect(secondRequest.cookies).toBeUndefined(); + expect(Object.getPrototypeOf(firstRequest.cookies)).toBe(Object.prototype); + }); + + it("supports replacing cookies storage on Request instances", () => { + const request = new Request({ + method: "GET", + headers: {}, + url: "http://localhost/test", + host: "localhost", + origin: "http://localhost", + matchedPath: null, + params: {}, + path: "/test", + query: {}, + bodyReader: createBodyReader(), + }); + const replacement = { + session: "assigned", + }; + + request.cookies = replacement; + + expect(request.cookies).toBe(replacement); + expect(Object.getPrototypeOf(request.cookies)).toBe(Object.prototype); + }); + + it("adds isolated cookie storage and helpers on Response instances", () => { + const firstResponse = new Response("200", "ok", undefined); + const secondResponse = new Response("200", "ok", undefined); + + type CookieCheck = ExpectType< + typeof firstResponse.cookie, + | Record + | undefined, + "strict" + >; + + expect(firstResponse.cookie).toBeUndefined(); + expect(secondResponse.cookie).toBeUndefined(); + + const returnedBySet = firstResponse.setCookie( + "session", + "value", + { + expireIn: D.createTime(1, "hour"), + httpOnly: true, + }, + ); + const returnedByDrop = firstResponse.dropCookie("expired"); + + expect(firstResponse.cookie).toStrictEqual( + { + session: { + value: "value", + params: { + expireIn: D.createTime(1, "hour"), + httpOnly: true, + }, + }, + expired: { + value: "", + params: { + maxAge: 0, + }, + }, + }, + ); + expect(secondResponse.cookie).toBeUndefined(); + expect(Object.getPrototypeOf(firstResponse.cookie)).toBe(Object.prototype); + }); + + it("supports replacing cookie storage on Response instances", () => { + const response = new Response("200", "ok", undefined); + const replacement = { + session: { + value: "assigned", + params: { + maxAge: 10, + }, + }, + }; + + response.cookie = replacement; + + expect(response.cookie).toBe(replacement); + expect(Object.getPrototypeOf(response.cookie)).toBe(Object.prototype); + }); + + it("initializes cookie storage when dropping a cookie on a fresh Response", () => { + const response = new Response("200", "ok", undefined); + + response.dropCookie("session"); + + expect(response.cookie).toStrictEqual( + { + session: { + value: "", + params: { + maxAge: 0, + }, + }, + }, + ); + expect(Object.getPrototypeOf(response.cookie)).toBe(Object.prototype); + }); +}); diff --git a/tests/plugins/cookie/parser.test.ts b/tests/plugins/cookie/parser.test.ts new file mode 100644 index 0000000..741b53b --- /dev/null +++ b/tests/plugins/cookie/parser.test.ts @@ -0,0 +1,76 @@ +import { + decode, + defaultParser, + findPairEndIndex, + sliceAndTrimOws, +} from "@plugin-cookie/parser"; + +describe("cookie parser utils", () => { + it("findPairEndIndex returns the next separator or the string length", () => { + expect(findPairEndIndex("foo=bar; test=value", 0, 19)).toBe(7); + expect(findPairEndIndex("foo=bar", 0, 7)).toBe(7); + }); + + it("sliceAndTrimOws trims spaces and tabs around the selected slice", () => { + expect(sliceAndTrimOws(" \t foo \t ", 0, 9)).toBe("foo"); + expect(sliceAndTrimOws("foo bar", 0, 7)).toBe("foo bar"); + expect(sliceAndTrimOws("value", 2, 2)).toBe(""); + }); + + it("decode keeps raw values without percent encoding and falls back on malformed input", () => { + expect(decode("plain-value")).toBe("plain-value"); + expect(decode("hello%20world")).toBe("hello world"); + expect(decode("%E0%A4%A")).toBe("%E0%A4%A"); + }); +}); + +describe("defaultParser", () => { + it("returns an object without prototype for short or empty values", () => { + const result = defaultParser(""); + + expect(result).toStrictEqual({}); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + }); + + it("returns an empty result when no cookie pair can be found", () => { + expect(defaultParser("foo; bar")).toStrictEqual({}); + }); + + it("parses cookie pairs, trims OWS and decodes values", () => { + expect( + defaultParser(" foo = bar ; test = hello%20world "), + ).toStrictEqual( + { + foo: "bar", + test: "hello world", + }, + ); + }); + + it("keeps the first value when a cookie name appears multiple times", () => { + expect( + defaultParser("token=first; token=second"), + ).toStrictEqual( + { + token: "first", + }, + ); + }); + + it("ignores empty names and recovers after invalid segments", () => { + expect( + defaultParser("=skip; broken; valid=value"), + ).toStrictEqual( + { + valid: "value", + }, + ); + }); + + it("ignores forbidden keys like __proto__, constructor and prototype", () => { + const result = defaultParser("__proto__=value; constructor=test; prototype=skip; ok=1"); + + expect(result).toStrictEqual({ ok: "1" }); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + }); +}); diff --git a/tests/plugins/cookie/plugin.test.ts b/tests/plugins/cookie/plugin.test.ts new file mode 100644 index 0000000..c3b9f90 --- /dev/null +++ b/tests/plugins/cookie/plugin.test.ts @@ -0,0 +1,103 @@ +import { createHub, launchHookBeforeBuildRoute, Request, Response, ResponseContract, useRouteBuilder } from "@core"; +import { cookiePlugin } from "@plugin-cookie"; +import { IgnoreRouteCookieMetadata } from "@plugin-cookie/metadata"; +import { createBodyReader } from "@test-utils/bodyReader"; + +function createRequest() { + return new Request({ + method: "GET", + headers: { + cookie: "session=value", + }, + url: "http://localhost/test", + host: "localhost", + origin: "http://localhost", + matchedPath: null, + params: {}, + path: "/test", + query: {}, + bodyReader: createBodyReader(), + }); +} + +describe("cookie plugin", () => { + it("returns the plugin definition with a beforeBuildRoute hook", () => { + const plugin = cookiePlugin()(); + + expect(plugin).toStrictEqual({ + name: "cookie-plugin", + hooksHubLifeCycle: [ + expect.objectContaining({ + beforeBuildRoute: expect.any(Function), + }), + ], + }); + }); + + it("injects the combined cookie hook and wires custom parser and serializer", async() => { + const parser = vi.fn(() => ({ session: "parsed" })); + const serializer = vi.fn((name: string) => `${name}=custom`); + const hub = createHub({ environment: "DEV" }) + .plug( + cookiePlugin({ + parser, + serializer, + }), + ); + const route = useRouteBuilder("GET", "/test") + .handler( + ResponseContract.noContent("test"), + (__, { response }) => response("test"), + ); + + const routeAfterHook = await launchHookBeforeBuildRoute( + hub.aggregatesHooksHubLifeCycle("beforeBuildRoute"), + route, + ); + const hook = routeAfterHook.definition.hooks.at(-1)!; + const request = createRequest(); + const response = new Response("200", "ok", undefined) + .setCookie("session", "value"); + + await hook.beforeRouteExecution!( + { + request, + next: () => undefined as never, + exit: () => null as never, + response: () => null as never, + }, + ); + await hook.beforeSendResponse!( + { + request, + currentResponse: response, + next: () => undefined as never, + exit: () => null as never, + }, + ); + + expect(routeAfterHook.definition.hooks).toHaveLength(route.definition.hooks.length + 1); + expect(parser).toHaveBeenCalledExactlyOnceWith("session=value"); + expect(request.cookies).toStrictEqual({ session: "parsed" }); + expect(serializer).toHaveBeenCalledExactlyOnceWith("session", "value", undefined); + expect(response.headers?.["set-cookie"]).toStrictEqual(["session=custom"]); + }); + + it("does not inject the hook when IgnoreRouteCookieMetadata is present", async() => { + const hub = createHub({ environment: "DEV" }) + .plug(cookiePlugin()); + const ignoredRoute = useRouteBuilder("GET", "/ignored", { + metadata: [IgnoreRouteCookieMetadata()], + }).handler( + ResponseContract.noContent("ignored"), + (__, { response }) => response("ignored"), + ); + + const routeAfterHook = await launchHookBeforeBuildRoute( + hub.aggregatesHooksHubLifeCycle("beforeBuildRoute"), + ignoredRoute, + ); + + expect(routeAfterHook).toBe(ignoredRoute); + }); +}); diff --git a/tests/plugins/cookie/serialize.test.ts b/tests/plugins/cookie/serialize.test.ts new file mode 100644 index 0000000..a20ac17 --- /dev/null +++ b/tests/plugins/cookie/serialize.test.ts @@ -0,0 +1,125 @@ +import { D } from "@duplojs/utils"; +import { defaultSerializer, SerializeCookieError } from "@plugin-cookie"; + +describe("defaultSerializer", () => { + it("serializes cookies with encoded values and attributes", () => { + const result = defaultSerializer( + "session_id", + "hello world", + { + maxAge: 0, + path: "/app", + sameSite: "lax", + httpOnly: true, + }, + ); + + expect(result).toBe( + "session_id=hello%20world; Max-Age=0; Path=/app; HttpOnly; SameSite=Lax", + ); + }); + + it("serializes domain, security and optional policy attributes", () => { + const result = defaultSerializer( + "session", + "value", + { + domain: ".example.com", + secure: true, + partitioned: true, + priority: "high", + sameSite: "strict", + }, + ); + + expect(result).toBe( + "session=value; Domain=.example.com; Secure; Partitioned; Priority=High; SameSite=Strict", + ); + }); + + it("serializes low and medium priorities and SameSite=None", () => { + expect( + defaultSerializer("low", "value", { priority: "low" }), + ).toBe("low=value; Priority=Low"); + expect( + defaultSerializer("medium", "value", { + priority: "medium", + sameSite: "none", + }), + ).toBe("medium=value; Priority=Medium; SameSite=None"); + }); + + it("serializes expires with the HTTP-date format", () => { + const result = defaultSerializer( + "token", + "value", + { + expires: D.createOrThrow("date1704164645000+"), + }, + ); + + expect(result).toBe( + "token=value; Expires=Tue, 02 Jan 2024 03:04:05 GMT", + ); + }); + + it("serializes expireIn from a D.TheTime value", () => { + vi.useFakeTimers(); + + try { + vi.setSystemTime(new Date("2024-01-02T03:04:05.000Z")); + + const result = defaultSerializer( + "token", + "value", + { + expireIn: D.createTime(1, "hour"), + }, + ); + + expect(result).toBe( + "token=value; Expires=Tue, 02 Jan 2024 04:04:05 GMT", + ); + } finally { + vi.useRealTimers(); + } + }); + + it("rejects invalid cookie names", () => { + expect(() => defaultSerializer("session:id", "value")) + .toThrowError(SerializeCookieError); + }); + + it("rejects values that cannot be percent-encoded", () => { + expect(() => defaultSerializer("token", "\uD800")) + .toThrow(); + }); + + it("rejects invalid numeric and path-like attributes", () => { + expect( + () => defaultSerializer("token", "value", { maxAge: 12.5 }), + ).toThrowError(/param maxAge is invalid/); + expect( + () => defaultSerializer("token", "value", { path: "/bad;path" }), + ).toThrowError(/param path is invalid/); + }); + + it("rejects invalid domain attributes", () => { + expect( + () => defaultSerializer("token", "value", { domain: "bad_domain" }), + ).toThrowError(/param domain is invalid/); + }); + + it("rejects expires and expireIn together", () => { + expect( + () => defaultSerializer( + "token", + "value", + { + expires: D.create("2024-01-02"), + expireIn: D.createTime(1, "hour"), + } as never, + ), + ).toThrowError(/params expires and expireIn are mutually exclusive/); + }); +}); diff --git a/tests/plugins/cookie/tsconfig.json b/tests/plugins/cookie/tsconfig.json new file mode 100644 index 0000000..cd7cc2c --- /dev/null +++ b/tests/plugins/cookie/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.test.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@core": ["../../../scripts/core/index.ts"], + "@core/*": ["../../../scripts/core/*"], + "@plugin-cookie": ["../../../scripts/plugins/cookie/index.ts"], + "@plugin-cookie/*": ["../../../scripts/plugins/cookie/*"], + "@test-utils/*": ["../../_utils/*"], + }, + }, + "include": [ + "**/*.ts", + "../../../scripts/core/**/*.ts", + "../../../scripts/plugins/cookie/**/*.ts", + ], +} diff --git a/tsconfig.json b/tsconfig.json index 6461faa..4a90f00 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ { "path": "./scripts/plugins/static/tsconfig.json" }, { "path": "./scripts/plugins/cacheController/tsconfig.json" }, { "path": "./scripts/plugins/cors/tsconfig.json" }, + { "path": "./scripts/plugins/cookie/tsconfig.json" }, { "path": "./tests/_utils/tsconfig.json" }, { "path": "./tests/core/tsconfig.json" }, @@ -23,6 +24,7 @@ { "path": "./tests/plugins/static/tsconfig.json" }, { "path": "./tests/plugins/cacheController/tsconfig.json" }, { "path": "./tests/plugins/cors/tsconfig.json" }, + { "path": "./tests/plugins/cookie/tsconfig.json" }, { "path": "./integration/core/tsconfig.json" }, { "path": "./integration/node/tsconfig.json" }, @@ -32,5 +34,6 @@ { "path": "./integration/static/tsconfig.json" }, { "path": "./integration/cacheController/tsconfig.json" }, { "path": "./integration/cors/tsconfig.json" }, + { "path": "./integration/cookie/tsconfig.json" }, ], } \ No newline at end of file