diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..32aa601 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,32 @@ +name: Deploy Documentation + +on: + push: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "docs" + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + - run: bun install ----frozen-lockfile && bunx typedoc + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: "./docs" + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.gitignore b/.gitignore index ed94386..7ecfd1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ -*.log -.DS_Store -node_modules - /build -/coverage \ No newline at end of file +/coverage +/docs +/node_modules + +*.log +.DS_Store \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 3048d1b..8b42aeb 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 3d2237e..ea14652 100644 --- a/package.json +++ b/package.json @@ -1,178 +1,180 @@ { - "name": "remix-utils", - "version": "7.6.0", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "type": "module", - "exports": { - "./package.json": "./package.json", - "./promise": "./build/common/promise.js", - "./cache-assets": "./build/client/cache-assets.js", - "./cors": "./build/server/cors.js", - "./get-client-ip-address": "./build/server/get-client-ip-address.js", - "./is-prefetch": "./build/server/is-prefetch.js", - "./json-hash": "./build/server/json-hash.js", - "./named-action": "./build/server/named-action.js", - "./parse-accept-header": "./build/server/parse-accept-header.js", - "./preload-route-assets": "./build/server/preload-route-assets.js", - "./redirect-back": "./build/server/redirect-back.js", - "./respond-to": "./build/server/respond-to.js", - "./responses": "./build/server/responses.js", - "./rolling-cookie": "./build/server/rolling-cookie.js", - "./safe-redirect": "./build/server/safe-redirect.js", - "./typed-cookie": "./build/server/typed-cookie.js", - "./typed-session": "./build/server/typed-session.js", - "./client-only": "./build/react/client-only.js", - "./existing-search-params": "./build/react/existing-search-params.js", - "./external-scripts": "./build/react/external-scripts.js", - "./fetcher-type": "./build/react/fetcher-type.js", - "./server-only": "./build/react/server-only.js", - "./use-debounce-fetcher": "./build/react/use-debounce-fetcher.js", - "./use-debounce-submit": "./build/react/use-debounce-submit.js", - "./use-delegated-anchors": "./build/react/use-delegated-anchors.js", - "./use-global-navigation-state": "./build/react/use-global-navigation-state.js", - "./use-hydrated": "./build/react/use-hydrated.js", - "./use-should-hydrate": "./build/react/use-should-hydrate.js", - "./sse/server": "./build/server/event-stream.js", - "./sse/react": "./build/react/use-event-source.js", - "./locales/server": "./build/server/get-client-locales.js", - "./locales/react": "./build/react/use-locales.js", - "./honeypot/server": "./build/server/honeypot.js", - "./honeypot/react": "./build/react/honeypot.js", - "./csrf/server": "./build/server/csrf.js", - "./csrf/react": "./build/react/authenticity-token.js", - "./sec-fetch": "./build/server/sec-fetch.js", - "./timers": "./build/common/timers.js" - }, - "sideEffects": false, - "scripts": { - "prepare": "npm run build", - "build": "tsc --project tsconfig.json --outDir ./build", - "postbuild": "prettier --write \"build/**/*.js\" \"build/**/*.d.ts\"", - "format": "prettier --write \"src/**/*.ts\" \"src/**/*.tsx\" \"test/**/*.ts\" \"test/**/*.tsx\" \"*.md\" \"package.json\"", - "typecheck": "tsc --project tsconfig.json --noEmit", - "lint": "eslint --ext .ts,.tsx src/", - "test": "vitest --run", - "test:watch": "vitest", - "test:coverage": "vitest --coverage", - "test:exports": "bun scripts/check-pkg-exports.ts" - }, - "author": { - "name": "Sergio Xalambrí", - "url": "https://sergiodxa.com", - "email": "hello@sergiodxa.com" - }, - "repository": { - "type": "git", - "url": "https://github.com/sergiodxa/remix-utils" - }, - "keywords": [ - "remix", - "remix.run", - "react", - "utils", - "request", - "response", - "csrf", - "redirect-back", - "client-only", - "hydrated", - "server-only", - "cors", - "rolling cookie", - "safe redirect", - "typed cookie", - "typed session", - "client IP address", - "client locale", - "json hash", - "prefetch", - "named action" - ], - "peerDependencies": { - "@remix-run/cloudflare": "^2.0.0", - "@remix-run/node": "^2.0.0", - "@remix-run/react": "^2.0.0", - "@remix-run/router": "^1.7.2", - "crypto-js": "^4.1.1", - "intl-parse-accept-language": "^1.0.0", - "is-ip": "^5.0.1", - "react": "^18.0.0", - "zod": "^3.22.4" - }, - "peerDependenciesMeta": { - "@remix-run/cloudflare": { - "optional": true - }, - "@remix-run/node": { - "optional": true - }, - "@remix-run/react": { - "optional": true - }, - "@remix-run/router": { - "optional": true - }, - "crypto-js": { - "optional": true - }, - "intl-parse-accept-language": { - "optional": true - }, - "is-ip": { - "optional": true - }, - "react": { - "optional": true - }, - "zod": { - "optional": true - } - }, - "devDependencies": { - "@arethetypeswrong/cli": "^0.15.0", - "@remix-run/node": "^2.0.0", - "@remix-run/react": "^2.0.0", - "@remix-run/router": "^1.7.2", - "@remix-run/testing": "^2.0.0", - "@testing-library/jest-dom": "^6.1.3", - "@testing-library/react": "^15.0.2", - "@types/crypto-js": "^4.1.2", - "@types/react": "^18.2.78", - "@typescript-eslint/eslint-plugin": "^7.0.2", - "@typescript-eslint/parser": "^7.0.1", - "@vitejs/plugin-react": "^4.2.1", - "@vitest/coverage-v8": "^1.5.0", - "crypto-js": "^4.1.1", - "eslint": "^8.12.0", - "eslint-config-prettier": "^9.0.0", - "eslint-import-resolver-typescript": "^3.6.1", - "eslint-plugin-cypress": "^2.15.1", - "eslint-plugin-import": "^2.28.1", - "eslint-plugin-jest-dom": "^5.1.0", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-prettier": "^5.0.0", - "eslint-plugin-promise": "^6.1.1", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-testing-library": "^6.2.1", - "eslint-plugin-unicorn": "^52.0.0", - "happy-dom": "^14.7.1", - "intl-parse-accept-language": "^1.0.0", - "is-ip": "5.0.1", - "msw": "^2.2.13", - "prettier": "^3.0.3", - "react": "^18.0.0", - "react-dom": "^18.0.0", - "ts-node": "^10.9.1", - "typescript": "^5.2.2", - "vite": "^5.2.8", - "vitest": "^1.4.0", - "zod": "^3.22.4" - }, - "dependencies": { - "type-fest": "^4.3.3" - } + "name": "remix-utils", + "version": "7.6.0", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "type": "module", + "exports": { + "./package.json": "./package.json", + "./promise": "./build/common/promise.js", + "./cache-assets": "./build/client/cache-assets.js", + "./cors": "./build/server/cors.js", + "./get-client-ip-address": "./build/server/get-client-ip-address.js", + "./is-prefetch": "./build/server/is-prefetch.js", + "./json-hash": "./build/server/json-hash.js", + "./named-action": "./build/server/named-action.js", + "./parse-accept-header": "./build/server/parse-accept-header.js", + "./preload-route-assets": "./build/server/preload-route-assets.js", + "./redirect-back": "./build/server/redirect-back.js", + "./respond-to": "./build/server/respond-to.js", + "./responses": "./build/server/responses.js", + "./rolling-cookie": "./build/server/rolling-cookie.js", + "./safe-redirect": "./build/server/safe-redirect.js", + "./typed-cookie": "./build/server/typed-cookie.js", + "./typed-session": "./build/server/typed-session.js", + "./client-only": "./build/react/client-only.js", + "./existing-search-params": "./build/react/existing-search-params.js", + "./external-scripts": "./build/react/external-scripts.js", + "./fetcher-type": "./build/react/fetcher-type.js", + "./server-only": "./build/react/server-only.js", + "./use-debounce-fetcher": "./build/react/use-debounce-fetcher.js", + "./use-debounce-submit": "./build/react/use-debounce-submit.js", + "./use-delegated-anchors": "./build/react/use-delegated-anchors.js", + "./use-global-navigation-state": "./build/react/use-global-navigation-state.js", + "./use-hydrated": "./build/react/use-hydrated.js", + "./use-should-hydrate": "./build/react/use-should-hydrate.js", + "./sse/server": "./build/server/event-stream.js", + "./sse/react": "./build/react/use-event-source.js", + "./locales/server": "./build/server/get-client-locales.js", + "./locales/react": "./build/react/use-locales.js", + "./honeypot/server": "./build/server/honeypot.js", + "./honeypot/react": "./build/react/honeypot.js", + "./csrf/server": "./build/server/csrf.js", + "./csrf/react": "./build/react/authenticity-token.js", + "./sec-fetch": "./build/server/sec-fetch.js", + "./timers": "./build/common/timers.js" + }, + "sideEffects": false, + "scripts": { + "prepare": "npm run build", + "build": "tsc --project tsconfig.json --outDir ./build", + "postbuild": "prettier --write \"build/**/*.js\" \"build/**/*.d.ts\"", + "format": "prettier --write \"src/**/*.ts\" \"src/**/*.tsx\" \"test/**/*.ts\" \"test/**/*.tsx\" \"*.md\" \"package.json\"", + "typecheck": "tsc --project tsconfig.json --noEmit", + "lint": "eslint --ext .ts,.tsx src/", + "test": "vitest --run", + "test:watch": "vitest", + "test:coverage": "vitest --coverage", + "test:exports": "bun scripts/check-pkg-exports.ts" + }, + "author": { + "name": "Sergio Xalambrí", + "url": "https://sergiodxa.com", + "email": "hello@sergiodxa.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/sergiodxa/remix-utils" + }, + "keywords": [ + "remix", + "remix.run", + "react", + "utils", + "request", + "response", + "csrf", + "redirect-back", + "client-only", + "hydrated", + "server-only", + "cors", + "rolling cookie", + "safe redirect", + "typed cookie", + "typed session", + "client IP address", + "client locale", + "json hash", + "prefetch", + "named action" + ], + "peerDependencies": { + "@remix-run/cloudflare": "^2.0.0", + "@remix-run/node": "^2.0.0", + "@remix-run/react": "^2.0.0", + "@remix-run/router": "^1.7.2", + "crypto-js": "^4.1.1", + "intl-parse-accept-language": "^1.0.0", + "is-ip": "^5.0.1", + "react": "^18.0.0", + "zod": "^3.22.4" + }, + "peerDependenciesMeta": { + "@remix-run/cloudflare": { + "optional": true + }, + "@remix-run/node": { + "optional": true + }, + "@remix-run/react": { + "optional": true + }, + "@remix-run/router": { + "optional": true + }, + "crypto-js": { + "optional": true + }, + "intl-parse-accept-language": { + "optional": true + }, + "is-ip": { + "optional": true + }, + "react": { + "optional": true + }, + "zod": { + "optional": true + } + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.15.0", + "@remix-run/node": "^2.0.0", + "@remix-run/react": "^2.0.0", + "@remix-run/router": "^1.7.2", + "@remix-run/testing": "^2.0.0", + "@testing-library/jest-dom": "^6.1.3", + "@testing-library/react": "^15.0.2", + "@types/crypto-js": "^4.1.2", + "@types/react": "^18.2.78", + "@typescript-eslint/eslint-plugin": "^7.0.2", + "@typescript-eslint/parser": "^7.0.1", + "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^1.5.0", + "crypto-js": "^4.1.1", + "eslint": "^8.12.0", + "eslint-config-prettier": "^9.0.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-cypress": "^2.15.1", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jest-dom": "^5.1.0", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-testing-library": "^6.2.1", + "eslint-plugin-unicorn": "^52.0.0", + "happy-dom": "^14.7.1", + "intl-parse-accept-language": "^1.0.0", + "is-ip": "5.0.1", + "msw": "^2.2.13", + "prettier": "^3.0.3", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "ts-node": "^10.9.1", + "typedoc": "^0.25.13", + "typedoc-plugin-mdn-links": "^3.1.23", + "typescript": "^5.2.2", + "vite": "^5.2.8", + "vitest": "^1.4.0", + "zod": "^3.22.4" + }, + "dependencies": { + "type-fest": "^4.3.3" + } } diff --git a/src/server/honeypot.ts b/src/server/honeypot.ts index 58900c7..23a83d0 100644 --- a/src/server/honeypot.ts +++ b/src/server/honeypot.ts @@ -1,28 +1,68 @@ import CryptoJS from "crypto-js"; export interface HoneypotInputProps { + /** + * The name expected to be used by the honeypot input field. + */ nameFieldName: string; + /** + * The name expected to be used by the honeypot valid from input field. + */ validFromFieldName: string | null; + /** + * The encrypted value of the current timestamp. + */ encryptedValidFrom: string; } export interface HoneypotConfig { + /** + * Enable randomization of the name field name, this way the honeypot field + * name will be different for each request. + */ randomizeNameFieldName?: boolean; + /** + * The name of the field that will be used for the honeypot input. + */ nameFieldName?: string; + /** + * The name of the field that will be used for the honeypot valid from input. + */ validFromFieldName?: string | null; + /** + * The seed used for the encryption of the valid from timestamp. + */ encryptionSeed?: string; } -export class SpamError extends Error {} +/** + * The error thrown when the Honeypot fails, meaning some automated bot filled + * the form and the request is probably spam. + */ +export class SpamError extends Error { + readonly name = "SpamError"; +} const DEFAULT_NAME_FIELD_NAME = "name__confirm"; const DEFAULT_VALID_FROM_FIELD_NAME = "from__confirm"; +/** + * Module used to implement a Honeypot. + * A Honeypot is a visually hidden input that is used to detect spam bots. This + * field is expected to be left empty by users because they don't see it, but + * bots will fill it falling in the honeypot trap. + */ export class Honeypot { private generatedEncryptionSeed = this.randomValue(); constructor(protected config: HoneypotConfig = {}) {} + /** + * Get the HoneypotInputProps to be used in your forms. + * @param {Object} options The options for the input props. + * @param {number} options.validFromTimestamp Since when the timestamp is valid. + * @returns {HoneypotInputProps} The props to be used in the form. + */ public getInputProps({ validFromTimestamp = Date.now(), } = {}): HoneypotInputProps { diff --git a/test/common/timers.test.ts b/test/common/timers.test.ts index 2011a17..acfd22d 100644 --- a/test/common/timers.test.ts +++ b/test/common/timers.test.ts @@ -1,10 +1,19 @@ import { wait, interval, TimersError } from "../../src/common/timers"; -import { describe, test, expect } from "vitest"; +import { describe, test, expect, beforeAll, vi, afterAll } from "vitest"; describe("Timers", () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + describe(wait.name, () => { test("should resolve after the specified time", async () => { let start = Date.now(); + vi.advanceTimersByTimeAsync(100); await wait(100); let end = Date.now(); expect(end - start).toBeGreaterThanOrEqual(100); @@ -25,6 +34,7 @@ describe("Timers", () => { test("should resolve after the specified time", async () => { let controller = new AbortController(); let start = Date.now(); + vi.advanceTimersByTimeAsync(100); let iterator = interval(100, { signal: controller.signal }); let next = await iterator.next(); let end = Date.now(); diff --git a/tsconfig.json b/tsconfig.json index 0054685..26014a7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,15 @@ { - "compilerOptions": { - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "esModuleInterop": true, - "moduleResolution": "NodeNext", - "module": "NodeNext", - "target": "ES2022", - "strict": true, - "skipLibCheck": true, - "declaration": true, - "jsx": "react" - }, - "exclude": ["node_modules"], - "include": ["src/**/*.ts", "src/**/*.tsx"] + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "esModuleInterop": true, + "moduleResolution": "NodeNext", + "module": "NodeNext", + "target": "ES2022", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "jsx": "react" + }, + "exclude": ["node_modules"], + "include": ["src/**/*.ts", "src/**/*.tsx"] } diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 0000000..52fc24d --- /dev/null +++ b/typedoc.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "name": "Remix Utils", + "includeVersion": true, + "entryPoints": [ + "./src/client/cache-assets.ts", + "./src/common/promise.ts", + "./src/common/timers.ts", + "./src/react/authenticity-token.tsx", + "./src/react/client-only.tsx", + "./src/react/existing-search-params.tsx", + "./src/react/external-scripts.tsx", + "./src/react/fetcher-type.ts", + "./src/react/honeypot.tsx", + "./src/react/server-only.tsx", + "./src/react/use-debounce-fetcher.ts", + "./src/react/use-debounce-submit.ts", + "./src/react/use-delegated-anchors.ts", + "./src/react/use-event-source.ts", + "./src/react/use-global-navigation-state.ts", + "./src/react/use-hydrated.ts", + "./src/react/use-locales.ts", + "./src/react/use-should-hydrate.ts", + "./src/server/cors.ts", + "./src/server/csrf.ts", + "./src/server/event-stream.ts", + "./src/server/get-client-ip-address.ts", + "./src/server/get-client-locales.ts", + "./src/server/honeypot.ts", + "./src/server/is-prefetch.ts", + "./src/server/json-hash.ts", + "./src/server/named-action.ts", + "./src/server/parse-accept-header.ts", + "./src/server/preload-route-assets.ts", + "./src/server/redirect-back.ts", + "./src/server/respond-to.ts", + "./src/server/responses.ts", + "./src/server/rolling-cookie.ts", + "./src/server/safe-redirect.ts", + "./src/server/sec-fetch.ts", + "./src/server/typed-cookie.ts", + "./src/server/typed-session.ts" + ], + "out": "docs", + "json": "docs/index.json", + "cleanOutputDir": true, + "exclude": [ + "./src/server/get-headers.ts", + "./src/react/handle-conventions.ts" + ], + "plugin": ["typedoc-plugin-mdn-links"], + "categorizeByGroup": false +}