From 35f4071d3049ae7303209b05aee484a55be20368 Mon Sep 17 00:00:00 2001 From: ZeRiix Date: Fri, 6 Mar 2026 19:34:53 +0100 Subject: [PATCH] feat(18): add plugin static and cacheController --- integration/cacheController/index.test.ts | 67 ++++ integration/cacheController/tsconfig.json | 11 + .../__snapshots__/index.test.ts.snap | 32 ++ integration/core/index.ts | 21 +- integration/core/tsconfig.json | 1 + integration/node/file.test.ts | 2 - .../__snapshots__/index.test.ts.snap | 102 +++++ integration/package.json | 2 +- integration/static/index.test.ts | 157 ++++++++ integration/static/tsconfig.json | 11 + package-lock.json | 8 +- package.json | 14 +- rollup.config.js | 50 +++ scripts/client/promiseRequest.ts | 4 +- .../core/functionsBuilders/route/default.ts | 10 +- scripts/core/route/hooks.ts | 28 +- .../createResponseHeader.ts | 54 +++ .../directiveRequestParsers/index.ts | 22 ++ .../parseExtensionsDirective.ts | 34 ++ .../parseMaxAgeDirective.ts | 15 + .../parseMaxStaleDirective.ts | 19 + .../parseMinFreshDirective.ts | 15 + .../parseMustUnderstandDirective.ts | 7 + .../parseNoCacheDirective.ts | 21 + .../parseNoStoreDirective.ts | 7 + .../parseNoTransformDirective.ts | 7 + .../parseOnlyIfCachedDirective.ts | 7 + .../hooks/createCacheController/hook.ts | 57 +++ .../hooks/createCacheController/index.ts | 5 + .../types/cacheControlDirectives.ts | 103 +++++ .../createCacheController/types/index.ts | 1 + .../plugins/cacheController/hooks/index.ts | 1 + scripts/plugins/cacheController/index.ts | 1 + .../cacheController/tsconfig.build.json | 10 + scripts/plugins/cacheController/tsconfig.json | 11 + scripts/plugins/codeGenerator/plugin.ts | 2 +- ...Transfomer.ts => typescriptTransformer.ts} | 0 scripts/plugins/static/index.ts | 3 + scripts/plugins/static/kind.ts | 12 + scripts/plugins/static/makeRouteFile.ts | 66 ++++ scripts/plugins/static/makeRouteFolder.ts | 122 ++++++ scripts/plugins/static/plugin.ts | 109 ++++++ scripts/plugins/static/tsconfig.build.json | 10 + scripts/plugins/static/tsconfig.json | 12 + tests/core/builders/route/builder.test.ts | 58 ++- .../createResponseHeader.test.ts | 57 +++ .../directiveRequestParsers.test.ts | 100 +++++ .../createCacheController/hook.test.ts | 112 ++++++ tests/plugins/cacheController/tsconfig.json | 17 + .../codeGenerator/routeToDataParser.test.ts | 2 +- .../typescriptTransfomer.test.ts | 2 +- tests/plugins/static/makeRouteFile.test.ts | 196 ++++++++++ tests/plugins/static/makeRouteFolder.test.ts | 362 ++++++++++++++++++ tests/plugins/static/plugin.test.ts | 106 +++++ tests/plugins/static/tsconfig.json | 19 + tsconfig.json | 6 + 56 files changed, 2259 insertions(+), 31 deletions(-) create mode 100644 integration/cacheController/index.test.ts create mode 100644 integration/cacheController/tsconfig.json create mode 100644 integration/static/index.test.ts create mode 100644 integration/static/tsconfig.json create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/createResponseHeader.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/index.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseExtensionsDirective.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMaxAgeDirective.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMaxStaleDirective.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMinFreshDirective.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMustUnderstandDirective.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseNoCacheDirective.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseNoStoreDirective.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseNoTransformDirective.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseOnlyIfCachedDirective.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/hook.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/index.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/types/cacheControlDirectives.ts create mode 100644 scripts/plugins/cacheController/hooks/createCacheController/types/index.ts create mode 100644 scripts/plugins/cacheController/hooks/index.ts create mode 100644 scripts/plugins/cacheController/index.ts create mode 100644 scripts/plugins/cacheController/tsconfig.build.json create mode 100644 scripts/plugins/cacheController/tsconfig.json rename scripts/plugins/codeGenerator/{typescriptTransfomer.ts => typescriptTransformer.ts} (100%) create mode 100644 scripts/plugins/static/index.ts create mode 100644 scripts/plugins/static/kind.ts create mode 100644 scripts/plugins/static/makeRouteFile.ts create mode 100644 scripts/plugins/static/makeRouteFolder.ts create mode 100644 scripts/plugins/static/plugin.ts create mode 100644 scripts/plugins/static/tsconfig.build.json create mode 100644 scripts/plugins/static/tsconfig.json create mode 100644 tests/plugins/cacheController/createCacheController/createResponseHeader.test.ts create mode 100644 tests/plugins/cacheController/createCacheController/directiveRequestParsers.test.ts create mode 100644 tests/plugins/cacheController/createCacheController/hook.test.ts create mode 100644 tests/plugins/cacheController/tsconfig.json create mode 100644 tests/plugins/static/makeRouteFile.test.ts create mode 100644 tests/plugins/static/makeRouteFolder.test.ts create mode 100644 tests/plugins/static/plugin.test.ts create mode 100644 tests/plugins/static/tsconfig.json diff --git a/integration/cacheController/index.test.ts b/integration/cacheController/index.test.ts new file mode 100644 index 0000000..0c8e222 --- /dev/null +++ b/integration/cacheController/index.test.ts @@ -0,0 +1,67 @@ +import { createCacheControllerHook } from "@duplojs/http/cacheController"; +import { createHub, ResponseContract, useRouteBuilder } from "@duplojs/http"; +import { createHttpServer } from "@duplojs/http/node"; +import { DP } from "@duplojs/utils"; + +describe("cacheController", () => { + it("route is good", async() => { + const route = useRouteBuilder("GET", "/", { + hooks: [ + createCacheControllerHook({ + response: { + private: ["authorization", "cookie"], + noCache: ["set-cookie"], + maxAge: 200, + }, + }), + ], + }) + .handler( + ResponseContract.ok("test", DP.boolean()), + (__, { response, request }) => response( + "test", + request.getCacheControlDirective("noStore"), + ), + ); + + const hub = createHub({ environment: "DEV" }).register(route); + const server = await createHttpServer(hub, { + host: "0.0.0.0", + port: 8947, + }); + + interface Routes { + method: "GET"; + path: "/"; + responses: { + code: "200"; + information: "test"; + body: boolean; + }; + } + + await expect( + fetch("http://localhost:8947/", { + method: "GET", + }) + .then(async(response) => ({ + body: await response.text(), + headers: [...response.headers.entries()], + })), + ).resolves.toStrictEqual({ + body: "false", + headers: expect.arrayContaining([ + [ + "information", + "test", + ], + [ + "cache-control", + "max-age=200,private=\"authorization,cookie\",no-cache=\"set-cookie\"", + ], + ]), + }); + + server.close(); + }); +}); diff --git a/integration/cacheController/tsconfig.json b/integration/cacheController/tsconfig.json new file mode 100644 index 0000000..70fe73f --- /dev/null +++ b/integration/cacheController/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.test.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@core": ["../core/index.ts"] + }, + "types": ["vitest/globals", "node"] + }, + "include": ["**/*.ts", "../core/**/*.ts"], +} diff --git a/integration/codeGenerator/__snapshots__/index.test.ts.snap b/integration/codeGenerator/__snapshots__/index.test.ts.snap index c921a5b..fbeb5e0 100644 --- a/integration/codeGenerator/__snapshots__/index.test.ts.snap +++ b/integration/codeGenerator/__snapshots__/index.test.ts.snap @@ -81,5 +81,37 @@ export type Routes = { information: "file.send"; body: File; }; +} | { + method: "GET"; + path: "/static-file"; + responses: { + code: "200"; + information: "resource.found"; + body: File; + } | { + code: "404"; + information: "resource.notfound"; + body?: undefined; + } | { + code: "304"; + information: "resource.notModified"; + body?: undefined; + }; +} | { + method: "GET"; + path: \`/static-folder/\${string}\`; + responses: { + code: "200"; + information: "resource.found"; + body: File; + } | { + code: "404"; + information: "resource.notfound"; + body?: undefined; + } | { + code: "304"; + information: "resource.notModified"; + body?: undefined; + }; };" `; diff --git a/integration/core/index.ts b/integration/core/index.ts index 83a6c1c..241e475 100644 --- a/integration/core/index.ts +++ b/integration/core/index.ts @@ -1,5 +1,22 @@ -import "./routes"; +import { setCurrentWorkingDirectory, SF } from "@duplojs/server-utils"; import { createHub, routeStore } from "@duplojs/http"; +import { staticPlugin } from "@duplojs/http/static"; +import { asserts, E, Path } from "@duplojs/utils"; +import "./routes"; + +const sourceFile = SF.createFileInterface("files/fakeFiles/superTextFile.txt"); +const sourceFolder = SF.createFolderInterface("files/fakeFiles"); + +asserts( + setCurrentWorkingDirectory(Path.resolveRelative([import.meta.dirname, "../"])), + E.isRight, +); export const hub = createHub({ environment: "DEV" }) - .register(routeStore.getAll()); + .register(routeStore.getAll()) + .plug( + staticPlugin(sourceFile, { path: "/static-file" }), + ) + .plug( + staticPlugin(sourceFolder, { prefix: "/static-folder" }), + ); diff --git a/integration/core/tsconfig.json b/integration/core/tsconfig.json index 415b00f..237178b 100644 --- a/integration/core/tsconfig.json +++ b/integration/core/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.app.json", "compilerOptions": { + "types": ["node"] }, "include": ["**/*.ts"], } diff --git a/integration/node/file.test.ts b/integration/node/file.test.ts index 05099e4..daf9539 100644 --- a/integration/node/file.test.ts +++ b/integration/node/file.test.ts @@ -12,8 +12,6 @@ describe("receive file", async() => { uploadFolder: resolve(import.meta.dirname, "../files/upload"), }); - process.chdir(resolve(import.meta.dirname, "../")); - afterAll(() => { server.close(); }); diff --git a/integration/openApiGenerator/__snapshots__/index.test.ts.snap b/integration/openApiGenerator/__snapshots__/index.test.ts.snap index 5e0456c..4a4c561 100644 --- a/integration/openApiGenerator/__snapshots__/index.test.ts.snap +++ b/integration/openApiGenerator/__snapshots__/index.test.ts.snap @@ -185,6 +185,100 @@ exports[`openApiGenerator > correct generate file 1`] = ` } } } + }, + "/static-file": { + "get": { + "parameters": [], + "responses": { + "200": { + "headers": { + "information": { + "schema": { + "const": "resource.found", + "type": "string" + }, + "description": "resource.found" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotIdentified7" + } + } + } + }, + "304": { + "headers": { + "information": { + "schema": { + "const": "resource.notModified", + "type": "string" + }, + "description": "resource.notModified" + } + } + }, + "404": { + "headers": { + "information": { + "schema": { + "const": "resource.notfound", + "type": "string" + }, + "description": "resource.notfound" + } + } + } + } + } + }, + "/static-folder/*": { + "get": { + "parameters": [], + "responses": { + "200": { + "headers": { + "information": { + "schema": { + "const": "resource.found", + "type": "string" + }, + "description": "resource.found" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotIdentified8" + } + } + } + }, + "304": { + "headers": { + "information": { + "schema": { + "const": "resource.notModified", + "type": "string" + }, + "description": "resource.notModified" + } + } + }, + "404": { + "headers": { + "information": { + "schema": { + "const": "resource.notfound", + "type": "string" + }, + "description": "resource.notfound" + } + } + } + } + } } }, "components": { @@ -325,6 +419,14 @@ exports[`openApiGenerator > correct generate file 1`] = ` "NotIdentified6": { "type": "string", "format": "binary" + }, + "NotIdentified7": { + "type": "string", + "format": "binary" + }, + "NotIdentified8": { + "type": "string", + "format": "binary" } } } diff --git a/integration/package.json b/integration/package.json index b7a1522..375bca2 100644 --- a/integration/package.json +++ b/integration/package.json @@ -2,7 +2,7 @@ "name": "integrations", "license": "ISC", "scripts": { - "test:types": "tsc -p codeGenerator/tsconfig.json && tsc -p openApiGenerator/tsconfig.json && tsc -p core/tsconfig.json && tsc -p node/tsconfig.json && tsc -p client/tsconfig.json" + "test:types": "tsc -p codeGenerator/tsconfig.json && tsc -p openApiGenerator/tsconfig.json && tsc -p core/tsconfig.json && tsc -p node/tsconfig.json && tsc -p client/tsconfig.json tsc -p static/tsconfig.json" }, "dependencies": { "@duplojs/http": "file:.." diff --git a/integration/static/index.test.ts b/integration/static/index.test.ts new file mode 100644 index 0000000..57ef457 --- /dev/null +++ b/integration/static/index.test.ts @@ -0,0 +1,157 @@ +import { createHttpServer } from "@duplojs/http/node"; +import { hub } from "@core"; + +describe("static plugin", async() => { + const server = await createHttpServer(hub, { + host: "0.0.0.0", + port: 8980, + }); + + afterAll(() => { + server.close(); + }); + + it("file found", async() => { + await expect( + fetch("http://localhost:8980/static-file", { + method: "GET", + }) + .then(async(response) => ({ + body: await response.text(), + headers: [...response.headers.entries()], + })), + ).resolves.toStrictEqual({ + body: "this is super file with super content.", + headers: expect.arrayContaining([ + [ + "information", + "resource.found", + ], + [ + "content-type", + "text/plain", + ], + [ + "last-modified", + "2026-03-02T09:51:37.413Z", + ], + [ + "content-disposition", + "attachment; filename=\"superTextFile.txt\"", + ], + ]), + }); + }); + + it("file notModified", async() => { + await expect( + fetch("http://localhost:8980/static-file", { + method: "GET", + headers: { + "if-modified-since": "2026-03-02T09:51:37.413Z", + }, + }) + .then(async(response) => ({ + code: response.status, + body: await response.text(), + headers: [...response.headers.entries()], + })), + ).resolves.toStrictEqual({ + body: "", + code: 304, + headers: expect.arrayContaining([ + [ + "information", + "resource.notModified", + ], + [ + "last-modified", + "2026-03-02T09:51:37.413Z", + ], + ]), + }); + }); + + it("file in folder found", async() => { + await expect( + fetch("http://localhost:8980/static-folder/superTextFile.txt", { + method: "GET", + }) + .then(async(response) => ({ + body: await response.text(), + headers: [...response.headers.entries()], + })), + ).resolves.toStrictEqual({ + body: "this is super file with super content.", + headers: expect.arrayContaining([ + [ + "information", + "resource.found", + ], + [ + "content-type", + "text/plain", + ], + [ + "last-modified", + "2026-03-02T09:51:37.413Z", + ], + [ + "content-disposition", + "attachment; filename=\"superTextFile.txt\"", + ], + ]), + }); + }); + + it("file in folder notModified", async() => { + await expect( + fetch("http://localhost:8980/static-folder/superTextFile.txt", { + method: "GET", + headers: { + "if-modified-since": "2026-03-02T09:51:37.413Z", + }, + }) + .then(async(response) => ({ + code: response.status, + body: await response.text(), + headers: [...response.headers.entries()], + })), + ).resolves.toStrictEqual({ + body: "", + code: 304, + headers: expect.arrayContaining([ + [ + "information", + "resource.notModified", + ], + [ + "last-modified", + "2026-03-02T09:51:37.413Z", + ], + ]), + }); + }); + + it("file in folder notModified", async() => { + await expect( + fetch("http://localhost:8980/static-folder/unknown.txt", { + method: "GET", + }) + .then(async(response) => ({ + code: response.status, + body: await response.text(), + headers: [...response.headers.entries()], + })), + ).resolves.toStrictEqual({ + body: "", + code: 404, + headers: expect.arrayContaining([ + [ + "information", + "resource.notfound", + ], + ]), + }); + }); +}); diff --git a/integration/static/tsconfig.json b/integration/static/tsconfig.json new file mode 100644 index 0000000..70fe73f --- /dev/null +++ b/integration/static/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.test.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@core": ["../core/index.ts"] + }, + "types": ["vitest/globals", "node"] + }, + "include": ["**/*.ts", "../core/**/*.ts"], +} diff --git a/package-lock.json b/package-lock.json index fe24f29..e5f53f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "peerDependencies": { "@duplojs/data-parser-tools": ">=0.2.8 <1.0.0", "@duplojs/server-utils": ">=0.2.0 <1.0.0", - "@duplojs/utils": ">=1.5.8 <2.0.0" + "@duplojs/utils": ">=1.5.10 <2.0.0" } }, "docs": { @@ -1518,9 +1518,9 @@ } }, "node_modules/@duplojs/utils": { - "version": "1.5.8", - "resolved": "https://registry.npmjs.org/@duplojs/utils/-/utils-1.5.8.tgz", - "integrity": "sha512-MQSXjzRhTZuEO6ZMgdtkH9XWUfNHnZSHY+fuiKY9VSRMgbNyd+WEX09ECqixL1XGCmmSOmSxAt41zJBw3BFFBw==", + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/@duplojs/utils/-/utils-1.5.10.tgz", + "integrity": "sha512-VIAK+QgeSJeeQdruyclP7RxVECyRZ8xY/+3CFDm/1W22S8ZZQH8Ib4VTpNKsY+L4ybjtRJU7+yYQMXGdle7zNA==", "license": "MIT", "peer": true, "workspaces": [ diff --git a/package.json b/package.json index 1d0f7dd..c20f3e4 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test:tu:bench": "vitest bench", "test:tu:watch": "vitest --coverage --watch", "test:tu:update": "vitest --coverage --update", - "test:types": "tsc -p tests/core/tsconfig.json && tsc -p tests/client/tsconfig.json && tsc -p tests/interfaces/node/tsconfig.json && tsc -p tests/interfaces/bun/tsconfig.json && tsc -p tests/interfaces/deno/tsconfig.json && tsc -p tests/plugins/codeGenerator/tsconfig.json && tsc -p tests/plugins/openApiGenerator/tsconfig.json && npm -w integration run test:types && npm -w docs run test:types", + "test:types": "tsc -p tests/core/tsconfig.json && tsc -p tests/client/tsconfig.json && tsc -p tests/interfaces/node/tsconfig.json && tsc -p tests/interfaces/bun/tsconfig.json && tsc -p tests/interfaces/deno/tsconfig.json && tsc -p tests/plugins/codeGenerator/tsconfig.json && tsc -p tests/plugins/openApiGenerator/tsconfig.json && tsc -p tests/plugins/cacheController/tsconfig.json && tsc -p tests/plugins/static/tsconfig.json && npm -w integration run test:types && npm -w docs run test:types", "test:lint": "eslint --quiet", "test:lint:fix": "eslint --fix --quiet", "prepare": "husky" @@ -58,6 +58,16 @@ "import": "./dist/plugins/openApiGenerator/index.mjs", "require": "./dist/plugins/openApiGenerator/index.cjs", "types": "./dist/plugins/openApiGenerator/index.d.ts" + }, + "./cacheController": { + "import": "./dist/plugins/cacheController/index.mjs", + "require": "./dist/plugins/cacheController/index.cjs", + "types": "./dist/plugins/cacheController/index.d.ts" + }, + "./static": { + "import": "./dist/plugins/static/index.mjs", + "require": "./dist/plugins/static/index.cjs", + "types": "./dist/plugins/static/index.d.ts" } }, "files": [ @@ -67,7 +77,7 @@ "peerDependencies": { "@duplojs/data-parser-tools": ">=0.2.8 <1.0.0", "@duplojs/server-utils": ">=0.2.0 <1.0.0", - "@duplojs/utils": ">=1.5.8 <2.0.0" + "@duplojs/utils": ">=1.5.10 <2.0.0" }, "devDependencies": { "@commitlint/cli": "19.8.1", diff --git a/rollup.config.js b/rollup.config.js index 0d908d3..db055f0 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -55,6 +55,56 @@ export default defineConfig([ tscAlias({ configFile: "scripts/plugins/codeGenerator/tsconfig.build.json" }), ], }, + { + input: "scripts/plugins/cacheController/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/cacheController" }), + typescript({ tsconfig: "scripts/plugins/cacheController/tsconfig.build.json" }), + tscAlias({ configFile: "scripts/plugins/cacheController/tsconfig.build.json" }), + ], + }, + { + input: "scripts/plugins/static/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/static" }), + typescript({ tsconfig: "scripts/plugins/static/tsconfig.build.json" }), + tscAlias({ configFile: "scripts/plugins/static/tsconfig.build.json" }), + ], + }, // interfaces { diff --git a/scripts/client/promiseRequest.ts b/scripts/client/promiseRequest.ts index c6b8b7c..f790c1a 100644 --- a/scripts/client/promiseRequest.ts +++ b/scripts/client/promiseRequest.ts @@ -393,7 +393,7 @@ export class PromiseRequest< > > > { - const formattedInformation = AA.coalescing(information); + const formattedInformation: readonly string[] = AA.coalescing(information); return this.then( EE.whenIsRight( @@ -439,7 +439,7 @@ export class PromiseRequest< > > > { - const formattedCode = AA.coalescing(code); + const formattedCode: readonly SS.Number[] = AA.coalescing(code); return this.then( EE.whenIsRight( diff --git a/scripts/core/functionsBuilders/route/default.ts b/scripts/core/functionsBuilders/route/default.ts index 08c956d..e308d74 100644 --- a/scripts/core/functionsBuilders/route/default.ts +++ b/scripts/core/functionsBuilders/route/default.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/prefer-for-of */ -import { type HookAfterSendResponse, type HookBeforeRouteExecution, type HookBeforeSendResponse, type HookError, type HookOnConstructRequest, type HookRouteLifeCycle, type HookSendResponse, routeKind } from "@core/route"; +import { type HookAfterSendResponse, type HookBeforeRouteExecution, type HookBeforeSendResponse, type HookError, type HookOnConstructRequest, type HookRouteLifeCycle, hookRouteLifeCycleAddRequestProperties, type HookSendResponse, routeKind } from "@core/route"; import { A, E, forward, isType, pipe } from "@duplojs/utils"; import { HookResponse, Response } from "@core/response"; import { type Request } from "@core/request"; @@ -182,13 +182,7 @@ export const defaultRouteFunctionBuilder = createRouteFunctionBuilder( async(request) => { const currentRequest = await hooks.onConstructRequest({ request, - addRequestProperties: (newProperties) => { - for (const key in newProperties) { - request[key as never] = newProperties[key] as never; - } - - return request as never; - }, + addRequestProperties: hookRouteLifeCycleAddRequestProperties(request), }); const currentResponse = await routeExecution(currentRequest); diff --git a/scripts/core/route/hooks.ts b/scripts/core/route/hooks.ts index 221f9a2..d4ae95e 100644 --- a/scripts/core/route/hooks.ts +++ b/scripts/core/route/hooks.ts @@ -1,6 +1,6 @@ import { type Request } from "../request"; import { createCoreLibKind } from "../kind"; -import { type UnionToIntersection, type AnyFunction, type Kind, type MaybePromise, type SimplifyTopLevel, type IsEqual } from "@duplojs/utils"; +import { type UnionToIntersection, type AnyFunction, type Kind, type MaybePromise, type SimplifyTopLevel, type IsEqual, type BivariantFunction } from "@duplojs/utils"; import { type HookResponse } from "../response"; import { type ResponseCode, type Response } from "@core/response"; @@ -114,12 +114,12 @@ export type HookAfterSendResponse< export interface HookRouteLifeCycle< GenericRequest extends Request = Request, > { - onConstructRequest?: HookOnConstructRequest; - beforeRouteExecution?: HookBeforeRouteExecution; + onConstructRequest?: BivariantFunction>; + beforeRouteExecution?: BivariantFunction>; error?: HookError; - beforeSendResponse?: HookBeforeSendResponse; - sendResponse?: HookSendResponse; - afterSendResponse?: HookAfterSendResponse; + beforeSendResponse?: BivariantFunction>; + sendResponse?: BivariantFunction>; + afterSendResponse?: BivariantFunction>; } export function createHookRouteLifeCycle< @@ -161,6 +161,22 @@ export function createHookRouteLifeCycle( }; } +export function hookRouteLifeCycleAddRequestProperties< + GenericRequest extends Request = Request, +>( + request: GenericRequest, +) { + return < + GenericNewProperties extends Record = Record, + >(newProperties: GenericNewProperties) => { + for (const key in newProperties) { + request[key as never] = newProperties[key] as never; + } + + return request as never; + }; +} + export type ExtractRequestFromHooks< GenericHooks extends readonly HookRouteLifeCycle[], > = GenericHooks extends readonly [ diff --git a/scripts/plugins/cacheController/hooks/createCacheController/createResponseHeader.ts b/scripts/plugins/cacheController/hooks/createCacheController/createResponseHeader.ts new file mode 100644 index 0000000..b433272 --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/createResponseHeader.ts @@ -0,0 +1,54 @@ +import { A, O, pipe } from "@duplojs/utils"; +import type { CacheControlResponseDirectives } from "./types"; + +export function createCacheControlResponseHeader( + directives: CacheControlResponseDirectives, +) { + return pipe( + [ + O.entry("max-age", directives.maxAge), + O.entry("s-maxage", directives.sMaxAge), + O.entry("public", directives.public), + O.entry("private", directives.private), + O.entry("no-cache", directives.noCache), + O.entry("no-store", directives.noStore), + O.entry("no-transform", directives.noTransform), + O.entry("must-revalidate", directives.mustRevalidate), + O.entry("proxy-revalidate", directives.proxyRevalidate), + O.entry("immutable", directives.immutable), + O.entry("stale-while-revalidate", directives.staleWhileRevalidate), + O.entry("stale-if-error", directives.staleIfError), + O.entry("must-understand", directives.mustUnderstand), + ], + A.concat(directives.extensions ? O.entries(directives.extensions) : []), + A.reduce( + A.reduceFrom([]), + ({ element: [key, value], lastValue, nextPush, next }) => { + if ( + value === true + ) { + return nextPush(lastValue, key); + } else if ( + typeof value === "number" + && Number.isFinite(value) + && value >= 0 + ) { + return nextPush(lastValue, `${key}=${Math.trunc(value)}`); + } else if ( + value instanceof Array + && A.minElements(value, 1) + ) { + return nextPush(lastValue, `${key}="${A.join(value, ",")}"`); + } else if ( + value !== "" + && typeof value === "string" + ) { + return nextPush(lastValue, `${key}="${value}"`); + } else { + return next(lastValue); + } + }, + ), + A.join(","), + ); +} diff --git a/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/index.ts b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/index.ts new file mode 100644 index 0000000..5f5e47a --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/index.ts @@ -0,0 +1,22 @@ +import { type CacheControlRequestDirectives } from "../types"; +import { parseExtensionsDirective } from "./parseExtensionsDirective"; +import { parseMaxAgeDirective } from "./parseMaxAgeDirective"; +import { parseMaxStaleDirective } from "./parseMaxStaleDirective"; +import { parseMinFreshDirective } from "./parseMinFreshDirective"; +import { parseMustUnderstandDirective } from "./parseMustUnderstandDirective"; +import { parseNoCacheDirective } from "./parseNoCacheDirective"; +import { parseNoStoreDirective } from "./parseNoStoreDirective"; +import { parseNoTransformDirective } from "./parseNoTransformDirective"; +import { parseOnlyIfCachedDirective } from "./parseOnlyIfCachedDirective"; + +export const directiveRequestParsers = { + maxAge: parseMaxAgeDirective, + maxStale: parseMaxStaleDirective, + minFresh: parseMinFreshDirective, + noCache: parseNoCacheDirective, + noStore: parseNoStoreDirective, + noTransform: parseNoTransformDirective, + onlyIfCached: parseOnlyIfCachedDirective, + mustUnderstand: parseMustUnderstandDirective, + extensions: parseExtensionsDirective, +} satisfies Record; diff --git a/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseExtensionsDirective.ts b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseExtensionsDirective.ts new file mode 100644 index 0000000..d7b698d --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseExtensionsDirective.ts @@ -0,0 +1,34 @@ +import { createEnum, O } from "@duplojs/utils"; +import type { CacheControlRequestDirectives } from "../types"; + +const extensionRegex = /(?:^|,)\s*(?[!#$%&'*+.^_`|~0-9a-z-]+)(?:\s*=\s*(?:"[ ]*(?[^"]*)[ ]*"|[ ]*(?[^,]*)[ ]*))?\s*(?=,|$)/gi; + +const defaultCacheControlDirectiveEnum = createEnum( + [ + "max-age", + "max-stale", + "min-fresh", + "no-cache", + "no-store", + "no-transform", + "only-if-cached", + "must-understand", + ], +); + +export function parseExtensionsDirective(cacheControlValue: string): CacheControlRequestDirectives["extensions"] { + const extensions: Record = {}; + + for (const match of cacheControlValue.matchAll(extensionRegex)) { + const directiveName = match.groups?.name?.toLowerCase(); + if (!directiveName || defaultCacheControlDirectiveEnum.has(directiveName)) { + continue; + } + + extensions[directiveName] = match.groups?.quotedValue ?? match.groups?.rawValue; + } + + return O.countKeys(extensions) > 0 + ? extensions + : null; +} diff --git a/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMaxAgeDirective.ts b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMaxAgeDirective.ts new file mode 100644 index 0000000..a378547 --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMaxAgeDirective.ts @@ -0,0 +1,15 @@ +import type { CacheControlRequestDirectives } from "../types"; + +const maxAgeRegex = /(?:^|,)\s*max-age\s*=\s*(?\d+)\s*(?=,|$)/i; + +export function parseMaxAgeDirective(cacheControlValue: string): CacheControlRequestDirectives["maxAge"] { + const match = maxAgeRegex.exec(cacheControlValue); + if (match?.groups?.value === undefined) { + return null; + } + + const integerValue = Number.parseInt(match.groups.value, 10); + return Number.isFinite(integerValue) && integerValue >= 0 + ? integerValue + : null; +} diff --git a/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMaxStaleDirective.ts b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMaxStaleDirective.ts new file mode 100644 index 0000000..4af0baf --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMaxStaleDirective.ts @@ -0,0 +1,19 @@ +import type { CacheControlRequestDirectives } from "../types"; + +const maxStaleRegex = /(?:^|,)\s*max-stale(?:\s*=\s*(?\d+))?\s*(?=,|$)/i; + +export function parseMaxStaleDirective(cacheControlValue: string): CacheControlRequestDirectives["maxStale"] { + const match = maxStaleRegex.exec(cacheControlValue); + if (!match) { + return false; + } + + if (match.groups?.value === undefined) { + return true; + } + + const integerValue = Number.parseInt(match.groups.value, 10); + return Number.isFinite(integerValue) && integerValue >= 0 + ? integerValue + : false; +} diff --git a/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMinFreshDirective.ts b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMinFreshDirective.ts new file mode 100644 index 0000000..17c6b07 --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMinFreshDirective.ts @@ -0,0 +1,15 @@ +import type { CacheControlRequestDirectives } from "../types"; + +const minFreshRegex = /(?:^|,)\s*min-fresh\s*=\s*(?\d+)\s*(?=,|$)/i; + +export function parseMinFreshDirective(cacheControlValue: string): CacheControlRequestDirectives["minFresh"] { + const match = minFreshRegex.exec(cacheControlValue); + if (match?.groups?.value === undefined) { + return null; + } + + const integerValue = Number.parseInt(match.groups.value, 10); + return Number.isFinite(integerValue) && integerValue >= 0 + ? integerValue + : null; +} diff --git a/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMustUnderstandDirective.ts b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMustUnderstandDirective.ts new file mode 100644 index 0000000..2bfb82e --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseMustUnderstandDirective.ts @@ -0,0 +1,7 @@ +import type { CacheControlRequestDirectives } from "../types"; + +const mustUnderstandRegex = /(?:^|,)\s*must-understand\s*(?=,|$)/i; + +export function parseMustUnderstandDirective(cacheControlValue: string): CacheControlRequestDirectives["mustUnderstand"] { + return mustUnderstandRegex.test(cacheControlValue); +} diff --git a/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseNoCacheDirective.ts b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseNoCacheDirective.ts new file mode 100644 index 0000000..ca95a33 --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseNoCacheDirective.ts @@ -0,0 +1,21 @@ +import type { CacheControlRequestDirectives } from "../types"; + +const noCacheRegex = /(?:^|,)\s*no-cache(?:\s*=\s*(?:"[ ]*(?[^"]*)[ ]*"|[ ]*(?[^,]*)[ ]*))?\s*(?=,|$)/i; + +export function parseNoCacheDirective(cacheControlValue: string): CacheControlRequestDirectives["noCache"] { + const match = noCacheRegex.exec(cacheControlValue); + + if (!match) { + return false; + } + + const value = match.groups?.quotedValue ?? match.groups?.rawValue; + + if (!value) { + return true; + } + + return value + .split(/[ ]*,[ ]*/) + .filter((fieldName) => fieldName.length > 0); +} diff --git a/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseNoStoreDirective.ts b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseNoStoreDirective.ts new file mode 100644 index 0000000..bb29f30 --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseNoStoreDirective.ts @@ -0,0 +1,7 @@ +import type { CacheControlRequestDirectives } from "../types"; + +const noStoreRegex = /(?:^|,)\s*no-store\s*(?=,|$)/i; + +export function parseNoStoreDirective(cacheControlValue: string): CacheControlRequestDirectives["noStore"] { + return noStoreRegex.test(cacheControlValue); +} diff --git a/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseNoTransformDirective.ts b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseNoTransformDirective.ts new file mode 100644 index 0000000..105d68f --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseNoTransformDirective.ts @@ -0,0 +1,7 @@ +import type { CacheControlRequestDirectives } from "../types"; + +const noTransformRegex = /(?:^|,)\s*no-transform\s*(?=,|$)/i; + +export function parseNoTransformDirective(cacheControlValue: string): CacheControlRequestDirectives["noTransform"] { + return noTransformRegex.test(cacheControlValue); +} diff --git a/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseOnlyIfCachedDirective.ts b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseOnlyIfCachedDirective.ts new file mode 100644 index 0000000..273d2e3 --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/directiveRequestParsers/parseOnlyIfCachedDirective.ts @@ -0,0 +1,7 @@ +import type { CacheControlRequestDirectives } from "../types"; + +const onlyIfCachedRegex = /(?:^|,)\s*only-if-cached\s*(?=,|$)/i; + +export function parseOnlyIfCachedDirective(cacheControlValue: string): CacheControlRequestDirectives["onlyIfCached"] { + return onlyIfCachedRegex.test(cacheControlValue); +} diff --git a/scripts/plugins/cacheController/hooks/createCacheController/hook.ts b/scripts/plugins/cacheController/hooks/createCacheController/hook.ts new file mode 100644 index 0000000..540c70c --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/hook.ts @@ -0,0 +1,57 @@ +import { N } from "@duplojs/utils"; +import type { CacheControlResponseDirectives, CacheControlRequestDirectives } from "./types"; +import { directiveRequestParsers } from "./directiveRequestParsers"; +import { createCacheControlResponseHeader } from "./createResponseHeader"; +import { createHookRouteLifeCycle } from "@core/route"; + +export interface CreateCacheControllerProcessParams { + response?: CacheControlResponseDirectives; +} + +export function createCacheControllerHook( + params?: CreateCacheControllerProcessParams, +) { + const cacheControl = params?.response + ? createCacheControlResponseHeader(params.response) + : null; + + return createHookRouteLifeCycle( + ({ request, addRequestProperties }) => { + let cacheControl = ""; + + if (typeof request.headers["cache-control"] === "string") { + cacheControl = request.headers["cache-control"]; + } else if (request.headers["cache-control"] instanceof Array) { + cacheControl = request.headers["cache-control"].join(","); + } + + const cacheDirectiveStore: CacheControlRequestDirectives = {} as never; + + return addRequestProperties( + { + getCacheControlDirective: < + GenericDirective extends keyof CacheControlRequestDirectives, + >( + directive: GenericDirective, + ) => { + if (cacheDirectiveStore[directive] === undefined) { + cacheDirectiveStore[directive] = directiveRequestParsers[directive]( + cacheControl, + ) as never; + } + + return cacheDirectiveStore[directive]; + }, + }, + ); + }, + { + beforeSendResponse: ({ currentResponse, next }) => { + if (cacheControl && N.between(Number(currentResponse.code), 199, 399)) { + currentResponse.setHeader("cache-control", cacheControl); + } + return next(); + }, + }, + ); +} diff --git a/scripts/plugins/cacheController/hooks/createCacheController/index.ts b/scripts/plugins/cacheController/hooks/createCacheController/index.ts new file mode 100644 index 0000000..05bedd2 --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/index.ts @@ -0,0 +1,5 @@ +export * from "./types"; + +export * from "./directiveRequestParsers"; +export * from "./createResponseHeader"; +export * from "./hook"; diff --git a/scripts/plugins/cacheController/hooks/createCacheController/types/cacheControlDirectives.ts b/scripts/plugins/cacheController/hooks/createCacheController/types/cacheControlDirectives.ts new file mode 100644 index 0000000..fd461c0 --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/types/cacheControlDirectives.ts @@ -0,0 +1,103 @@ +export interface CacheControlRequestDirectives { + + /** "max-age=seconds": the client accepts a response whose age does not exceed N seconds. */ + maxAge: number | null; + + /** "max-stale" or "max-stale=seconds": accepts a stale response, optionally limited to N seconds. */ + maxStale: boolean | number; + + /** "min-fresh=seconds": the client requires the response to stay fresh for at least N more seconds. */ + minFresh: number | null; + + /** + * "no-cache" or "no-cache=field-names" + * In a request: asks for revalidation (do not use a cached response without revalidating), + * optionally limited to specific fields. + */ + noCache: boolean | string[]; + + /** "no-store": do not store the request/response in caches (sensitive data, etc.). */ + noStore: boolean; + + /** "no-transform": forbids transformations (for example compression/content changes) by intermediary caches. */ + noTransform: boolean; + + /** "only-if-cached": only accept a response from a cache (otherwise cache-side 504-like behavior). */ + onlyIfCached: boolean; + + /** + * "must-understand" + * Modern standard directive: a cache must only store the response if it understands + * the semantics of the included directives (useful with extensions). + * (Can appear on responses too, but its purpose is cache understanding semantics.) + */ + mustUnderstand: boolean; + + /** + * Extensions: Cache-Control allows non-standard directives. + * Example: "x-my-directive", "foo=bar" + */ + extensions: Record | null; +} + +export interface CacheControlResponseDirectives { + + /** "max-age=seconds": freshness lifetime for browser/cache. */ + maxAge?: number; + + /** "s-maxage=seconds": like max-age, but for shared caches (proxy/CDN), and takes precedence for them. */ + sMaxAge?: number; + + /** + * "public": the response can be stored by shared caches (CDN/proxy) + * even if it would otherwise be non-cacheable. + */ + public?: true; + + /** + * "private" or "private=field-names" + * Cacheable only by private caches (browser), not by shared caches. + */ + private?: true | string[]; + + /** + * "no-cache" or "no-cache=field-names" + * The response may be stored but MUST be revalidated before reuse. + */ + noCache?: true | string[]; + + /** "no-store": do not store at all. */ + noStore?: true; + + /** "no-transform": no transformation. */ + noTransform?: true; + + /** "must-revalidate": once stale, the cache must revalidate (cannot serve stale directly). */ + mustRevalidate?: true; + + /** "proxy-revalidate": shared-cache variant of must-revalidate. */ + proxyRevalidate?: true; + + /** + * "immutable": the resource will not change during its freshness lifetime (useful with hashed files). + */ + immutable?: true; + + /** + * "stale-while-revalidate=seconds" + * The cache may serve stale content for N seconds while revalidating in the background. + */ + staleWhileRevalidate?: number; + + /** + * "stale-if-error=seconds" + * The cache may serve stale content for N seconds if the origin fails (5xx, timeout, etc.). + */ + staleIfError?: number; + + /** "must-understand": see description above. */ + mustUnderstand?: true; + + /** Non-standard extensions. */ + extensions?: Record; +} diff --git a/scripts/plugins/cacheController/hooks/createCacheController/types/index.ts b/scripts/plugins/cacheController/hooks/createCacheController/types/index.ts new file mode 100644 index 0000000..d030668 --- /dev/null +++ b/scripts/plugins/cacheController/hooks/createCacheController/types/index.ts @@ -0,0 +1 @@ +export * from "./cacheControlDirectives"; diff --git a/scripts/plugins/cacheController/hooks/index.ts b/scripts/plugins/cacheController/hooks/index.ts new file mode 100644 index 0000000..df45577 --- /dev/null +++ b/scripts/plugins/cacheController/hooks/index.ts @@ -0,0 +1 @@ +export * from "./createCacheController"; diff --git a/scripts/plugins/cacheController/index.ts b/scripts/plugins/cacheController/index.ts new file mode 100644 index 0000000..007f69d --- /dev/null +++ b/scripts/plugins/cacheController/index.ts @@ -0,0 +1 @@ +export * from "./hooks"; diff --git a/scripts/plugins/cacheController/tsconfig.build.json b/scripts/plugins/cacheController/tsconfig.build.json new file mode 100644 index 0000000..97d62f0 --- /dev/null +++ b/scripts/plugins/cacheController/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist", + "noEmit": false, + "declaration": true, + "declarationDir": "../../../dist", + "types": null, + }, +} \ No newline at end of file diff --git a/scripts/plugins/cacheController/tsconfig.json b/scripts/plugins/cacheController/tsconfig.json new file mode 100644 index 0000000..92be26f --- /dev/null +++ b/scripts/plugins/cacheController/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.app.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@core/*": ["../../core/*"], + "@plugin-cacheController/*": ["./*"], + }, + }, + "include": ["**/*.ts", "../../core/**/*.ts"], +} diff --git a/scripts/plugins/codeGenerator/plugin.ts b/scripts/plugins/codeGenerator/plugin.ts index 83eae71..6b25940 100644 --- a/scripts/plugins/codeGenerator/plugin.ts +++ b/scripts/plugins/codeGenerator/plugin.ts @@ -3,7 +3,7 @@ import { type HubPlugin } from "@core/hub"; import { A, asserts, DP, E, equal } from "@duplojs/utils"; import { routeToDataParser } from "./routeToDataParser"; import { SF } from "@duplojs/server-utils"; -import { dateTransformer, fileTransformer, timeTransformer } from "./typescriptTransfomer"; +import { dateTransformer, fileTransformer, timeTransformer } from "./typescriptTransformer"; export interface CodeGeneratorPluginParams { outputFile: string; diff --git a/scripts/plugins/codeGenerator/typescriptTransfomer.ts b/scripts/plugins/codeGenerator/typescriptTransformer.ts similarity index 100% rename from scripts/plugins/codeGenerator/typescriptTransfomer.ts rename to scripts/plugins/codeGenerator/typescriptTransformer.ts diff --git a/scripts/plugins/static/index.ts b/scripts/plugins/static/index.ts new file mode 100644 index 0000000..a5d981b --- /dev/null +++ b/scripts/plugins/static/index.ts @@ -0,0 +1,3 @@ +export * from "./makeRouteFolder"; +export * from "./makeRouteFile"; +export * from "./plugin"; diff --git a/scripts/plugins/static/kind.ts b/scripts/plugins/static/kind.ts new file mode 100644 index 0000000..f74993c --- /dev/null +++ b/scripts/plugins/static/kind.ts @@ -0,0 +1,12 @@ +import { createKindNamespace } from "@duplojs/utils"; + +declare module "@duplojs/utils" { + interface ReservedKindNamespace { + DuplojsStaticPlugin: true; + } +} + +export const createStaticPluginKind = createKindNamespace( + // @ts-expect-error reserved kind namespace + "DuplojsStaticPlugin", +); diff --git a/scripts/plugins/static/makeRouteFile.ts b/scripts/plugins/static/makeRouteFile.ts new file mode 100644 index 0000000..ddf7bc8 --- /dev/null +++ b/scripts/plugins/static/makeRouteFile.ts @@ -0,0 +1,66 @@ +import { SDP, type SF } from "@duplojs/server-utils"; +import { A, type AnyTuple, E, unwrap } from "@duplojs/utils"; + +import { useRouteBuilder } from "@core/builders"; +import { IgnoreByRouteStoreMetadata } from "@core/metadata"; +import { ResponseContract } from "@core/response"; +import type { RoutePath } from "@core/route"; +import { createCacheControllerHook, type CacheControlResponseDirectives } from "@plugin-cacheController/hooks"; + +interface MakeRouteFileParams { + readonly source: SF.FileInterface; + readonly path: RoutePath | AnyTuple; + readonly cacheControlConfig?: CacheControlResponseDirectives; +} + +export function makeRouteFile(params: MakeRouteFileParams) { + const localPath = A.coalescing(params.path); + + return useRouteBuilder( + "GET", + localPath, + { + metadata: [IgnoreByRouteStoreMetadata()], + hooks: [ + createCacheControllerHook({ + response: params.cacheControlConfig, + }), + ], + }, + ) + .handler( + [ + ResponseContract.ok("resource.found", SDP.file()), + ResponseContract.notFound("resource.notfound"), + ResponseContract.notModified("resource.notModified"), + ], + async(__, { response, request }) => { + const sourceStatResult = await params.source.stat(); + + if (E.isLeft(sourceStatResult)) { + return response("resource.notfound"); + } + + const resourceStat = unwrap(sourceStatResult); + + if (!resourceStat.isFile) { + return response("resource.notfound"); + } + + if ( + request.headers["if-modified-since"] + && typeof request.headers["if-modified-since"] === "string" + && resourceStat.modifiedAt + && new Date(request.headers["if-modified-since"]).getTime() >= resourceStat.modifiedAt.getTime() + ) { + return response("resource.notModified") + .setHeader("last-modified", resourceStat.modifiedAt.toISOString()); + } + + return resourceStat.modifiedAt + ? response("resource.found", params.source) + .setHeader("last-modified", resourceStat.modifiedAt.toISOString()) + : response("resource.found", params.source); + }, + ); +} diff --git a/scripts/plugins/static/makeRouteFolder.ts b/scripts/plugins/static/makeRouteFolder.ts new file mode 100644 index 0000000..5397f88 --- /dev/null +++ b/scripts/plugins/static/makeRouteFolder.ts @@ -0,0 +1,122 @@ +import { SDP, SF } from "@duplojs/server-utils"; +import { A, type AnyTuple, type D, E, escapeRegExp, Path, pipe, S, unwrap, whenNot } from "@duplojs/utils"; + +import { useRouteBuilder } from "@core/builders"; +import { IgnoreByRouteStoreMetadata } from "@core/metadata"; +import { ResponseContract } from "@core/response"; +import type { RoutePath } from "@core/route"; +import { createCacheControllerHook, type CacheControlResponseDirectives } from "@plugin-cacheController/hooks"; + +interface MakeRouteFolderParams { + readonly source: SF.FolderInterface; + readonly prefix: RoutePath | AnyTuple; + readonly cacheControlConfig?: CacheControlResponseDirectives; + readonly directoryIndexFilePrefix?: string; +} + +export function makeRouteFolder(params: MakeRouteFolderParams) { + const sourcePath = whenNot( + params.source.path, + S.endsWith("/"), + S.concat("/"), + ); + + const localPrefix = A.coalescing(params.prefix); + + const routePath = A.mapTuple( + localPrefix, + (prefix) => `${prefix}/*`, + ); + + const prefixRegex = pipe( + localPrefix, + A.map(escapeRegExp), + A.join("|"), + (value) => new RegExp(`^(?:${value})(?:/|$)`), + ); + + return useRouteBuilder( + "GET", + routePath, + { + metadata: [IgnoreByRouteStoreMetadata()], + hooks: [ + createCacheControllerHook({ + response: params.cacheControlConfig, + }), + ], + }, + ) + .handler( + [ + ResponseContract.ok("resource.found", SDP.file()), + ResponseContract.notFound("resource.notfound"), + ResponseContract.notModified("resource.notModified"), + ], + async(__, { request, response }) => { + if (!Path.isAbsolute(request.path)) { + return response("resource.notfound"); + } + + const resourcePath = pipe( + request.path, + S.replace(prefixRegex, ""), + S.prepend(sourcePath), + ); + + const replyFile = ( + file: SF.FileInterface, + modifiedAt: D.TheDate | null, + ) => { + if ( + request.headers["if-modified-since"] + && typeof request.headers["if-modified-since"] === "string" + && modifiedAt + && new Date(request.headers["if-modified-since"]).getTime() >= modifiedAt.getTime() + ) { + return response("resource.notModified") + .setHeader("last-modified", modifiedAt.toISOString()); + } + + return modifiedAt + ? response("resource.found", file) + .setHeader("last-modified", modifiedAt.toISOString()) + : response("resource.found", file); + }; + + const resource = SF.createFileInterface(resourcePath); + const resourceStatResult = await resource.stat(); + + if (E.isLeft(resourceStatResult)) { + return response("resource.notfound"); + } + + const resourceStat = unwrap(resourceStatResult); + + if (resourceStat.isFile) { + return replyFile(resource, resourceStat.modifiedAt); + } + + if (!params.directoryIndexFilePrefix) { + return response("resource.notfound"); + } + + const indexResource = SF.createFileInterface( + `${resourcePath}/${params.directoryIndexFilePrefix}`, + ); + const indexStatResult = await indexResource.stat(); + + if (E.isLeft(indexStatResult)) { + return response("resource.notfound"); + } + + const indexStat = unwrap(indexStatResult); + + if (!indexStat.isFile) { + return response("resource.notfound"); + } + + return replyFile(indexResource, indexStat.modifiedAt); + }, + ); +} diff --git a/scripts/plugins/static/plugin.ts b/scripts/plugins/static/plugin.ts new file mode 100644 index 0000000..f0b375d --- /dev/null +++ b/scripts/plugins/static/plugin.ts @@ -0,0 +1,109 @@ +import { type AnyTuple, E, kindHeritage, P, toCurriedPredicate, unwrap } from "@duplojs/utils"; +import { SF } from "@duplojs/server-utils"; + +import type { HubPlugin } from "@core/hub"; +import type { RoutePath } from "@core/route"; +import type { CacheControlResponseDirectives } from "@plugin-cacheController/hooks/createCacheController/types"; + +import { createStaticPluginKind } from "./kind"; +import { makeRouteFile } from "./makeRouteFile"; +import { makeRouteFolder } from "./makeRouteFolder"; + +export interface BaseStaticPluginParams { + readonly cacheControlConfig?: CacheControlResponseDirectives; +} + +export interface StaticPluginFileParams extends BaseStaticPluginParams { + readonly path: RoutePath | AnyTuple; +} + +export interface StaticPluginFolderParams extends BaseStaticPluginParams { + + readonly prefix: RoutePath | AnyTuple; + readonly directoryIndexFilePrefix?: string; +} + +export class StaticPluginError extends kindHeritage( + "StaticPluginError", + createStaticPluginKind("static-plugin-error"), + Error, +) { + public constructor( + public information: string, + public error: unknown, + ) { + super({}, ["Error during registration static route."]); + } +} + +export function staticPlugin( + source: SF.FolderInterface, + params: StaticPluginFolderParams +): HubPlugin; + +export function staticPlugin( + source: SF.FileInterface, + params: StaticPluginFileParams +): HubPlugin; + +export function staticPlugin( + ...args: + | [SF.FolderInterface, StaticPluginFolderParams] + | [SF.FileInterface, StaticPluginFileParams] +): HubPlugin { + const route = P.match(args) + .with( + [toCurriedPredicate(SF.isFolderInterface)], + ([source, params]) => makeRouteFolder({ + source, + ...params, + }), + ) + .with( + [toCurriedPredicate(SF.isFileInterface)], + ([source, params]) => makeRouteFile({ + source, + ...params, + }), + ) + .exhaustive(); + + return { + name: "static", + routes: [route], + hooksHubLifeCycle: [ + { + beforeStartServer: async() => { + const statResult = await args[0].stat(); + + if (E.isLeft(statResult)) { + throw new StaticPluginError( + "source does not exit.", + statResult, + ); + } + + const stat = unwrap(statResult); + + if (SF.isFileInterface(args[0]) && !stat.isFile) { + throw new StaticPluginError( + "source does not file (consistency).", + { + source: args[0], + stat, + }, + ); + } else if (SF.isFolderInterface(args[0]) && stat.isFile) { + throw new StaticPluginError( + "source does not folder (consistency).", + { + source: args[0], + stat, + }, + ); + } + }, + }, + ], + }; +} diff --git a/scripts/plugins/static/tsconfig.build.json b/scripts/plugins/static/tsconfig.build.json new file mode 100644 index 0000000..97d62f0 --- /dev/null +++ b/scripts/plugins/static/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist", + "noEmit": false, + "declaration": true, + "declarationDir": "../../../dist", + "types": null, + }, +} \ No newline at end of file diff --git a/scripts/plugins/static/tsconfig.json b/scripts/plugins/static/tsconfig.json new file mode 100644 index 0000000..f2d04fd --- /dev/null +++ b/scripts/plugins/static/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.app.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@core/*": ["../../core/*"], + "@plugin-cacheController/*": ["../cacheController/*"], + "@plugin-static/*": ["./*"], + }, + }, + "include": ["**/*.ts", "../../core/**/*.ts", "../cacheController/**/*ts"], +} diff --git a/tests/core/builders/route/builder.test.ts b/tests/core/builders/route/builder.test.ts index 9be55a5..39f3e3f 100644 --- a/tests/core/builders/route/builder.test.ts +++ b/tests/core/builders/route/builder.test.ts @@ -1,4 +1,4 @@ -import { type RouteBuilder, useRouteBuilder, type Request, type HookParamsOnConstructRequest, type Metadata, IgnoreByRouteStoreMetadata, controlBodyAsFormData, type BodyController, type FormDataBodyReaderParams } from "@core"; +import { type RouteBuilder, useRouteBuilder, type Request, type HookParamsOnConstructRequest, type Metadata, IgnoreByRouteStoreMetadata, controlBodyAsFormData, type BodyController, type FormDataBodyReaderParams, createHookRouteLifeCycle } from "@core"; import { builderKind, type ExpectType } from "@duplojs/utils"; describe("route builder", () => { @@ -133,6 +133,62 @@ describe("route builder", () => { >; }); + it("useRouteBuilder with createHookRouteLifeCycle factory", () => { + const hookWithRequestModifier = createHookRouteLifeCycle( + ({ addRequestProperties }) => addRequestProperties({ aa: 1 }), + { + beforeRouteExecution: ({ request, next }) => { + type Check = ExpectType< + typeof request, + Request & { aa: number }, + "strict" + >; + + return next(); + }, + }, + ); + + const routeBuilder = useRouteBuilder("GET", "/test", { + hooks: [ + hookWithRequestModifier, + { + beforeRouteExecution: ({ request, next }) => { + type Check = ExpectType< + typeof request, + Request, + "strict" + >; + + return next(); + }, + }, + ], + }); + + expect({ ...routeBuilder }).toStrictEqual( + expect.objectContaining({ + [builderKind.runTimeKey]: { + hooks: [ + { + onConstructRequest: expect.any(Function), + beforeRouteExecution: expect.any(Function), + }, + { + beforeRouteExecution: expect.any(Function), + }, + ], + method: "GET", + paths: ["/test"], + preflightSteps: [], + steps: [], + metadata: [], + bodyController: null, + }, + }), + ); + }); + it("useRouteBuilder with Custom bodyController", () => { const bodyController = controlBodyAsFormData({ maxFileQuantity: 10 }); const routeBuilder = useRouteBuilder("GET", "/test", { bodyController }); diff --git a/tests/plugins/cacheController/createCacheController/createResponseHeader.test.ts b/tests/plugins/cacheController/createCacheController/createResponseHeader.test.ts new file mode 100644 index 0000000..ed8fdb1 --- /dev/null +++ b/tests/plugins/cacheController/createCacheController/createResponseHeader.test.ts @@ -0,0 +1,57 @@ +import { createCacheControlResponseHeader } from "@plugin-cacheController/hooks/createCacheController/createResponseHeader"; + +describe("createCacheControlResponseHeader", () => { + it("serializes boolean and numeric directives", () => { + const result = createCacheControlResponseHeader({ + maxAge: 120.9, + sMaxAge: 30, + public: true, + noStore: true, + mustRevalidate: true, + staleWhileRevalidate: 15.8, + }); + + expect(result).toBe( + "max-age=120,s-maxage=30,public,no-store,must-revalidate,stale-while-revalidate=15", + ); + }); + + it("serializes array directives", () => { + const result = createCacheControlResponseHeader({ + private: ["authorization", "cookie"], + noCache: ["set-cookie"], + mustUnderstand: true, + }); + + expect(result).toBe( + "private=\"authorization,cookie\",no-cache=\"set-cookie\",must-understand", + ); + }); + + it("ignores invalid, empty, or unsupported directive values", () => { + const result = createCacheControlResponseHeader({ + maxAge: -1, + sMaxAge: Number.POSITIVE_INFINITY, + public: undefined, + private: [], + noCache: [], + noTransform: false as never, + staleIfError: Number.NaN, + extensions: {}, + }); + + expect(result).toBe(""); + }); + + it("support extensions", () => { + const result = createCacheControlResponseHeader({ + public: true, + extensions: { + foo: "bar", + varyBy: "user", + }, + }); + + expect(result).toBe("public,foo=\"bar\",varyBy=\"user\""); + }); +}); diff --git a/tests/plugins/cacheController/createCacheController/directiveRequestParsers.test.ts b/tests/plugins/cacheController/createCacheController/directiveRequestParsers.test.ts new file mode 100644 index 0000000..c600aca --- /dev/null +++ b/tests/plugins/cacheController/createCacheController/directiveRequestParsers.test.ts @@ -0,0 +1,100 @@ +import { type ExpectType } from "@duplojs/utils"; +import { directiveRequestParsers } from "@plugin-cacheController/hooks/createCacheController/directiveRequestParsers"; +import { parseExtensionsDirective } from "@plugin-cacheController/hooks/createCacheController/directiveRequestParsers/parseExtensionsDirective"; +import { parseMaxAgeDirective } from "@plugin-cacheController/hooks/createCacheController/directiveRequestParsers/parseMaxAgeDirective"; +import { parseMaxStaleDirective } from "@plugin-cacheController/hooks/createCacheController/directiveRequestParsers/parseMaxStaleDirective"; +import { parseMinFreshDirective } from "@plugin-cacheController/hooks/createCacheController/directiveRequestParsers/parseMinFreshDirective"; +import { parseMustUnderstandDirective } from "@plugin-cacheController/hooks/createCacheController/directiveRequestParsers/parseMustUnderstandDirective"; +import { parseNoCacheDirective } from "@plugin-cacheController/hooks/createCacheController/directiveRequestParsers/parseNoCacheDirective"; +import { parseNoStoreDirective } from "@plugin-cacheController/hooks/createCacheController/directiveRequestParsers/parseNoStoreDirective"; +import { parseNoTransformDirective } from "@plugin-cacheController/hooks/createCacheController/directiveRequestParsers/parseNoTransformDirective"; +import { parseOnlyIfCachedDirective } from "@plugin-cacheController/hooks/createCacheController/directiveRequestParsers/parseOnlyIfCachedDirective"; +import type { CacheControlRequestDirectives } from "@plugin-cacheController/hooks/createCacheController/types"; + +describe("directiveRequestParsers", () => { + it("maps each directive to the expected parser", () => { + expect(directiveRequestParsers).toStrictEqual({ + maxAge: parseMaxAgeDirective, + maxStale: parseMaxStaleDirective, + minFresh: parseMinFreshDirective, + noCache: parseNoCacheDirective, + noStore: parseNoStoreDirective, + noTransform: parseNoTransformDirective, + onlyIfCached: parseOnlyIfCachedDirective, + mustUnderstand: parseMustUnderstandDirective, + extensions: parseExtensionsDirective, + }); + }); + + it("propagates the expected parser return types", () => { + const maxAge = directiveRequestParsers.maxAge("max-age=60"); + const maxStale = directiveRequestParsers.maxStale("max-stale"); + const minFresh = directiveRequestParsers.minFresh("min-fresh=20"); + const noCache = directiveRequestParsers.noCache("no-cache"); + const noStore = directiveRequestParsers.noStore("no-store"); + const noTransform = directiveRequestParsers.noTransform("no-transform"); + const onlyIfCached = directiveRequestParsers.onlyIfCached("only-if-cached"); + const mustUnderstand = directiveRequestParsers.mustUnderstand("must-understand"); + const extensions = directiveRequestParsers.extensions("x-test=value"); + + type CheckMaxAge = ExpectType; + type CheckMaxStale = ExpectType; + type CheckMinFresh = ExpectType; + type CheckNoCache = ExpectType; + type CheckNoStore = ExpectType; + type CheckNoTransform = ExpectType; + type CheckOnlyIfCached = ExpectType; + type CheckMustUnderstand = ExpectType; + type CheckExtensions = ExpectType; + }); + + it("extracts each directive value from a complete cache-control header", () => { + const cacheControl = [ + "max-age=60", + "max-stale=120", + "min-fresh=30", + "no-cache=\"authorization,set-cookie\"", + "no-store", + "no-transform", + "only-if-cached", + "must-understand", + "x-test=value", + "x-flag", + ].join(", "); + + expect(directiveRequestParsers.maxAge(cacheControl)).toBe(60); + expect(directiveRequestParsers.maxStale(cacheControl)).toBe(120); + expect(directiveRequestParsers.minFresh(cacheControl)).toBe(30); + expect(directiveRequestParsers.noCache(cacheControl)).toStrictEqual([ + "authorization", + "set-cookie", + ]); + expect(directiveRequestParsers.noStore(cacheControl)).toBe(true); + expect(directiveRequestParsers.noTransform(cacheControl)).toBe(true); + expect(directiveRequestParsers.onlyIfCached(cacheControl)).toBe(true); + expect(directiveRequestParsers.mustUnderstand(cacheControl)).toBe(true); + expect(directiveRequestParsers.extensions(cacheControl)).toStrictEqual({ + "x-test": "value", + "x-flag": undefined, + }); + }); + + it("empty cacheControl", () => { + const cacheControl = ""; + + expect(directiveRequestParsers.maxAge(cacheControl)).toBeNull(); + expect(directiveRequestParsers.maxStale(cacheControl)).toBe(false); + expect(directiveRequestParsers.minFresh(cacheControl)).toBe(null); + expect(directiveRequestParsers.noCache(cacheControl)).toBe(false); + expect(directiveRequestParsers.noStore(cacheControl)).toBe(false); + expect(directiveRequestParsers.onlyIfCached(cacheControl)).toBe(false); + expect(directiveRequestParsers.mustUnderstand(cacheControl)).toBe(false); + expect(directiveRequestParsers.extensions(cacheControl)).toBeNull(); + }); + + it("specific test", () => { + expect(directiveRequestParsers.minFresh(`min-fresh=${"9".repeat(309)}`)).toBe(null); + expect(directiveRequestParsers.maxAge(`max-age=${"9".repeat(309)}`)).toBe(null); + expect(directiveRequestParsers.maxStale(`max-stale=${"9".repeat(309)}`)).toBe(false); + }); +}); diff --git a/tests/plugins/cacheController/createCacheController/hook.test.ts b/tests/plugins/cacheController/createCacheController/hook.test.ts new file mode 100644 index 0000000..7359490 --- /dev/null +++ b/tests/plugins/cacheController/createCacheController/hook.test.ts @@ -0,0 +1,112 @@ +import { type ExpectType } from "@duplojs/utils"; +import { type RouteHookParamsAfter, Request, useRouteBuilder, ResponseContract, PredictedResponse, hookRouteLifeCycleAddRequestProperties } from "@core"; +import { useTestRouteFunctionBuilder } from "@test-utils/useTestRouteFunctionBuilder"; +import { createBodyReader } from "@test-utils/bodyReader"; +import { createCacheControllerHook } from "@plugin-cacheController/hooks/createCacheController/hook"; +import type { CacheControlRequestDirectives } from "@plugin-cacheController/hooks/createCacheController/types"; + +describe("createCacheControllerHook", () => { + it("hook test", () => { + const hook = createCacheControllerHook({ + response: { + maxAge: 120.9, + sMaxAge: 30, + public: true, + noStore: true, + mustRevalidate: true, + staleWhileRevalidate: 15.8, + }, + }); + + const request = new Request({ + headers: { + "cache-control": "max-age=300,no-cache", + }, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }); + + const newRequest = hook.onConstructRequest({ + request, + addRequestProperties: hookRouteLifeCycleAddRequestProperties(request), + }); + + expect(newRequest.getCacheControlDirective("maxAge")).toBe(300); + expect(newRequest.getCacheControlDirective("maxAge")).toBe(300); + expect(newRequest.getCacheControlDirective("noCache")).toBe(true); + expect(newRequest.getCacheControlDirective("noStore")).toBe(false); + + const response = new PredictedResponse("204", "test", undefined); + + hook.beforeSendResponse({ + currentResponse: response, + request: newRequest, + next: () => null as never, + exit: () => null as never, + }); + + expect(response.headers!["cache-control"]) + .toStrictEqual( + "max-age=120,s-maxage=30,public,no-store,must-revalidate,stale-while-revalidate=15", + ); + }); + + it("cache-control header is array", () => { + const hook = createCacheControllerHook(); + + type CheckOutputOnConstructRequest = ExpectType< + ReturnType, + Request & { + getCacheControlDirective< + GenericDirective extends keyof CacheControlRequestDirectives, + >( + directive: GenericDirective + ): CacheControlRequestDirectives[GenericDirective]; + }, + "strict" + >; + + type CheckInputBeforeSendResponse = ExpectType< + Parameters, + [ + RouteHookParamsAfter( + directive: GenericDirective, + ): CacheControlRequestDirectives[GenericDirective]; + }>, + ], + "strict" + >; + + const request = new Request({ + headers: { + "cache-control": ["max-age=300", "no-cache"], + }, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }); + + const newRequest = hook.onConstructRequest({ + request, + addRequestProperties: hookRouteLifeCycleAddRequestProperties(request), + }); + + expect(newRequest.getCacheControlDirective("maxAge")).toBe(300); + }); +}); diff --git a/tests/plugins/cacheController/tsconfig.json b/tests/plugins/cacheController/tsconfig.json new file mode 100644 index 0000000..f3e36a6 --- /dev/null +++ b/tests/plugins/cacheController/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.test.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@core": ["../../../scripts/core/index.ts"], + "@core/*": ["../../../scripts/core/*"], + "@plugin-cacheController": ["../../../scripts/plugins/cacheController/index.ts"], + "@plugin-cacheController/*": ["../../../scripts/plugins/cacheController/*"], + "@test-utils/*": ["../../_utils/*"], + }, + }, + "include": [ + "**/*.ts", + "../../../scripts/plugins/cacheController/**/*.ts", + ], +} diff --git a/tests/plugins/codeGenerator/routeToDataParser.test.ts b/tests/plugins/codeGenerator/routeToDataParser.test.ts index 8b97445..cb80bd6 100644 --- a/tests/plugins/codeGenerator/routeToDataParser.test.ts +++ b/tests/plugins/codeGenerator/routeToDataParser.test.ts @@ -5,7 +5,7 @@ import { omitFunctions } from "@test-utils/omitFunction"; import { bodyAsFormData, convertRoutePath, IgnoreByCodeGeneratorMetadata, routeToDataParser } from "@plugin-codeGenerator"; import { SDPE } from "@duplojs/server-utils"; import { defaultTransformers, render } from "@duplojs/data-parser-tools/toTypescript"; -import { fileTransformer } from "@plugin-codeGenerator/typescriptTransfomer"; +import { fileTransformer } from "@plugin-codeGenerator/typescriptTransformer"; describe("routeToDataParser", () => { const process1 = useProcessBuilder() diff --git a/tests/plugins/codeGenerator/typescriptTransfomer.test.ts b/tests/plugins/codeGenerator/typescriptTransfomer.test.ts index e1e5a18..80ced01 100644 --- a/tests/plugins/codeGenerator/typescriptTransfomer.test.ts +++ b/tests/plugins/codeGenerator/typescriptTransfomer.test.ts @@ -1,7 +1,7 @@ import { defaultTransformers, render } from "@duplojs/data-parser-tools/toTypescript"; import { SDPE } from "@duplojs/server-utils"; import { DPE } from "@duplojs/utils"; -import { dateTransformer, fileTransformer, timeTransformer } from "@plugin-codeGenerator/typescriptTransfomer"; +import { dateTransformer, fileTransformer, timeTransformer } from "@plugin-codeGenerator/typescriptTransformer"; describe("typescript transformer", () => { it("file", () => { diff --git a/tests/plugins/static/makeRouteFile.test.ts b/tests/plugins/static/makeRouteFile.test.ts new file mode 100644 index 0000000..f34328c --- /dev/null +++ b/tests/plugins/static/makeRouteFile.test.ts @@ -0,0 +1,196 @@ +import { D, E } from "@duplojs/utils"; +import { setEnvironment, SF, TESTImplementation } from "@duplojs/server-utils"; +import { PredictedResponse, Request } from "@core"; +import "@plugin-cacheController"; +import { makeRouteFile } from "@plugin-static"; +import { useTestRouteFunctionBuilder } from "@test-utils/useTestRouteFunctionBuilder"; +import { createBodyReader } from "@test-utils/bodyReader"; + +describe("makeRouteFile", async() => { + setEnvironment("TEST"); + const spyResponse = vi.fn(); + + beforeEach(() => { + spyResponse.mockClear(); + }); + + const source = SF.createFileInterface("/tmp/file"); + + const route = makeRouteFile({ + path: "/file", + source, + cacheControlConfig: { + maxAge: 100, + public: true, + }, + }); + + const buildedRoute = await useTestRouteFunctionBuilder( + route, + { + globalHooksRouteLifeCycle: [{ afterSendResponse: spyResponse }], + }, + ); + + it("source found", async() => { + const modifiedAt = D.create("2020-01-01"); + + const defaultStat = { + isFile: true, + modifiedAt, + } as SF.StatInfo; + const spy = vi.fn(() => Promise.resolve(E.success(defaultStat))); + TESTImplementation.set("stat", spy); + + await buildedRoute( + new Request({ + headers: {}, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/file", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse("200", "resource.found", source) + .setHeaders({ + "cache-control": "max-age=100,public", + "last-modified": modifiedAt.toISOString(), + }), + }), + ); + }); + + it("source found no modifiedAt", async() => { + const defaultStat = { + isFile: true, + modifiedAt: null, + } as SF.StatInfo; + const spy = vi.fn(() => Promise.resolve(E.success(defaultStat))); + TESTImplementation.set("stat", spy); + + await buildedRoute( + new Request({ + headers: {}, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/file", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse("200", "resource.found", source) + .setHeaders({ + "cache-control": "max-age=100,public", + }), + }), + ); + }); + + it("source notModified", async() => { + const modifiedAt = D.create("2020-01-01"); + + const defaultStat = { + isFile: true, + modifiedAt, + } as SF.StatInfo; + const spy = vi.fn(() => Promise.resolve(E.success(defaultStat))); + TESTImplementation.set("stat", spy); + + await buildedRoute( + new Request({ + headers: { + "if-modified-since": modifiedAt.toISOString(), + }, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/file", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse("304", "resource.notModified", undefined) + .setHeaders({ + "cache-control": "max-age=100,public", + "last-modified": modifiedAt.toISOString(), + }), + }), + ); + }); + + it("source does not file", async() => { + const defaultStat = { + isFile: false, + } as SF.StatInfo; + const spy = vi.fn(() => Promise.resolve(E.success(defaultStat))); + TESTImplementation.set("stat", spy); + + await buildedRoute( + new Request({ + headers: {}, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/file", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse("404", "resource.notfound", undefined), + }), + ); + }); + + it("source does not exist", async() => { + const spy = vi.fn(() => Promise.resolve(E.left("file-system-stat"))); + TESTImplementation.set("stat", spy); + + await buildedRoute( + new Request({ + headers: {}, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/file", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse("404", "resource.notfound", undefined), + }), + ); + }); +}); diff --git a/tests/plugins/static/makeRouteFolder.test.ts b/tests/plugins/static/makeRouteFolder.test.ts new file mode 100644 index 0000000..0ded02f --- /dev/null +++ b/tests/plugins/static/makeRouteFolder.test.ts @@ -0,0 +1,362 @@ +import { D, E } from "@duplojs/utils"; +import { setEnvironment, SF, TESTImplementation } from "@duplojs/server-utils"; +import { PredictedResponse, Request } from "@core"; +import "@plugin-cacheController"; +import { makeRouteFolder } from "@plugin-static"; +import { useTestRouteFunctionBuilder } from "@test-utils/useTestRouteFunctionBuilder"; +import { createBodyReader } from "@test-utils/bodyReader"; + +describe("makeRouteFolder", async() => { + setEnvironment("TEST"); + const spyResponse = vi.fn(); + + beforeEach(() => { + spyResponse.mockClear(); + }); + + const source = SF.createFolderInterface("/tmp/folder"); + const source2 = SF.createFolderInterface("/tmp/folder"); + + const route = makeRouteFolder({ + prefix: "/folder", + source, + }); + const route2 = makeRouteFolder({ + prefix: "/folder", + source, + cacheControlConfig: { + maxAge: 100, + public: true, + }, + directoryIndexFilePrefix: "index.txt", + }); + + const buildedRoute = await useTestRouteFunctionBuilder( + route, + { + globalHooksRouteLifeCycle: [{ afterSendResponse: spyResponse }], + }, + ); + const buildedRoute2 = await useTestRouteFunctionBuilder( + route2, + { + globalHooksRouteLifeCycle: [{ afterSendResponse: spyResponse }], + }, + ); + + it("source found", async() => { + const modifiedAt = D.create("2020-01-01"); + + const defaultStat = { + isFile: true, + modifiedAt, + } as SF.StatInfo; + const spyStat = vi.fn(() => Promise.resolve(E.success(defaultStat))); + TESTImplementation.set("stat", spyStat); + + await buildedRoute( + new Request({ + headers: {}, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/folder/file.txt", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse( + "200", + "resource.found", + expect.objectContaining({ + path: "/tmp/folder/file.txt", + }), + ) + .setHeader("last-modified", modifiedAt.toISOString()), + }), + ); + }); + + it("source found but source modifiedAt does not exist", async() => { + const defaultStat = { + isFile: true, + modifiedAt: null, + } as SF.StatInfo; + const spyStat = vi.fn(() => Promise.resolve(E.success(defaultStat))); + TESTImplementation.set("stat", spyStat); + + await buildedRoute( + new Request({ + headers: {}, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/folder/file.txt", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse( + "200", + "resource.found", + expect.objectContaining({ + path: "/tmp/folder/file.txt", + }), + ), + }), + ); + }); + + it("source notModified", async() => { + const modifiedAt = D.create("2020-01-01"); + + const defaultStat = { + isFile: true, + modifiedAt, + } as SF.StatInfo; + const spyStat = vi.fn(() => Promise.resolve(E.success(defaultStat))); + TESTImplementation.set("stat", spyStat); + + await buildedRoute( + new Request({ + headers: { + "if-modified-since": modifiedAt.toISOString(), + }, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/folder/file.txt", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse("304", "resource.notModified", undefined) + .setHeader("last-modified", modifiedAt.toISOString()), + }), + ); + }); + + it("path not absolute", async() => { + await buildedRoute( + new Request({ + headers: {}, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/folder/../folder2/file.txt", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse("404", "resource.notfound", undefined), + }), + ); + }); + + it("resource requested notfound", async() => { + const spyStat = vi.fn(() => Promise.resolve(E.left("file-system-stat"))); + TESTImplementation.set("stat", spyStat); + + await buildedRoute( + new Request({ + headers: {}, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/folder/file.txt", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse("404", "resource.notfound", undefined), + }), + ); + }); + + it("resource requested is not a file", async() => { + const defaultStat = { + isFile: false, + } as SF.StatInfo; + const spyStat = vi.fn(() => Promise.resolve(E.success(defaultStat))); + TESTImplementation.set("stat", spyStat); + + await buildedRoute( + new Request({ + headers: {}, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/folder/file.txt", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse("404", "resource.notfound", undefined), + }), + ); + }); + + it("index resource found", async() => { + const modifiedAt = D.create("2020-01-01"); + const spyStat = vi.fn() + .mockResolvedValueOnce( + E.success( + { + isFile: false, + } as SF.StatInfo, + ), + ) + .mockResolvedValueOnce( + E.success( + { + isFile: true, + modifiedAt, + } as SF.StatInfo, + ), + ); + TESTImplementation.set("stat", spyStat); + + await buildedRoute2( + new Request({ + headers: {}, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/folder/childrenFolder", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse( + "200", + "resource.found", + expect.objectContaining({ + path: "/tmp/folder/childrenFolder/index.txt", + }), + ) + .setHeaders({ + "last-modified": modifiedAt.toISOString(), + "cache-control": "max-age=100,public", + }), + }), + ); + }); + + it("index resource is not exist", async() => { + const spyStat = vi.fn() + .mockResolvedValueOnce( + E.success( + { + isFile: false, + } as SF.StatInfo, + ), + ) + .mockResolvedValueOnce( + E.left("file-system-stat"), + ); + TESTImplementation.set("stat", spyStat); + + await buildedRoute2( + new Request({ + headers: {}, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/folder/childrenFolder", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse("404", "resource.notfound", undefined), + }), + ); + }); + + it("index resource is not a file", async() => { + const spyStat = vi.fn() + .mockResolvedValueOnce( + E.success( + { + isFile: false, + } as SF.StatInfo, + ), + ) + .mockResolvedValueOnce( + E.success( + { + isFile: false, + } as SF.StatInfo, + ), + ); + TESTImplementation.set("stat", spyStat); + + await buildedRoute2( + new Request({ + headers: {}, + host: "", + matchedPath: "", + method: "", + origin: "", + path: "/folder/childrenFolder", + params: {}, + query: {}, + url: "", + bodyReader: createBodyReader(), + }), + ); + + expect(spyResponse).toHaveBeenCalledWith( + expect.objectContaining({ + currentResponse: new PredictedResponse("404", "resource.notfound", undefined), + }), + ); + }); +}); diff --git a/tests/plugins/static/plugin.test.ts b/tests/plugins/static/plugin.test.ts new file mode 100644 index 0000000..b96c1d1 --- /dev/null +++ b/tests/plugins/static/plugin.test.ts @@ -0,0 +1,106 @@ +import { setEnvironment, SF, TESTImplementation } from "@duplojs/server-utils"; +import { createHub, launchHookServer } from "@core"; +import { staticPlugin } from "@plugin-static"; +import { E } from "@duplojs/utils"; + +describe("static plugin implementation", () => { + setEnvironment("TEST"); + + const sourceFile = SF.createFileInterface("/tmp/file.txt"); + const sourceFolder = SF.createFolderInterface("/tmp/folder"); + + const hubFile = createHub({ environment: "DEV" }) + .plug(staticPlugin(sourceFile, { path: "/file" })); + + const hubFolder = createHub({ environment: "DEV" }) + .plug(staticPlugin(sourceFolder, { prefix: "/folder" })); + + it("API file source not exist", async() => { + const spyStat = vi.fn(() => Promise.resolve(E.left("file-system-stat"))); + TESTImplementation.set("stat", spyStat); + + await expect( + launchHookServer( + hubFile.aggregatesHooksHubLifeCycle("beforeStartServer"), + hubFile, + {} as any, + ), + ).rejects.toThrow(); + }); + + it("API file source is not file", async() => { + const spyStat = vi.fn(() => Promise.resolve( + E.success( + { + isFile: false, + } as SF.StatInfo, + ), + )); + TESTImplementation.set("stat", spyStat); + + await expect( + launchHookServer( + hubFile.aggregatesHooksHubLifeCycle("beforeStartServer"), + hubFile, + {} as any, + ), + ).rejects.toThrow(); + }); + + it("API file expect good", async() => { + const spyStat = vi.fn(() => Promise.resolve( + E.success( + { + isFile: true, + } as SF.StatInfo, + ), + )); + TESTImplementation.set("stat", spyStat); + + await expect( + launchHookServer( + hubFile.aggregatesHooksHubLifeCycle("beforeStartServer"), + hubFile, + {} as any, + ), + ).resolves.toBeUndefined(); + }); + + it("API folder source is not folder", async() => { + const spyStat = vi.fn(() => Promise.resolve( + E.success( + { + isFile: true, + } as SF.StatInfo, + ), + )); + TESTImplementation.set("stat", spyStat); + + await expect( + launchHookServer( + hubFolder.aggregatesHooksHubLifeCycle("beforeStartServer"), + hubFolder, + {} as any, + ), + ).rejects.toThrow(); + }); + + it("API folder expect good", async() => { + const spyStat = vi.fn(() => Promise.resolve( + E.success( + { + isFile: false, + } as SF.StatInfo, + ), + )); + TESTImplementation.set("stat", spyStat); + + await expect( + launchHookServer( + hubFolder.aggregatesHooksHubLifeCycle("beforeStartServer"), + hubFolder, + {} as any, + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/tests/plugins/static/tsconfig.json b/tests/plugins/static/tsconfig.json new file mode 100644 index 0000000..5a071cb --- /dev/null +++ b/tests/plugins/static/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../tsconfig.test.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@core": ["../../../scripts/core/index.ts"], + "@core/*": ["../../../scripts/core/*"], + "@plugin-static": ["../../../scripts/plugins/static/index.ts"], + "@plugin-static/*": ["../../../scripts/plugins/static/*"], + "@test-utils/*": ["../../_utils/*"], + "@plugin-cacheController": ["../../../scripts/plugins/cacheController/index.ts"], + "@plugin-cacheController/*": ["../../../scripts/plugins/cacheController/*"], + }, + }, + "include": [ + "**/*.ts", + "../../../scripts/plugins/static/**/*.ts", + ], +} diff --git a/tsconfig.json b/tsconfig.json index c1b2064..70b6264 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,8 @@ { "path": "./scripts/interfaces/deno/tsconfig.json" }, { "path": "./scripts/plugins/codeGenerator/tsconfig.json" }, { "path": "./scripts/plugins/openApiGenerator/tsconfig.json" }, + { "path": "./scripts/plugins/static/tsconfig.json" }, + { "path": "./scripts/plugins/cacheController/tsconfig.json" }, { "path": "./tests/_utils/tsconfig.json" }, { "path": "./tests/core/tsconfig.json" }, @@ -17,11 +19,15 @@ { "path": "./tests/interfaces/deno/tsconfig.json" }, { "path": "./tests/plugins/codeGenerator/tsconfig.json" }, { "path": "./tests/plugins/openApiGenerator/tsconfig.json" }, + { "path": "./tests/plugins/static/tsconfig.json" }, + { "path": "./tests/plugins/cacheController/tsconfig.json" }, { "path": "./integration/core/tsconfig.json" }, { "path": "./integration/node/tsconfig.json" }, { "path": "./integration/codeGenerator/tsconfig.json" }, { "path": "./integration/openApiGenerator/tsconfig.json" }, { "path": "./integration/client/tsconfig.json" }, + { "path": "./integration/static/tsconfig.json" }, + { "path": "./tests/plugins/cacheController/tsconfig.json" }, ], } \ No newline at end of file