├── .editorconfig ├── .github ├── labels.json ├── lock.yml ├── stale.yml └── workflows │ ├── checks.yml │ ├── labels.yml │ └── release.yml ├── .gitignore ├── .husky ├── .gitignore └── commit-msg ├── .npmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── bin └── test.ts ├── configure.ts ├── docker-compose.yml ├── eslint.config.js ├── factories ├── main.ts └── session_middleware_factory.ts ├── index.ts ├── package.json ├── providers └── session_provider.ts ├── src ├── client.ts ├── debug.ts ├── define_config.ts ├── errors.ts ├── plugins │ ├── edge.ts │ └── japa │ │ ├── api_client.ts │ │ └── browser_client.ts ├── session.ts ├── session_middleware.ts ├── stores │ ├── cookie.ts │ ├── dynamodb.ts │ ├── file.ts │ ├── memory.ts │ └── redis.ts ├── types.ts └── values_store.ts ├── stubs ├── config │ └── session.stub └── main.ts ├── tests ├── concurrent_session.spec.ts ├── configure.spec.ts ├── define_config.spec.ts ├── plugins │ ├── api_client.spec.ts │ └── browser_client.spec.ts ├── session.spec.ts ├── session_client.spec.ts ├── session_middleware.spec.ts ├── session_provider.spec.ts ├── stores │ ├── cookie_store.spec.ts │ ├── dynamodb_store.spec.ts │ ├── file_store.spec.ts │ ├── memory_store.spec.ts │ └── redis_store.spec.ts └── values_store.spec.ts ├── tests_helpers ├── dynamodb_schemas │ ├── custom-key-sessions-table.json │ └── sessions-table.json └── index.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [**.min.js] 15 | indent_style = ignore 16 | insert_final_newline = ignore 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.github/labels.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Priority: Critical", 4 | "color": "ea0056", 5 | "description": "The issue needs urgent attention", 6 | "aliases": [] 7 | }, 8 | { 9 | "name": "Priority: High", 10 | "color": "5666ed", 11 | "description": "Look into this issue before picking up any new work", 12 | "aliases": [] 13 | }, 14 | { 15 | "name": "Priority: Medium", 16 | "color": "f4ff61", 17 | "description": "Try to fix the issue for the next patch/minor release", 18 | "aliases": [] 19 | }, 20 | { 21 | "name": "Priority: Low", 22 | "color": "87dfd6", 23 | "description": "Something worth considering, but not a top priority for the team", 24 | "aliases": [] 25 | }, 26 | { 27 | "name": "Semver: Alpha", 28 | "color": "008480", 29 | "description": "Will make it's way to the next alpha version of the package", 30 | "aliases": [] 31 | }, 32 | { 33 | "name": "Semver: Major", 34 | "color": "ea0056", 35 | "description": "Has breaking changes", 36 | "aliases": [] 37 | }, 38 | { 39 | "name": "Semver: Minor", 40 | "color": "fbe555", 41 | "description": "Mainly new features and improvements", 42 | "aliases": [] 43 | }, 44 | { 45 | "name": "Semver: Next", 46 | "color": "5666ed", 47 | "description": "Will make it's way to the bleeding edge version of the package", 48 | "aliases": [] 49 | }, 50 | { 51 | "name": "Semver: Patch", 52 | "color": "87dfd6", 53 | "description": "A bug fix", 54 | "aliases": [] 55 | }, 56 | { 57 | "name": "Status: Abandoned", 58 | "color": "ffffff", 59 | "description": "Dropped and not into consideration", 60 | "aliases": ["wontfix"] 61 | }, 62 | { 63 | "name": "Status: Accepted", 64 | "color": "e5fbf2", 65 | "description": "The proposal or the feature has been accepted for the future versions", 66 | "aliases": [] 67 | }, 68 | { 69 | "name": "Status: Blocked", 70 | "color": "ea0056", 71 | "description": "The work on the issue or the PR is blocked. Check comments for reasoning", 72 | "aliases": [] 73 | }, 74 | { 75 | "name": "Status: Completed", 76 | "color": "008672", 77 | "description": "The work has been completed, but not released yet", 78 | "aliases": [] 79 | }, 80 | { 81 | "name": "Status: In Progress", 82 | "color": "73dbc4", 83 | "description": "Still banging the keyboard", 84 | "aliases": ["in progress"] 85 | }, 86 | { 87 | "name": "Status: On Hold", 88 | "color": "f4ff61", 89 | "description": "The work was started earlier, but is on hold now. Check comments for reasoning", 90 | "aliases": ["On Hold"] 91 | }, 92 | { 93 | "name": "Status: Review Needed", 94 | "color": "fbe555", 95 | "description": "Review from the core team is required before moving forward", 96 | "aliases": [] 97 | }, 98 | { 99 | "name": "Status: Awaiting More Information", 100 | "color": "89f8ce", 101 | "description": "Waiting on the issue reporter or PR author to provide more information", 102 | "aliases": [] 103 | }, 104 | { 105 | "name": "Status: Need Contributors", 106 | "color": "7057ff", 107 | "description": "Looking for contributors to help us move forward with this issue or PR", 108 | "aliases": [] 109 | }, 110 | { 111 | "name": "Type: Bug", 112 | "color": "ea0056", 113 | "description": "The issue has indentified a bug", 114 | "aliases": ["bug"] 115 | }, 116 | { 117 | "name": "Type: Security", 118 | "color": "ea0056", 119 | "description": "Spotted security vulnerability and is a top priority for the core team", 120 | "aliases": [] 121 | }, 122 | { 123 | "name": "Type: Duplicate", 124 | "color": "00837e", 125 | "description": "Already answered or fixed previously", 126 | "aliases": ["duplicate"] 127 | }, 128 | { 129 | "name": "Type: Enhancement", 130 | "color": "89f8ce", 131 | "description": "Improving an existing feature", 132 | "aliases": ["enhancement"] 133 | }, 134 | { 135 | "name": "Type: Feature Request", 136 | "color": "483add", 137 | "description": "Request to add a new feature to the package", 138 | "aliases": [] 139 | }, 140 | { 141 | "name": "Type: Invalid", 142 | "color": "dbdbdb", 143 | "description": "Doesn't really belong here. Maybe use discussion threads?", 144 | "aliases": ["invalid"] 145 | }, 146 | { 147 | "name": "Type: Question", 148 | "color": "eceafc", 149 | "description": "Needs clarification", 150 | "aliases": ["help wanted", "question"] 151 | }, 152 | { 153 | "name": "Type: Documentation Change", 154 | "color": "7057ff", 155 | "description": "Documentation needs some improvements", 156 | "aliases": ["documentation"] 157 | }, 158 | { 159 | "name": "Type: Dependencies Update", 160 | "color": "00837e", 161 | "description": "Bump dependencies", 162 | "aliases": ["dependencies"] 163 | }, 164 | { 165 | "name": "Good First Issue", 166 | "color": "008480", 167 | "description": "Want to contribute? Just filter by this label", 168 | "aliases": ["good first issue"] 169 | } 170 | ] 171 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | # Configuration for Lock Threads - https://github.com/dessant/lock-threads-app 2 | 3 | # Number of days of inactivity before a closed issue or pull request is locked 4 | daysUntilLock: 60 5 | 6 | # Skip issues and pull requests created before a given timestamp. Timestamp must 7 | # follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable 8 | skipCreatedBefore: false 9 | 10 | # Issues and pull requests with these labels will be ignored. Set to `[]` to disable 11 | exemptLabels: ['Type: Security'] 12 | 13 | # Label to add before locking, such as `outdated`. Set to `false` to disable 14 | lockLabel: false 15 | 16 | # Comment to post before locking. Set to `false` to disable 17 | lockComment: > 18 | This thread has been automatically locked since there has not been 19 | any recent activity after it was closed. Please open a new issue for 20 | related bugs. 21 | 22 | # Assign `resolved` as the reason for locking. Set to `false` to disable 23 | setLockReason: false 24 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | 4 | # Number of days of inactivity before a stale issue is closed 5 | daysUntilClose: 7 6 | 7 | # Issues with these labels will never be considered stale 8 | exemptLabels: 9 | - 'Type: Security' 10 | 11 | # Label to use when marking an issue as stale 12 | staleLabel: 'Status: Abandoned' 13 | 14 | # Comment to post when marking an issue as stale. Set to `false` to disable 15 | markComment: > 16 | This issue has been automatically marked as stale because it has not had 17 | recent activity. It will be closed if no further activity occurs. Thank you 18 | for your contributions. 19 | 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false 22 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | - push 4 | - pull_request 5 | - workflow_call 6 | jobs: 7 | lint: 8 | uses: adonisjs/.github/.github/workflows/lint.yml@main 9 | 10 | typecheck: 11 | uses: adonisjs/.github/.github/workflows/typecheck.yml@main 12 | 13 | test_linux: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [20.10.0, 21.x] 18 | services: 19 | redis: 20 | image: redis 21 | options: >- 22 | --health-cmd "redis-cli ping" 23 | --health-interval 10s 24 | --health-timeout 5s 25 | --health-retries 5 26 | ports: 27 | - 6379:6379 28 | dynamodb-local: 29 | image: amazon/dynamodb-local 30 | ports: 31 | - 8000:8000 32 | steps: 33 | - uses: actions/checkout@v2 34 | - name: Create DynamoDB Table 35 | env: 36 | AWS_ACCESS_KEY_ID: accessKeyId 37 | AWS_SECRET_ACCESS_KEY: secretAccessKey 38 | AWS_DEFAULT_REGION: us-east-1 39 | run: | 40 | aws dynamodb create-table --endpoint-url http://localhost:8000 \ 41 | --table-name Session \ 42 | --key-schema AttributeName=key,KeyType=HASH \ 43 | --attribute-definitions AttributeName=key,AttributeType=S \ 44 | --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ 45 | && aws dynamodb create-table --endpoint-url http://localhost:8000 \ 46 | --table-name CustomKeySession \ 47 | --key-schema AttributeName=sessionId,KeyType=HASH \ 48 | --attribute-definitions AttributeName=sessionId,AttributeType=S \ 49 | --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 50 | 51 | - name: Use Node.js ${{ matrix.node-version }} 52 | uses: actions/setup-node@v1 53 | with: 54 | node-version: ${{ matrix.node-version }} 55 | - name: Install 56 | run: npm install 57 | - name: Install Playwright Browsers 58 | run: npx playwright install --with-deps 59 | - name: Run tests 60 | run: npm test 61 | env: 62 | REDIS_HOST: 0.0.0.0 63 | REDIS_PORT: 6379 64 | 65 | test_windows: 66 | runs-on: windows-latest 67 | strategy: 68 | matrix: 69 | node-version: [20.10.0, 21.x] 70 | steps: 71 | - uses: actions/checkout@v2 72 | - name: Use Node.js ${{ matrix.node-version }} 73 | uses: actions/setup-node@v1 74 | with: 75 | node-version: ${{ matrix.node-version }} 76 | - name: Install 77 | run: npm install 78 | - name: Install Playwright Browsers 79 | run: npx playwright install --with-deps 80 | - name: Run tests 81 | run: npm test 82 | env: 83 | NO_REDIS: true 84 | NO_DYNAMODB: true 85 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync labels 2 | on: 3 | workflow_dispatch: 4 | permissions: 5 | issues: write 6 | jobs: 7 | labels: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: EndBug/label-sync@v2 12 | with: 13 | config-file: 'https://raw.githubusercontent.com/thetutlage/static/main/labels.yml' 14 | delete-other-labels: true 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: workflow_dispatch 3 | permissions: 4 | contents: write 5 | id-token: write 6 | jobs: 7 | checks: 8 | uses: ./.github/workflows/checks.yml 9 | release: 10 | needs: checks 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | - name: git config 20 | run: | 21 | git config user.name "${GITHUB_ACTOR}" 22 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 23 | - name: Init npm config 24 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 25 | env: 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | - run: npm install 28 | - run: npm run release -- --ci 29 | env: 30 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .DS_STORE 4 | .nyc_output 5 | .idea 6 | .vscode/ 7 | *.sublime-project 8 | *.sublime-workspace 9 | *.log 10 | build 11 | dist 12 | yarn.lock 13 | shrinkwrap.yaml 14 | package-lock.json 15 | test/__app 16 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | *.md 4 | config.json 5 | .eslintrc.json 6 | package.json 7 | *.html 8 | *.txt 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2022 Harminder Virk, contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @adonisjs/session 2 | 3 |
4 | 5 | [![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] ![][typescript-image] [![license-image]][license-url] 6 | 7 | ## Introduction 8 | Use sessions in your AdonisJS applications with a unified API to persist session data across different data-stores. Has inbuilt support for **cookie**, **files**, and **redis** drivers. 9 | 10 | ## Official Documentation 11 | The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/sessions) 12 | 13 | ## Contributing 14 | One of the primary goals of AdonisJS is to have a vibrant community of users and contributors who believes in the principles of the framework. 15 | 16 | We encourage you to read the [contribution guide](https://github.com/adonisjs/.github/blob/main/docs/CONTRIBUTING.md) before contributing to the framework. 17 | 18 | ## Code of Conduct 19 | In order to ensure that the AdonisJS community is welcoming to all, please review and abide by the [Code of Conduct](https://github.com/adonisjs/.github/blob/main/docs/CODE_OF_CONDUCT.md). 20 | 21 | ## License 22 | AdonisJS session is open-sourced software licensed under the [MIT license](LICENSE.md). 23 | 24 | [gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/adonisjs/session/checks.yml?style=for-the-badge 25 | [gh-workflow-url]: https://github.com/adonisjs/session/actions/workflows/checks.yml "Github action" 26 | 27 | [npm-image]: https://img.shields.io/npm/v/@adonisjs/session/latest.svg?style=for-the-badge&logo=npm 28 | [npm-url]: https://www.npmjs.com/package/@adonisjs/session/v/latest "npm" 29 | 30 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 31 | 32 | [license-url]: LICENSE.md 33 | [license-image]: https://img.shields.io/github/license/adonisjs/session?style=for-the-badge 34 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@japa/assert' 2 | import { snapshot } from '@japa/snapshot' 3 | import { fileSystem } from '@japa/file-system' 4 | import { processCLIArgs, configure, run } from '@japa/runner' 5 | 6 | /* 7 | |-------------------------------------------------------------------------- 8 | | Configure tests 9 | |-------------------------------------------------------------------------- 10 | | 11 | | The configure method accepts the configuration to configure the Japa 12 | | tests runner. 13 | | 14 | | The first method call "processCliArgs" process the command line arguments 15 | | and turns them into a config object. Using this method is not mandatory. 16 | | 17 | | Please consult japa.dev/runner-config for the config docs. 18 | */ 19 | processCLIArgs(process.argv.slice(2)) 20 | configure({ 21 | files: ['tests/**/*.spec.ts'], 22 | plugins: [assert(), fileSystem(), snapshot()], 23 | forceExit: true, 24 | }) 25 | 26 | /* 27 | |-------------------------------------------------------------------------- 28 | | Run tests 29 | |-------------------------------------------------------------------------- 30 | | 31 | | The following "run" method is required to execute all the tests. 32 | | 33 | */ 34 | run() 35 | -------------------------------------------------------------------------------- /configure.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type Configure from '@adonisjs/core/commands/configure' 11 | import { stubsRoot } from './stubs/main.js' 12 | 13 | /** 14 | * Configures the package 15 | */ 16 | export async function configure(command: Configure) { 17 | const codemods = await command.createCodemods() 18 | 19 | /** 20 | * Publish config file 21 | */ 22 | await codemods.makeUsingStub(stubsRoot, 'config/session.stub', {}) 23 | 24 | /** 25 | * Define environment variables 26 | */ 27 | await codemods.defineEnvVariables({ SESSION_DRIVER: 'cookie' }) 28 | 29 | /** 30 | * Define environment variables validations 31 | */ 32 | await codemods.defineEnvValidations({ 33 | variables: { 34 | SESSION_DRIVER: `Env.schema.enum(['cookie', 'memory'] as const)`, 35 | }, 36 | leadingComment: 'Variables for configuring session package', 37 | }) 38 | 39 | /** 40 | * Register middleware 41 | */ 42 | await codemods.registerMiddleware('router', [ 43 | { 44 | path: '@adonisjs/session/session_middleware', 45 | }, 46 | ]) 47 | 48 | /** 49 | * Register provider 50 | */ 51 | await codemods.updateRcFile((rcFile) => { 52 | rcFile.addProvider('@adonisjs/session/session_provider') 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | redis: 5 | image: redis:alpine 6 | ports: 7 | - 6379:6379 8 | command: redis-server --appendonly yes 9 | 10 | redis-insight: 11 | image: redislabs/redisinsight:latest 12 | ports: 13 | - 8001:8001 14 | environment: 15 | - REDIS_URI=redis://redis:6379 16 | 17 | dynamodb-local: 18 | image: amazon/dynamodb-local 19 | ports: 20 | - 8002:8000 21 | command: '-jar DynamoDBLocal.jar -inMemory -sharedDb' 22 | working_dir: /home/dynamodblocal 23 | healthcheck: 24 | test: 25 | [ 26 | 'CMD-SHELL', 27 | '[ "$(curl -s -o /dev/null -I -w ''%{http_code}'' http://localhost:8000)" == "400" ]', 28 | ] 29 | interval: 10s 30 | timeout: 10s 31 | retries: 10 32 | 33 | dynamodb-local-setup: 34 | depends_on: 35 | dynamodb-local: 36 | condition: service_healthy 37 | image: amazon/aws-cli 38 | volumes: 39 | - './tests_helpers/dynamodb_schemas:/tmp/dynamo' 40 | environment: 41 | AWS_ACCESS_KEY_ID: 'accessKeyId' 42 | AWS_SECRET_ACCESS_KEY: 'secretAccessKey' 43 | AWS_REGION: 'us-east-1' 44 | entrypoint: 45 | - bash 46 | command: '-c "for f in /tmp/dynamo/*.json; do aws dynamodb create-table --endpoint-url "http://dynamodb-local:8000" --cli-input-json file://"$${f#./}"; done"' 47 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | export default configPkg() 3 | -------------------------------------------------------------------------------- /factories/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export { SessionMiddlewareFactory } from './session_middleware_factory.js' 11 | -------------------------------------------------------------------------------- /factories/session_middleware_factory.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Emitter } from '@adonisjs/core/events' 11 | import { AppFactory } from '@adonisjs/core/factories/app' 12 | import type { ApplicationService, EventsList } from '@adonisjs/core/types' 13 | 14 | import { defineConfig } from '../index.js' 15 | import SessionMiddleware from '../src/session_middleware.js' 16 | import type { SessionConfig, SessionStoreFactory } from '../src/types.js' 17 | 18 | /** 19 | * Exposes the API to create an instance of the session middleware 20 | * without additional plumbing 21 | */ 22 | export class SessionMiddlewareFactory { 23 | #config: Partial & { 24 | store: string 25 | stores: Record 26 | } = { 27 | store: 'memory', 28 | stores: {}, 29 | } 30 | 31 | #emitter?: Emitter 32 | 33 | #getApp() { 34 | return new AppFactory().create(new URL('./', import.meta.url), () => {}) as ApplicationService 35 | } 36 | 37 | #getEmitter() { 38 | return this.#emitter || new Emitter(this.#getApp()) 39 | } 40 | 41 | /** 42 | * Merge custom options 43 | */ 44 | merge(options: { 45 | config?: Partial & { 46 | store: string 47 | stores: Record 48 | } 49 | emitter?: Emitter 50 | }) { 51 | if (options.config) { 52 | this.#config = options.config 53 | } 54 | 55 | if (options.emitter) { 56 | this.#emitter = options.emitter 57 | } 58 | 59 | return this 60 | } 61 | 62 | /** 63 | * Creates an instance of the session middleware 64 | */ 65 | async create() { 66 | const config = await defineConfig(this.#config).resolver(this.#getApp()) 67 | return new SessionMiddleware(config, this.#getEmitter()) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export * as errors from './src/errors.js' 11 | export { configure } from './configure.js' 12 | export { Session } from './src/session.js' 13 | export { stubsRoot } from './stubs/main.js' 14 | export { defineConfig, stores } from './src/define_config.js' 15 | export { ReadOnlyValuesStore, ValuesStore } from './src/values_store.js' 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adonisjs/session", 3 | "description": "Session provider for AdonisJS", 4 | "version": "7.5.1", 5 | "engines": { 6 | "node": ">=18.16.0" 7 | }, 8 | "main": "build/index.js", 9 | "type": "module", 10 | "files": [ 11 | "build", 12 | "!build/bin", 13 | "!build/tests", 14 | "!build/tests_helpers" 15 | ], 16 | "exports": { 17 | ".": "./build/index.js", 18 | "./factories": "./build/factories/main.js", 19 | "./session_provider": "./build/providers/session_provider.js", 20 | "./session_middleware": "./build/src/session_middleware.js", 21 | "./plugins/edge": "./build/src/plugins/edge.js", 22 | "./plugins/api_client": "./build/src/plugins/japa/api_client.js", 23 | "./plugins/browser_client": "./build/src/plugins/japa/browser_client.js", 24 | "./client": "./build/src/client.js", 25 | "./types": "./build/src/types.js" 26 | }, 27 | "scripts": { 28 | "pretest": "npm run lint", 29 | "test": "cross-env NODE_DEBUG=adonisjs:session c8 npm run quick:test", 30 | "lint": "eslint", 31 | "format": "prettier --write .", 32 | "typecheck": "tsc --noEmit", 33 | "copy:templates": "copyfiles \"stubs/**/*.stub\" --up=\"1\" build", 34 | "precompile": "npm run lint", 35 | "compile": "tsup-node && tsc --emitDeclarationOnly --declaration", 36 | "postcompile": "npm run copy:templates", 37 | "build": "npm run compile", 38 | "version": "npm run build", 39 | "prepublishOnly": "npm run build", 40 | "release": "release-it", 41 | "quick:test": "node --import=ts-node-maintained/register/esm --enable-source-maps bin/test.ts" 42 | }, 43 | "devDependencies": { 44 | "@adonisjs/assembler": "^7.8.2", 45 | "@adonisjs/core": "^6.17.1", 46 | "@adonisjs/eslint-config": "^2.0.0-beta.7", 47 | "@adonisjs/i18n": "^2.2.0", 48 | "@adonisjs/prettier-config": "^1.4.0", 49 | "@adonisjs/redis": "^9.1.0", 50 | "@adonisjs/tsconfig": "^1.4.0", 51 | "@aws-sdk/client-dynamodb": "^3.726.1", 52 | "@aws-sdk/util-dynamodb": "^3.726.1", 53 | "@japa/api-client": "^3.0.3", 54 | "@japa/assert": "^4.0.1", 55 | "@japa/browser-client": "^2.1.1", 56 | "@japa/file-system": "^2.3.2", 57 | "@japa/plugin-adonisjs": "^4.0.0", 58 | "@japa/runner": "^4.1.0", 59 | "@japa/snapshot": "^2.0.8", 60 | "@release-it/conventional-changelog": "^10.0.0", 61 | "@swc/core": "^1.10.7", 62 | "@types/node": "^22.10.7", 63 | "@types/set-cookie-parser": "^2.4.10", 64 | "@types/supertest": "^6.0.2", 65 | "@vinejs/vine": "^3.0.0", 66 | "c8": "^10.1.3", 67 | "copyfiles": "^2.4.1", 68 | "cross-env": "^7.0.3", 69 | "edge.js": "^6.2.0", 70 | "eslint": "^9.18.0", 71 | "get-port": "^7.1.0", 72 | "playwright": "^1.49.1", 73 | "prettier": "^3.4.2", 74 | "release-it": "^18.1.1", 75 | "set-cookie-parser": "^2.7.1", 76 | "supertest": "^7.0.0", 77 | "ts-node-maintained": "^10.9.4", 78 | "tsup": "^8.3.5", 79 | "typescript": "^5.7.3" 80 | }, 81 | "dependencies": { 82 | "@poppinss/macroable": "^1.0.4", 83 | "@poppinss/utils": "^6.9.2" 84 | }, 85 | "peerDependencies": { 86 | "@adonisjs/core": "^6.6.0", 87 | "@adonisjs/redis": "^8.0.1 || ^9.0.0", 88 | "@aws-sdk/client-dynamodb": "^3.658.0", 89 | "@aws-sdk/util-dynamodb": "^3.658.0", 90 | "@japa/api-client": "^2.0.3 || ^3.0.0", 91 | "@japa/browser-client": "^2.0.3", 92 | "edge.js": "^6.0.2" 93 | }, 94 | "peerDependenciesMeta": { 95 | "@adonisjs/redis": { 96 | "optional": true 97 | }, 98 | "edge.js": { 99 | "optional": true 100 | }, 101 | "@aws-sdk/client-dynamodb": { 102 | "optional": true 103 | }, 104 | "@aws-sdk/util-dynamodb": { 105 | "optional": true 106 | }, 107 | "@japa/api-client": { 108 | "optional": true 109 | }, 110 | "@japa/browser-client": { 111 | "optional": true 112 | } 113 | }, 114 | "author": "virk,adonisjs", 115 | "license": "MIT", 116 | "homepage": "https://github.com/adonisjs/session#readme", 117 | "repository": { 118 | "type": "git", 119 | "url": "git+https://github.com/adonisjs/session.git" 120 | }, 121 | "bugs": { 122 | "url": "https://github.com/adonisjs/session/issues" 123 | }, 124 | "keywords": [ 125 | "session", 126 | "adonisjs" 127 | ], 128 | "publishConfig": { 129 | "access": "public", 130 | "provenance": true 131 | }, 132 | "tsup": { 133 | "entry": [ 134 | "./index.ts", 135 | "./factories/main.ts", 136 | "./providers/session_provider.ts", 137 | "./src/session_middleware.ts", 138 | "./src/types.ts", 139 | "./src/plugins/edge.ts", 140 | "./src/plugins/japa/api_client.ts", 141 | "./src/plugins/japa/browser_client.ts", 142 | "./src/client.ts" 143 | ], 144 | "outDir": "./build", 145 | "clean": true, 146 | "format": "esm", 147 | "dts": false, 148 | "sourcemap": true, 149 | "target": "esnext" 150 | }, 151 | "release-it": { 152 | "git": { 153 | "requireCleanWorkingDir": true, 154 | "requireUpstream": true, 155 | "commitMessage": "chore(release): ${version}", 156 | "tagAnnotation": "v${version}", 157 | "push": true, 158 | "tagName": "v${version}" 159 | }, 160 | "github": { 161 | "release": true 162 | }, 163 | "npm": { 164 | "publish": true, 165 | "skipChecks": true 166 | }, 167 | "plugins": { 168 | "@release-it/conventional-changelog": { 169 | "preset": { 170 | "name": "angular" 171 | } 172 | } 173 | } 174 | }, 175 | "c8": { 176 | "reporter": [ 177 | "text", 178 | "html" 179 | ], 180 | "exclude": [ 181 | "tests/**", 182 | "stubs/**", 183 | "factories/**", 184 | "bin/**" 185 | ] 186 | }, 187 | "prettier": "@adonisjs/prettier-config" 188 | } 189 | -------------------------------------------------------------------------------- /providers/session_provider.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { configProvider } from '@adonisjs/core' 11 | import { RuntimeException } from '@poppinss/utils' 12 | import type { ApplicationService } from '@adonisjs/core/types' 13 | 14 | import type { Session } from '../src/session.js' 15 | import SessionMiddleware from '../src/session_middleware.js' 16 | 17 | /** 18 | * Events emitted by the session class 19 | */ 20 | declare module '@adonisjs/core/types' { 21 | interface EventsList { 22 | 'session:initiated': { session: Session } 23 | 'session:committed': { session: Session } 24 | 'session:migrated': { fromSessionId: string; toSessionId: string; session: Session } 25 | } 26 | } 27 | 28 | /** 29 | * Session provider configures the session management inside an 30 | * AdonisJS application 31 | */ 32 | export default class SessionProvider { 33 | constructor(protected app: ApplicationService) {} 34 | 35 | /** 36 | * Registers edge plugin when edge is installed 37 | * in the user application. 38 | */ 39 | protected async registerEdgePlugin() { 40 | if (this.app.usingEdgeJS) { 41 | const edge = await import('edge.js') 42 | const { edgePluginSession } = await import('../src/plugins/edge.js') 43 | edge.default.use(edgePluginSession) 44 | } 45 | } 46 | 47 | /** 48 | * Registering muddleware 49 | */ 50 | register() { 51 | this.app.container.singleton(SessionMiddleware, async (resolver) => { 52 | const sessionConfigProvider = this.app.config.get('session', {}) 53 | 54 | /** 55 | * Resolve config from the provider 56 | */ 57 | const config = await configProvider.resolve(this.app, sessionConfigProvider) 58 | if (!config) { 59 | throw new RuntimeException( 60 | 'Invalid "config/session.ts" file. Make sure you are using the "defineConfig" method' 61 | ) 62 | } 63 | 64 | const emitter = await resolver.make('emitter') 65 | return new SessionMiddleware(config, emitter) 66 | }) 67 | } 68 | 69 | /** 70 | * Adding edge tags (if edge is installed) 71 | */ 72 | async boot() { 73 | await this.registerEdgePlugin() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { cuid } from '@adonisjs/core/helpers' 11 | 12 | import debug from './debug.js' 13 | import { ValuesStore } from './values_store.js' 14 | import type { SessionData, SessionStoreContract } from './types.js' 15 | 16 | /** 17 | * Session client exposes the API to set session data as a client 18 | */ 19 | export class SessionClient { 20 | /** 21 | * Data store 22 | */ 23 | #valuesStore = new ValuesStore({}) 24 | 25 | /** 26 | * Flash messages store 27 | */ 28 | #flashMessagesStore = new ValuesStore({}) 29 | 30 | /** 31 | * The session store to use for reading and writing session data 32 | */ 33 | #store: SessionStoreContract 34 | 35 | /** 36 | * Session key for setting flash messages 37 | */ 38 | flashKey = '__flash__' 39 | 40 | /** 41 | * Session to use when no explicit session id is 42 | * defined 43 | */ 44 | sessionId = cuid() 45 | 46 | constructor(store: SessionStoreContract) { 47 | this.#store = store 48 | } 49 | 50 | /** 51 | * Merge session data 52 | */ 53 | merge(values: SessionData) { 54 | this.#valuesStore.merge(values) 55 | return this 56 | } 57 | 58 | /** 59 | * Merge flash messages 60 | */ 61 | flash(values: SessionData) { 62 | this.#flashMessagesStore.merge(values) 63 | return this 64 | } 65 | 66 | /** 67 | * Commits data to the session store. 68 | */ 69 | async commit() { 70 | if (!this.#flashMessagesStore.isEmpty) { 71 | this.#valuesStore.set(this.flashKey, this.#flashMessagesStore.toJSON()) 72 | } 73 | 74 | debug('committing session data during api request') 75 | if (!this.#valuesStore.isEmpty) { 76 | this.#store.write(this.sessionId, this.#valuesStore.toJSON()) 77 | } 78 | } 79 | 80 | /** 81 | * Destroys the session data with the store 82 | */ 83 | async destroy(sessionId?: string) { 84 | debug('destroying session data during api request') 85 | this.#store.destroy(sessionId || this.sessionId) 86 | } 87 | 88 | /** 89 | * Loads session data from the session store 90 | */ 91 | async load(sessionId?: string) { 92 | const contents = await this.#store.read(sessionId || this.sessionId) 93 | const store = new ValuesStore(contents) 94 | const flashMessages = store.pull(this.flashKey, {}) 95 | 96 | return { 97 | values: store.all(), 98 | flashMessages, 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { debuglog } from 'node:util' 11 | 12 | export default debuglog('adonisjs:session') 13 | -------------------------------------------------------------------------------- /src/define_config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /// 11 | 12 | import string from '@poppinss/utils/string' 13 | import { configProvider } from '@adonisjs/core' 14 | import type { ConfigProvider } from '@adonisjs/core/types' 15 | import { InvalidArgumentsException } from '@poppinss/utils' 16 | import type { CookieOptions } from '@adonisjs/core/types/http' 17 | 18 | import debug from './debug.js' 19 | import { MemoryStore } from './stores/memory.js' 20 | import type { 21 | SessionConfig, 22 | FileStoreConfig, 23 | RedisStoreConfig, 24 | SessionStoreFactory, 25 | DynamoDBStoreConfig, 26 | } from './types.js' 27 | 28 | /** 29 | * Resolved config with stores 30 | */ 31 | type ResolvedConfig> = SessionConfig & { 32 | store: keyof KnownStores 33 | stores: KnownStores 34 | cookie: Partial 35 | } 36 | 37 | /** 38 | * Helper to normalize session config 39 | */ 40 | export function defineConfig< 41 | KnownStores extends Record>, 42 | >( 43 | config: Partial & { 44 | store: keyof KnownStores | 'memory' 45 | stores: KnownStores 46 | } 47 | ): ConfigProvider< 48 | ResolvedConfig<{ 49 | [K in keyof KnownStores]: SessionStoreFactory 50 | }> 51 | > { 52 | debug('processing session config %O', config) 53 | 54 | /** 55 | * Make sure a store is defined 56 | */ 57 | if (!config.store) { 58 | throw new InvalidArgumentsException('Missing "store" property inside the session config') 59 | } 60 | 61 | /** 62 | * Destructuring config with the default values. We pull out 63 | * stores and cookie values, since we have to transform 64 | * them in the output value. 65 | */ 66 | const { stores, cookie, ...rest } = { 67 | enabled: true, 68 | age: '2h', 69 | cookieName: 'adonis_session', 70 | clearWithBrowser: false, 71 | ...config, 72 | } 73 | 74 | const cookieOptions: Partial = { ...cookie } 75 | 76 | /** 77 | * Define maxAge property when session id cookie is 78 | * not a session cookie. 79 | */ 80 | if (!rest.clearWithBrowser) { 81 | cookieOptions.maxAge = string.seconds.parse(rest.age) 82 | debug('computing maxAge "%s" for session id cookie', cookieOptions.maxAge) 83 | } 84 | 85 | return configProvider.create(async (app) => { 86 | const storesNames = Object.keys(config.stores) 87 | 88 | /** 89 | * List of stores with memory store always configured 90 | */ 91 | const storesList = { 92 | memory: () => new MemoryStore(), 93 | } as Record 94 | 95 | /** 96 | * Looping for stores and resolving them 97 | */ 98 | for (let storeName of storesNames) { 99 | const store = config.stores[storeName] 100 | if (typeof store === 'function') { 101 | storesList[storeName] = store 102 | } else { 103 | storesList[storeName] = await store.resolver(app) 104 | } 105 | } 106 | 107 | const transformedConfig = { 108 | ...rest, 109 | cookie: cookieOptions, 110 | stores: storesList as { [K in keyof KnownStores]: SessionStoreFactory }, 111 | } 112 | 113 | debug('transformed session config %O', transformedConfig) 114 | return transformedConfig 115 | }) 116 | } 117 | 118 | /** 119 | * Inbuilt stores to store the session data. 120 | */ 121 | export const stores: { 122 | file: (config: FileStoreConfig) => ConfigProvider 123 | redis: (config: RedisStoreConfig) => ConfigProvider 124 | cookie: () => ConfigProvider 125 | dynamodb: (config: DynamoDBStoreConfig) => ConfigProvider 126 | } = { 127 | file: (config) => { 128 | return configProvider.create(async () => { 129 | const { FileStore } = await import('./stores/file.js') 130 | return (_, sessionConfig: SessionConfig) => { 131 | return new FileStore(config, sessionConfig.age) 132 | } 133 | }) 134 | }, 135 | redis: (config) => { 136 | return configProvider.create(async (app) => { 137 | const { RedisStore } = await import('./stores/redis.js') 138 | const redis = await app.container.make('redis') 139 | 140 | return (_, sessionConfig: SessionConfig) => { 141 | return new RedisStore(redis.connection(config.connection), sessionConfig.age) 142 | } 143 | }) 144 | }, 145 | cookie: () => { 146 | return configProvider.create(async () => { 147 | const { CookieStore } = await import('./stores/cookie.js') 148 | return (ctx, sessionConfig: SessionConfig) => { 149 | return new CookieStore(sessionConfig.cookie, ctx) 150 | } 151 | }) 152 | }, 153 | dynamodb: (config) => { 154 | return configProvider.create(async () => { 155 | const { DynamoDBStore } = await import('./stores/dynamodb.js') 156 | const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb') 157 | 158 | const client = 159 | 'clientConfig' in config ? new DynamoDBClient(config.clientConfig) : config.client 160 | 161 | return (_, sessionConfig: SessionConfig) => { 162 | return new DynamoDBStore(client, sessionConfig.age, { 163 | tableName: config.tableName, 164 | keyAttribute: config.keyAttribute, 165 | }) 166 | } 167 | }) 168 | }, 169 | } 170 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { createError } from '@poppinss/utils' 11 | 12 | /** 13 | * Raised when session store is not mutable 14 | */ 15 | export const E_SESSION_NOT_MUTABLE = createError( 16 | 'Session store is in readonly mode and cannot be mutated', 17 | 'E_SESSION_NOT_MUTABLE', 18 | 500 19 | ) 20 | 21 | /** 22 | * Raised when session store has been initiated 23 | */ 24 | export const E_SESSION_NOT_READY = createError( 25 | 'Session store has not been initiated. Make sure you have registered the session middleware', 26 | 'E_SESSION_NOT_READY', 27 | 500 28 | ) 29 | -------------------------------------------------------------------------------- /src/plugins/edge.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { PluginFn } from 'edge.js/types' 11 | import debug from '../debug.js' 12 | 13 | /** 14 | * The edge plugin for AdonisJS Session adds tags to read 15 | * flash messages 16 | */ 17 | export const edgePluginSession: PluginFn = (edge) => { 18 | debug('registering session tags with edge') 19 | 20 | edge.registerTag({ 21 | tagName: 'flashMessage', 22 | seekable: true, 23 | block: true, 24 | compile(parser, buffer, token) { 25 | const expression = parser.utils.transformAst( 26 | parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), 27 | token.filename, 28 | parser 29 | ) 30 | 31 | const key = parser.utils.stringify(expression) 32 | 33 | /** 34 | * Write an if statement 35 | */ 36 | buffer.writeStatement( 37 | `if (state.flashMessages.has(${key})) {`, 38 | token.filename, 39 | token.loc.start.line 40 | ) 41 | 42 | /** 43 | * Define a local variable 44 | */ 45 | buffer.writeExpression( 46 | `let $message = state.flashMessages.get(${key})`, 47 | token.filename, 48 | token.loc.start.line 49 | ) 50 | 51 | /** 52 | * Create a local variables scope and tell the parser about 53 | * the existence of the "message" variable 54 | */ 55 | parser.stack.defineScope() 56 | parser.stack.defineVariable('$message') 57 | 58 | /** 59 | * Process component children using the parser 60 | */ 61 | token.children.forEach((child) => { 62 | parser.processToken(child, buffer) 63 | }) 64 | 65 | /** 66 | * Clear the scope of the local variables before we 67 | * close the if statement 68 | */ 69 | parser.stack.clearScope() 70 | 71 | /** 72 | * Close if statement 73 | */ 74 | buffer.writeStatement(`}`, token.filename, token.loc.start.line) 75 | }, 76 | }) 77 | 78 | edge.registerTag({ 79 | tagName: 'inputError', 80 | seekable: true, 81 | block: true, 82 | compile(parser, buffer, token) { 83 | const expression = parser.utils.transformAst( 84 | parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), 85 | token.filename, 86 | parser 87 | ) 88 | 89 | const key = parser.utils.stringify(expression) 90 | 91 | /** 92 | * Write an if statement 93 | */ 94 | buffer.writeStatement( 95 | `if (!!state.flashMessages.get('inputErrorsBag', {})[${key}]) {`, 96 | token.filename, 97 | token.loc.start.line 98 | ) 99 | 100 | /** 101 | * Define a local variable 102 | */ 103 | buffer.writeExpression( 104 | `let $messages = state.flashMessages.get('inputErrorsBag', {})[${key}]`, 105 | token.filename, 106 | token.loc.start.line 107 | ) 108 | 109 | /** 110 | * Create a local variables scope and tell the parser about 111 | * the existence of the "messages" variable 112 | */ 113 | parser.stack.defineScope() 114 | parser.stack.defineVariable('$messages') 115 | 116 | /** 117 | * Process component children using the parser 118 | */ 119 | token.children.forEach((child) => { 120 | parser.processToken(child, buffer) 121 | }) 122 | 123 | /** 124 | * Clear the scope of the local variables before we 125 | * close the if statement 126 | */ 127 | parser.stack.clearScope() 128 | 129 | /** 130 | * Close if statement 131 | */ 132 | buffer.writeStatement(`}`, token.filename, token.loc.start.line) 133 | }, 134 | }) 135 | 136 | edge.registerTag({ 137 | tagName: 'error', 138 | seekable: true, 139 | block: true, 140 | compile(parser, buffer, token) { 141 | const expression = parser.utils.transformAst( 142 | parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), 143 | token.filename, 144 | parser 145 | ) 146 | 147 | const key = parser.utils.stringify(expression) 148 | 149 | /** 150 | * Write an if statement 151 | */ 152 | buffer.writeStatement( 153 | `if (state.flashMessages.has(['errorsBag', ${key}])) {`, 154 | token.filename, 155 | token.loc.start.line 156 | ) 157 | 158 | /** 159 | * Define a local variable 160 | */ 161 | buffer.writeExpression( 162 | `let $message = state.flashMessages.get(['errorsBag', ${key}])`, 163 | token.filename, 164 | token.loc.start.line 165 | ) 166 | 167 | /** 168 | * Create a local variables scope and tell the parser about 169 | * the existence of the "messages" variable 170 | */ 171 | parser.stack.defineScope() 172 | parser.stack.defineVariable('$message') 173 | 174 | /** 175 | * Process component children using the parser 176 | */ 177 | token.children.forEach((child) => { 178 | parser.processToken(child, buffer) 179 | }) 180 | 181 | /** 182 | * Clear the scope of the local variables before we 183 | * close the if statement 184 | */ 185 | parser.stack.clearScope() 186 | 187 | /** 188 | * Close if statement 189 | */ 190 | buffer.writeStatement(`}`, token.filename, token.loc.start.line) 191 | }, 192 | }) 193 | 194 | edge.registerTag({ 195 | tagName: 'errors', 196 | seekable: true, 197 | block: true, 198 | compile(parser, buffer, token) { 199 | /** 200 | * Write an if statement 201 | */ 202 | buffer.writeStatement( 203 | `if (state.flashMessages.has('errorsBag')) {`, 204 | token.filename, 205 | token.loc.start.line 206 | ) 207 | 208 | /** 209 | * Define a local variable 210 | */ 211 | buffer.writeExpression( 212 | `let $messages = state.flashMessages.get('errorsBag')`, 213 | token.filename, 214 | token.loc.start.line 215 | ) 216 | 217 | /** 218 | * Create a local variables scope and tell the parser about 219 | * the existence of the "messages" variable 220 | */ 221 | parser.stack.defineScope() 222 | parser.stack.defineVariable('$messages') 223 | 224 | /** 225 | * Process component children using the parser 226 | */ 227 | token.children.forEach((child) => { 228 | parser.processToken(child, buffer) 229 | }) 230 | 231 | /** 232 | * Clear the scope of the local variables before we 233 | * close the if statement 234 | */ 235 | parser.stack.clearScope() 236 | 237 | /** 238 | * Close if statement 239 | */ 240 | buffer.writeStatement(`}`, token.filename, token.loc.start.line) 241 | }, 242 | }) 243 | } 244 | -------------------------------------------------------------------------------- /src/plugins/japa/api_client.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import lodash from '@poppinss/utils/lodash' 11 | import { configProvider } from '@adonisjs/core' 12 | import { RuntimeException } from '@poppinss/utils' 13 | import type { PluginFn } from '@japa/runner/types' 14 | import type { ApplicationService } from '@adonisjs/core/types' 15 | import { ApiClient, ApiRequest, ApiResponse } from '@japa/api-client' 16 | 17 | import { SessionClient } from '../../client.js' 18 | import type { SessionData } from '../../types.js' 19 | 20 | declare module '@japa/api-client' { 21 | export interface ApiRequest { 22 | sessionClient: SessionClient 23 | 24 | /** 25 | * Make HTTP request along with the provided session data 26 | */ 27 | withSession(values: SessionData): this 28 | 29 | /** 30 | * Make HTTP request along with the provided session flash 31 | * messages. 32 | */ 33 | withFlashMessages(values: SessionData): this 34 | } 35 | 36 | export interface ApiResponse { 37 | sessionBag: { 38 | values: SessionData 39 | flashMessages: SessionData 40 | } 41 | 42 | /** 43 | * Get session data from the HTTP response 44 | */ 45 | session(key?: string): any 46 | 47 | /** 48 | * Get flash messages from the HTTP response 49 | */ 50 | flashMessages(): SessionData 51 | 52 | /** 53 | * Get flash messages for a specific key from the HTTP response 54 | */ 55 | flashMessage(key: string): SessionData 56 | 57 | /** 58 | * Assert session key-value pair exists 59 | */ 60 | assertSession(key: string, value?: any): void 61 | 62 | /** 63 | * Assert key is missing in session store 64 | */ 65 | assertSessionMissing(key: string): void 66 | 67 | /** 68 | * Assert flash message key-value pair exists 69 | */ 70 | assertFlashMessage(key: string, value?: any): void 71 | 72 | /** 73 | * Assert key is missing flash messages store 74 | */ 75 | assertFlashMissing(key: string): void 76 | 77 | /** 78 | * Assert flash messages has validation errors for 79 | * the given field 80 | */ 81 | assertHasValidationError(field: string): void 82 | 83 | /** 84 | * Assert flash messages does not have validation errors 85 | * for the given field 86 | */ 87 | assertDoesNotHaveValidationError(field: string): void 88 | 89 | /** 90 | * Assert error message for a given field 91 | */ 92 | assertValidationError(field: string, message: string): void 93 | 94 | /** 95 | * Assert all error messages for a given field 96 | */ 97 | assertValidationErrors(field: string, messages: string[]): void 98 | } 99 | } 100 | 101 | /** 102 | * Hooks AdonisJS Session with the Japa API client 103 | * plugin 104 | */ 105 | export const sessionApiClient = (app: ApplicationService) => { 106 | const pluginFn: PluginFn = async function () { 107 | const sessionConfigProvider = app.config.get('session', {}) 108 | 109 | /** 110 | * Resolve config from the provider 111 | */ 112 | const config = await configProvider.resolve(app, sessionConfigProvider) 113 | if (!config) { 114 | throw new RuntimeException( 115 | 'Invalid "config/session.ts" file. Make sure you are using the "defineConfig" method' 116 | ) 117 | } 118 | 119 | /** 120 | * Stick an singleton session client to APIRequest. The session 121 | * client is used to keep a track of session data we have 122 | * to send during the request. 123 | */ 124 | ApiRequest.getter( 125 | 'sessionClient', 126 | function () { 127 | return new SessionClient(config.stores.memory()) 128 | }, 129 | true 130 | ) 131 | 132 | /** 133 | * Define session data 134 | */ 135 | ApiRequest.macro('withSession', function (this: ApiRequest, data) { 136 | this.sessionClient.merge(data) 137 | return this 138 | }) 139 | 140 | /** 141 | * Define flash messages 142 | */ 143 | ApiRequest.macro('withFlashMessages', function (this: ApiRequest, data) { 144 | this.sessionClient.flash(data) 145 | return this 146 | }) 147 | 148 | /** 149 | * Get session data 150 | */ 151 | ApiResponse.macro('session', function (this: ApiResponse, key) { 152 | return key ? lodash.get(this.sessionBag.values, key) : this.sessionBag.values 153 | }) 154 | 155 | /** 156 | * Get flash messages 157 | */ 158 | ApiResponse.macro('flashMessages', function (this: ApiResponse) { 159 | return this.sessionBag.flashMessages 160 | }) 161 | ApiResponse.macro('flashMessage', function (this: ApiResponse, key) { 162 | return lodash.get(this.sessionBag.flashMessages, key) 163 | }) 164 | 165 | /** 166 | * Response session assertions 167 | */ 168 | ApiResponse.macro('assertSession', function (this: ApiResponse, key, value) { 169 | this.assert!.property(this.session(), key) 170 | if (value !== undefined) { 171 | this.assert!.deepEqual(this.session(key), value) 172 | } 173 | }) 174 | ApiResponse.macro('assertSessionMissing', function (this: ApiResponse, key) { 175 | this.assert!.notProperty(this.session(), key) 176 | }) 177 | ApiResponse.macro('assertFlashMessage', function (this: ApiResponse, key, value) { 178 | this.assert!.property(this.flashMessages(), key) 179 | if (value !== undefined) { 180 | this.assert!.deepEqual(this.flashMessage(key), value) 181 | } 182 | }) 183 | ApiResponse.macro('assertFlashMissing', function (this: ApiResponse, key) { 184 | this.assert!.notProperty(this.flashMessages(), key) 185 | }) 186 | ApiResponse.macro('assertHasValidationError', function (this: ApiResponse, field) { 187 | this.assert!.property(this.flashMessage('errors'), field) 188 | }) 189 | ApiResponse.macro('assertDoesNotHaveValidationError', function (this: ApiResponse, field) { 190 | this.assert!.notProperty(this.flashMessage('errors'), field) 191 | }) 192 | ApiResponse.macro('assertValidationError', function (this: ApiResponse, field, message) { 193 | this.assert!.include(this.flashMessage('errors')?.[field] || [], message) 194 | }) 195 | ApiResponse.macro('assertValidationErrors', function (this: ApiResponse, field, messages) { 196 | this.assert!.deepEqual(this.flashMessage('errors')?.[field] || [], messages) 197 | }) 198 | 199 | /** 200 | * We define the hook using the "request.setup" method because we 201 | * want to allow other Japa hooks to mutate the session store 202 | * without running into race conditions 203 | */ 204 | ApiClient.onRequest((request) => { 205 | request.setup(async () => { 206 | /** 207 | * Set cookie 208 | */ 209 | request.withCookie(config.cookieName, request.sessionClient.sessionId) 210 | 211 | /** 212 | * Persist data 213 | */ 214 | await request.sessionClient.commit() 215 | 216 | /** 217 | * Cleanup if request fails 218 | */ 219 | return async (error: any) => { 220 | if (error) { 221 | await request.sessionClient.destroy() 222 | } 223 | } 224 | }) 225 | 226 | request.teardown(async (response) => { 227 | const sessionId = response.cookie(config.cookieName) 228 | 229 | /** 230 | * Reading session data from the response cookie 231 | */ 232 | response.sessionBag = sessionId 233 | ? await response.request.sessionClient.load(sessionId.value) 234 | : { 235 | values: {}, 236 | flashMessages: {}, 237 | } 238 | 239 | /** 240 | * Cleanup state 241 | */ 242 | await request.sessionClient.destroy(sessionId?.value) 243 | }) 244 | }) 245 | } 246 | 247 | return pluginFn 248 | } 249 | -------------------------------------------------------------------------------- /src/plugins/japa/browser_client.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { configProvider } from '@adonisjs/core' 11 | import { RuntimeException } from '@poppinss/utils' 12 | import type { PluginFn } from '@japa/runner/types' 13 | import { decoratorsCollection } from '@japa/browser-client' 14 | import type { ApplicationService } from '@adonisjs/core/types' 15 | import type { CookieOptions as AdonisCookieOptions } from '@adonisjs/core/types/http' 16 | 17 | import { SessionClient } from '../../client.js' 18 | import type { SessionConfig, SessionData } from '../../types.js' 19 | 20 | declare module 'playwright' { 21 | export interface BrowserContext { 22 | sessionClient: SessionClient 23 | 24 | /** 25 | * Initiate session. The session id cookie will be defined 26 | * if missing 27 | */ 28 | initiateSession(options?: Partial): Promise 29 | 30 | /** 31 | * Returns data from the session store 32 | */ 33 | getSession(): Promise 34 | 35 | /** 36 | * Returns data from the session store 37 | */ 38 | getFlashMessages(): Promise 39 | 40 | /** 41 | * Set session data 42 | */ 43 | setSession(values: SessionData): Promise 44 | 45 | /** 46 | * Set flash messages 47 | */ 48 | setFlashMessages(values: SessionData): Promise 49 | } 50 | } 51 | 52 | /** 53 | * Transforming AdonisJS same site option to playwright 54 | * same site option. 55 | */ 56 | function transformSameSiteOption(sameSite?: AdonisCookieOptions['sameSite']) { 57 | if (!sameSite) { 58 | return 59 | } 60 | 61 | if (sameSite === true || sameSite === 'strict') { 62 | return 'Strict' as const 63 | } 64 | 65 | if (sameSite === 'lax') { 66 | return 'Lax' as const 67 | } 68 | 69 | if (sameSite === 'none') { 70 | return 'None' as const 71 | } 72 | } 73 | 74 | /** 75 | * Transforming AdonisJS session config to playwright cookie options. 76 | */ 77 | function getSessionCookieOptions( 78 | config: SessionConfig, 79 | cookieOptions?: Partial 80 | ) { 81 | const options = { ...config.cookie, ...cookieOptions } 82 | return { 83 | ...options, 84 | expires: undefined, 85 | sameSite: transformSameSiteOption(options.sameSite), 86 | } 87 | } 88 | 89 | /** 90 | * Hooks AdonisJS Session with the Japa browser client 91 | * plugin 92 | */ 93 | export const sessionBrowserClient = (app: ApplicationService) => { 94 | const pluginFn: PluginFn = async function () { 95 | const sessionConfigProvider = app.config.get('session', {}) 96 | 97 | /** 98 | * Resolve config from the provider 99 | */ 100 | const config = await configProvider.resolve(app, sessionConfigProvider) 101 | if (!config) { 102 | throw new RuntimeException( 103 | 'Invalid "config/session.ts" file. Make sure you are using the "defineConfig" method' 104 | ) 105 | } 106 | 107 | decoratorsCollection.register({ 108 | context(context) { 109 | /** 110 | * Reference to session client per browser context 111 | */ 112 | context.sessionClient = new SessionClient(config.stores.memory()) 113 | 114 | /** 115 | * Initiating session store 116 | */ 117 | context.initiateSession = async function (options) { 118 | const sessionId = await context.getCookie(config.cookieName) 119 | if (sessionId) { 120 | context.sessionClient.sessionId = sessionId 121 | return 122 | } 123 | 124 | await context.setCookie( 125 | config.cookieName, 126 | context.sessionClient.sessionId, 127 | getSessionCookieOptions(config, options) 128 | ) 129 | } 130 | 131 | /** 132 | * Returns session data 133 | */ 134 | context.getSession = async function () { 135 | await context.initiateSession() 136 | const sessionData = await context.sessionClient.load() 137 | return sessionData.values 138 | } 139 | 140 | /** 141 | * Returns flash messages from the data store 142 | */ 143 | context.getFlashMessages = async function () { 144 | await context.initiateSession() 145 | const sessionData = await context.sessionClient.load() 146 | return sessionData.flashMessages 147 | } 148 | 149 | /** 150 | * Set session data 151 | */ 152 | context.setSession = async function (values) { 153 | await context.initiateSession() 154 | context.sessionClient.merge(values) 155 | await context.sessionClient.commit() 156 | } 157 | 158 | /** 159 | * Set flash messages 160 | */ 161 | context.setFlashMessages = async function (values) { 162 | await context.initiateSession() 163 | context.sessionClient.flash(values) 164 | await context.sessionClient.commit() 165 | } 166 | 167 | /** 168 | * Destroy session when context is closed 169 | */ 170 | context.on('close', async function () { 171 | await context.sessionClient.destroy() 172 | }) 173 | }, 174 | }) 175 | } 176 | 177 | return pluginFn 178 | } 179 | -------------------------------------------------------------------------------- /src/session.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { I18n } from '@adonisjs/i18n' 11 | import Macroable from '@poppinss/macroable' 12 | import lodash from '@poppinss/utils/lodash' 13 | import { cuid } from '@adonisjs/core/helpers' 14 | import type { HttpContext } from '@adonisjs/core/http' 15 | import type { EmitterService } from '@adonisjs/core/types' 16 | import type { HttpError } from '@adonisjs/core/types/http' 17 | 18 | import debug from './debug.js' 19 | import * as errors from './errors.js' 20 | import { ReadOnlyValuesStore, ValuesStore } from './values_store.js' 21 | import type { 22 | SessionData, 23 | SessionConfig, 24 | SessionStoreFactory, 25 | AllowedSessionValues, 26 | SessionStoreContract, 27 | } from './types.js' 28 | 29 | /** 30 | * The session class exposes the API to read and write values to 31 | * the session store. 32 | * 33 | * A session instance is isolated between requests but 34 | * uses a centralized persistence store and 35 | */ 36 | export class Session extends Macroable { 37 | #store: SessionStoreContract 38 | #emitter: EmitterService 39 | #ctx: HttpContext 40 | #readonly: boolean = false 41 | 42 | /** 43 | * Session values store 44 | */ 45 | #valuesStore?: ValuesStore 46 | 47 | /** 48 | * Session id refers to the session id that will be committed 49 | * as a cookie during the response. 50 | */ 51 | #sessionId: string 52 | 53 | /** 54 | * Session id from cookie refers to the value we read from the 55 | * cookie during the HTTP request. 56 | * 57 | * This only might not exist during the first request. Also during 58 | * session id re-generation, this value will be different from 59 | * the session id. 60 | */ 61 | #sessionIdFromCookie?: string 62 | 63 | /** 64 | * Store of flash messages that be written during the 65 | * HTTP request 66 | */ 67 | responseFlashMessages = new ValuesStore({}) 68 | 69 | /** 70 | * Store of flash messages for the current HTTP request. 71 | */ 72 | flashMessages = new ValuesStore({}) 73 | 74 | /** 75 | * The key to use for storing flash messages inside 76 | * the session store. 77 | */ 78 | flashKey: string = '__flash__' 79 | 80 | /** 81 | * Session id for the current HTTP request 82 | */ 83 | get sessionId() { 84 | return this.#sessionId 85 | } 86 | 87 | /** 88 | * A boolean to know if a fresh session is created during 89 | * the request 90 | */ 91 | get fresh(): boolean { 92 | return this.#sessionIdFromCookie === undefined 93 | } 94 | 95 | /** 96 | * A boolean to know if session is in readonly 97 | * state 98 | */ 99 | get readonly() { 100 | return this.#readonly 101 | } 102 | 103 | /** 104 | * A boolean to know if session store has been initiated 105 | */ 106 | get initiated() { 107 | return !!this.#valuesStore 108 | } 109 | 110 | /** 111 | * A boolean to know if the session id has been re-generated 112 | * during the current request 113 | */ 114 | get hasRegeneratedSession() { 115 | return !!(this.#sessionIdFromCookie && this.#sessionIdFromCookie !== this.#sessionId) 116 | } 117 | 118 | /** 119 | * A boolean to know if the session store is empty 120 | */ 121 | get isEmpty() { 122 | return this.#valuesStore?.isEmpty ?? true 123 | } 124 | 125 | /** 126 | * A boolean to know if the session store has been 127 | * modified 128 | */ 129 | get hasBeenModified() { 130 | return this.#valuesStore?.hasBeenModified ?? false 131 | } 132 | 133 | constructor( 134 | public config: SessionConfig, 135 | storeFactory: SessionStoreFactory, 136 | emitter: EmitterService, 137 | ctx: HttpContext 138 | ) { 139 | super() 140 | this.#ctx = ctx 141 | this.#emitter = emitter 142 | this.#store = storeFactory(ctx, config) 143 | this.#sessionIdFromCookie = ctx.request.cookie(config.cookieName, undefined) 144 | this.#sessionId = this.#sessionIdFromCookie || cuid() 145 | } 146 | 147 | /** 148 | * Returns the flash messages store for a given 149 | * mode 150 | */ 151 | #getFlashStore(mode: 'write' | 'read'): ValuesStore { 152 | if (!this.#valuesStore) { 153 | throw new errors.E_SESSION_NOT_READY() 154 | } 155 | 156 | if (mode === 'write' && this.readonly) { 157 | throw new errors.E_SESSION_NOT_MUTABLE() 158 | } 159 | 160 | return this.responseFlashMessages 161 | } 162 | 163 | /** 164 | * Returns the store instance for a given mode 165 | */ 166 | #getValuesStore(mode: 'write' | 'read'): ValuesStore { 167 | if (!this.#valuesStore) { 168 | throw new errors.E_SESSION_NOT_READY() 169 | } 170 | 171 | if (mode === 'write' && this.readonly) { 172 | throw new errors.E_SESSION_NOT_MUTABLE() 173 | } 174 | 175 | return this.#valuesStore 176 | } 177 | 178 | /** 179 | * Initiates the session store. The method results in a noop 180 | * when called multiple times 181 | */ 182 | async initiate(readonly: boolean): Promise { 183 | if (this.#valuesStore) { 184 | return 185 | } 186 | 187 | debug('initiating session (readonly: %s)', readonly) 188 | 189 | this.#readonly = readonly 190 | const contents = await this.#store.read(this.#sessionId) 191 | this.#valuesStore = new ValuesStore(contents) 192 | 193 | /** 194 | * Extract flash messages from the store and keep a local 195 | * copy of it. 196 | */ 197 | if (this.has(this.flashKey)) { 198 | debug('reading flash data') 199 | if (this.#readonly) { 200 | this.flashMessages.update(this.get(this.flashKey, null)) 201 | } else { 202 | this.flashMessages.update(this.pull(this.flashKey, null)) 203 | } 204 | } 205 | 206 | /** 207 | * Share session with the templates. We assume the view property 208 | * is a reference to edge templates 209 | */ 210 | if ('view' in this.#ctx) { 211 | this.#ctx.view.share({ 212 | session: new ReadOnlyValuesStore(this.#valuesStore.all()), 213 | flashMessages: new ReadOnlyValuesStore(this.flashMessages.all()), 214 | old: function (key: string, defaultValue?: any) { 215 | return this.flashMessages.get(key, defaultValue) 216 | }, 217 | }) 218 | } 219 | 220 | this.#emitter.emit('session:initiated', { session: this }) 221 | } 222 | 223 | /** 224 | * Put a key-value pair to the session data store 225 | */ 226 | put(key: string, value: AllowedSessionValues) { 227 | this.#getValuesStore('write').set(key, value) 228 | } 229 | 230 | /** 231 | * Check if a key exists inside the datastore 232 | */ 233 | has(key: string): boolean { 234 | return this.#getValuesStore('read').has(key) 235 | } 236 | 237 | /** 238 | * Get the value of a key from the session datastore. 239 | * You can specify a default value to use, when key 240 | * does not exists or has undefined value. 241 | */ 242 | get(key: string, defaultValue?: any) { 243 | return this.#getValuesStore('read').get(key, defaultValue) 244 | } 245 | 246 | /** 247 | * Get everything from the session store 248 | */ 249 | all() { 250 | return this.#getValuesStore('read').all() 251 | } 252 | 253 | /** 254 | * Remove a key from the session datastore 255 | */ 256 | forget(key: string) { 257 | return this.#getValuesStore('write').unset(key) 258 | } 259 | 260 | /** 261 | * Read value for a key from the session datastore 262 | * and remove it simultaneously. 263 | */ 264 | pull(key: string, defaultValue?: any) { 265 | return this.#getValuesStore('write').pull(key, defaultValue) 266 | } 267 | 268 | /** 269 | * Increment the value of a key inside the session 270 | * store. 271 | * 272 | * A new key will be defined if does not exists already. 273 | * The value of a new key will be 1 274 | */ 275 | increment(key: string, steps: number = 1) { 276 | return this.#getValuesStore('write').increment(key, steps) 277 | } 278 | 279 | /** 280 | * Increment the value of a key inside the session 281 | * store. 282 | * 283 | * A new key will be defined if does not exists already. 284 | * The value of a new key will be -1 285 | */ 286 | decrement(key: string, steps: number = 1) { 287 | return this.#getValuesStore('write').decrement(key, steps) 288 | } 289 | 290 | /** 291 | * Empty the session store 292 | */ 293 | clear() { 294 | return this.#getValuesStore('write').clear() 295 | } 296 | 297 | /** 298 | * Add a key-value pair to flash messages 299 | */ 300 | flash(key: string, value: AllowedSessionValues): void 301 | flash(keyValue: SessionData): void 302 | flash(key: string | SessionData, value?: AllowedSessionValues): void { 303 | if (typeof key === 'string') { 304 | if (value) { 305 | this.#getFlashStore('write').set(key, value) 306 | } 307 | } else { 308 | this.#getFlashStore('write').merge(key) 309 | } 310 | } 311 | 312 | /** 313 | * Flash errors to the errorsBag. You can read these 314 | * errors via the "@error" tag. 315 | * 316 | * Appends new messages to the existing collection. 317 | */ 318 | flashErrors(errorsCollection: Record) { 319 | this.flash({ errorsBag: errorsCollection }) 320 | } 321 | 322 | /** 323 | * Flash validation error messages. Make sure the error 324 | * is an instance of VineJS ValidationException. 325 | * 326 | * Overrides existing inputErrors 327 | */ 328 | flashValidationErrors(error: HttpError) { 329 | const errorsBag = error.messages.reduce((result: Record, message: any) => { 330 | if (result[message.field]) { 331 | result[message.field].push(message.message) 332 | } else { 333 | result[message.field] = [message.message] 334 | } 335 | return result 336 | }, {}) 337 | 338 | this.flashExcept(['_csrf', '_method', 'password', 'password_confirmation']) 339 | 340 | /** 341 | * Adding the error summary to the "errorsBag" so that 342 | * we display the validation error globally using 343 | * the "@error" tag. 344 | */ 345 | let summary = 'The form could not be saved. Please check the errors below.' 346 | if ('i18n' in this.#ctx) { 347 | summary = (this.#ctx.i18n as I18n).t( 348 | `errors.${error.code}`, 349 | { 350 | count: error.messages.length, 351 | }, 352 | summary 353 | ) 354 | } 355 | 356 | this.flashErrors({ 357 | [String(error.code)]: summary, 358 | }) 359 | 360 | /** 361 | * Adding to inputErrorsBag for "@inputError" tag 362 | * to read validation errors 363 | */ 364 | this.flash('inputErrorsBag', errorsBag) 365 | 366 | /** 367 | * For legacy support and not to break apps using 368 | * the older version of @adonisjs/session package 369 | */ 370 | this.flash('errors', errorsBag) 371 | } 372 | 373 | /** 374 | * Flash form input data to the flash messages store 375 | */ 376 | flashAll() { 377 | return this.#getFlashStore('write').set('input', this.#ctx.request.original()) 378 | } 379 | 380 | /** 381 | * Flash form input data (except some keys) to the flash messages store 382 | */ 383 | flashExcept(keys: string[]): void { 384 | this.#getFlashStore('write').set('input', lodash.omit(this.#ctx.request.original(), keys)) 385 | } 386 | 387 | /** 388 | * Flash form input data (only some keys) to the flash messages store 389 | */ 390 | flashOnly(keys: string[]): void { 391 | this.#getFlashStore('write').set('input', lodash.pick(this.#ctx.request.original(), keys)) 392 | } 393 | 394 | /** 395 | * Reflash messages from the last request in the current response 396 | */ 397 | reflash(): void { 398 | this.#getFlashStore('write').set('reflashed', this.flashMessages.all()) 399 | } 400 | 401 | /** 402 | * Reflash messages (only some keys) from the last 403 | * request in the current response 404 | */ 405 | reflashOnly(keys: string[]) { 406 | this.#getFlashStore('write').set('reflashed', lodash.pick(this.flashMessages.all(), keys)) 407 | } 408 | 409 | /** 410 | * Reflash messages (except some keys) from the last 411 | * request in the current response 412 | */ 413 | reflashExcept(keys: string[]) { 414 | this.#getFlashStore('write').set('reflashed', lodash.omit(this.flashMessages.all(), keys)) 415 | } 416 | 417 | /** 418 | * Re-generate the session id and migrate data to it. 419 | */ 420 | regenerate() { 421 | this.#sessionId = cuid() 422 | } 423 | 424 | /** 425 | * Commit session changes. No more mutations will be 426 | * allowed after commit. 427 | */ 428 | async commit() { 429 | if (!this.#valuesStore || this.readonly) { 430 | return 431 | } 432 | 433 | /** 434 | * If the flash messages store is not empty, we should put 435 | * its messages inside main session store. 436 | */ 437 | if (!this.responseFlashMessages.isEmpty) { 438 | const { input, reflashed, ...others } = this.responseFlashMessages.all() 439 | this.put(this.flashKey, { ...reflashed, ...input, ...others }) 440 | } 441 | 442 | debug('committing session data') 443 | 444 | /** 445 | * Touch the session id cookie to stay alive 446 | */ 447 | this.#ctx.response.cookie(this.config.cookieName, this.#sessionId, this.config.cookie!) 448 | 449 | /** 450 | * Delete the session data when the session store 451 | * is empty. 452 | * 453 | * Also we only destroy the session id we read from the cookie. 454 | * If there was no session id in the cookie, there won't be 455 | * any data inside the store either. 456 | */ 457 | if (this.isEmpty) { 458 | if (this.#sessionIdFromCookie) { 459 | await this.#store.destroy(this.#sessionIdFromCookie) 460 | } 461 | this.#emitter.emit('session:committed', { session: this }) 462 | return 463 | } 464 | 465 | /** 466 | * Touch the store expiry when the session store was 467 | * not modified. 468 | */ 469 | if (!this.hasBeenModified) { 470 | if (this.#sessionIdFromCookie && this.#sessionIdFromCookie !== this.#sessionId) { 471 | await this.#store.destroy(this.#sessionIdFromCookie) 472 | await this.#store.write(this.#sessionId, this.#valuesStore.toJSON()) 473 | this.#emitter.emit('session:migrated', { 474 | fromSessionId: this.#sessionIdFromCookie, 475 | toSessionId: this.sessionId, 476 | session: this, 477 | }) 478 | } else { 479 | await this.#store.touch(this.#sessionId) 480 | } 481 | this.#emitter.emit('session:committed', { session: this }) 482 | return 483 | } 484 | 485 | /** 486 | * Otherwise commit to the session store 487 | */ 488 | if (this.#sessionIdFromCookie && this.#sessionIdFromCookie !== this.#sessionId) { 489 | await this.#store.destroy(this.#sessionIdFromCookie) 490 | await this.#store.write(this.#sessionId, this.#valuesStore.toJSON()) 491 | this.#emitter.emit('session:migrated', { 492 | fromSessionId: this.#sessionIdFromCookie, 493 | toSessionId: this.sessionId, 494 | session: this, 495 | }) 496 | } else { 497 | await this.#store.write(this.#sessionId, this.#valuesStore.toJSON()) 498 | } 499 | 500 | this.#emitter.emit('session:committed', { session: this }) 501 | } 502 | } 503 | -------------------------------------------------------------------------------- /src/session_middleware.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { EmitterService } from '@adonisjs/core/types' 11 | import type { NextFn } from '@adonisjs/core/types/http' 12 | import { ExceptionHandler, HttpContext } from '@adonisjs/core/http' 13 | 14 | import { Session } from './session.js' 15 | import type { SessionConfig, SessionStoreFactory } from './types.js' 16 | 17 | /** 18 | * HttpContext augmentations 19 | */ 20 | declare module '@adonisjs/core/http' { 21 | export interface HttpContext { 22 | session: Session 23 | } 24 | } 25 | 26 | /** 27 | * Overwriting validation exception renderer 28 | */ 29 | const originalErrorHandler = ExceptionHandler.prototype.renderValidationErrorAsHTML 30 | ExceptionHandler.macro('renderValidationErrorAsHTML', async function (error, ctx) { 31 | if (ctx.session) { 32 | ctx.session.flashValidationErrors(error) 33 | ctx.response.redirect('back', true) 34 | } else { 35 | return originalErrorHandler(error, ctx) 36 | } 37 | }) 38 | 39 | /** 40 | * Session middleware is used to initiate the session store 41 | * and commit its values during an HTTP request 42 | */ 43 | export default class SessionMiddleware> { 44 | #config: SessionConfig & { 45 | store: keyof KnownStores 46 | stores: KnownStores 47 | } 48 | #emitter: EmitterService 49 | 50 | constructor( 51 | config: SessionConfig & { 52 | store: keyof KnownStores 53 | stores: KnownStores 54 | }, 55 | emitter: EmitterService 56 | ) { 57 | this.#config = config 58 | this.#emitter = emitter 59 | } 60 | 61 | async handle(ctx: HttpContext, next: NextFn) { 62 | if (!this.#config.enabled) { 63 | return next() 64 | } 65 | 66 | ctx.session = new Session( 67 | this.#config, 68 | this.#config.stores[this.#config.store], // reference to store factory 69 | this.#emitter, 70 | ctx 71 | ) 72 | 73 | /** 74 | * Initiate session store 75 | */ 76 | await ctx.session.initiate(false) 77 | 78 | /** 79 | * Call next middlewares or route handler 80 | */ 81 | const response = await next() 82 | 83 | /** 84 | * Commit store mutations 85 | */ 86 | await ctx.session.commit() 87 | 88 | /** 89 | * Return response 90 | */ 91 | return response 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/stores/cookie.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { HttpContext } from '@adonisjs/core/http' 11 | import type { CookieOptions } from '@adonisjs/core/types/http' 12 | 13 | import debug from '../debug.js' 14 | import type { SessionData, SessionStoreContract } from '../types.js' 15 | 16 | /** 17 | * Cookie store stores the session data inside an encrypted 18 | * cookie. 19 | */ 20 | export class CookieStore implements SessionStoreContract { 21 | #ctx: HttpContext 22 | #config: Partial 23 | 24 | constructor(config: Partial, ctx: HttpContext) { 25 | this.#config = config 26 | this.#ctx = ctx 27 | debug('initiating cookie store %O', this.#config) 28 | } 29 | 30 | /** 31 | * Read session value from the cookie 32 | */ 33 | read(sessionId: string): SessionData | null { 34 | debug('cookie store: reading session data %s', sessionId) 35 | 36 | const cookieValue = this.#ctx.request.encryptedCookie(sessionId) 37 | if (typeof cookieValue !== 'object') { 38 | return null 39 | } 40 | 41 | return cookieValue 42 | } 43 | 44 | /** 45 | * Write session values to the cookie 46 | */ 47 | write(sessionId: string, values: SessionData): void { 48 | debug('cookie store: writing session data %s: %O', sessionId, values) 49 | this.#ctx.response.encryptedCookie(sessionId, values, this.#config) 50 | } 51 | 52 | /** 53 | * Removes the session cookie 54 | */ 55 | destroy(sessionId: string): void { 56 | debug('cookie store: destroying session data %s', sessionId) 57 | if (this.#ctx.request.cookiesList()[sessionId]) { 58 | this.#ctx.response.clearCookie(sessionId) 59 | } 60 | } 61 | 62 | /** 63 | * Updates the cookie with existing cookie values 64 | */ 65 | touch(sessionId: string): void { 66 | const value = this.read(sessionId) 67 | debug('cookie store: touching session data %s', sessionId) 68 | if (!value) { 69 | return 70 | } 71 | 72 | this.write(sessionId, value) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/stores/dynamodb.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import string from '@poppinss/utils/string' 11 | import { MessageBuilder } from '@adonisjs/core/helpers' 12 | import { marshall, unmarshall } from '@aws-sdk/util-dynamodb' 13 | import { 14 | DynamoDBClient, 15 | GetItemCommand, 16 | PutItemCommand, 17 | DeleteItemCommand, 18 | UpdateItemCommand, 19 | } from '@aws-sdk/client-dynamodb' 20 | 21 | import debug from '../debug.js' 22 | import type { SessionStoreContract, SessionData } from '../types.js' 23 | 24 | /** 25 | * DynamoDB store to read/write session to DynamoDB 26 | */ 27 | export class DynamoDBStore implements SessionStoreContract { 28 | #client: DynamoDBClient 29 | #tableName: string 30 | #keyAttribute: string 31 | #ttlSeconds: number 32 | #valueAttribute: string = 'value' 33 | #expiresAtAttribute: string = 'expires_at' 34 | 35 | constructor( 36 | client: DynamoDBClient, 37 | age: string | number, 38 | options?: { 39 | /** 40 | * Defaults to "Session" 41 | */ 42 | tableName?: string 43 | 44 | /** 45 | * Defaults to "key" 46 | */ 47 | keyAttribute?: string 48 | } 49 | ) { 50 | this.#client = client 51 | this.#tableName = options?.tableName ?? 'Session' 52 | this.#keyAttribute = options?.keyAttribute ?? 'key' 53 | this.#ttlSeconds = string.seconds.parse(age) 54 | debug('initiating dynamodb store') 55 | } 56 | 57 | /** 58 | * Returns session data. A new item will be created if it's 59 | * missing. 60 | */ 61 | async read(sessionId: string): Promise { 62 | debug('dynamodb store: reading session data %s', sessionId) 63 | 64 | const command = new GetItemCommand({ 65 | TableName: this.#tableName, 66 | Key: marshall({ [this.#keyAttribute]: sessionId }), 67 | }) 68 | 69 | const response = await this.#client.send(command) 70 | if (!response.Item) { 71 | return null 72 | } 73 | 74 | if (!response.Item[this.#valueAttribute]) { 75 | return null 76 | } 77 | 78 | const item = unmarshall(response.Item) 79 | const contents = item[this.#valueAttribute] as string 80 | const expiresAt = item[this.#expiresAtAttribute] as number 81 | 82 | /** 83 | * Check if the item has been expired and return null (if expired) 84 | */ 85 | if (Date.now() > expiresAt) { 86 | return null 87 | } 88 | 89 | /** 90 | * Verify contents with the session id and return them as an object. The verify 91 | * method can fail when the contents is not JSON. 92 | */ 93 | try { 94 | return new MessageBuilder().verify(contents, sessionId) 95 | } catch { 96 | return null 97 | } 98 | } 99 | 100 | /** 101 | * Write session values to DynamoDB 102 | */ 103 | async write(sessionId: string, values: Object): Promise { 104 | debug('dynamodb store: writing session data %s, %O', sessionId, values) 105 | 106 | const message = new MessageBuilder().build(values, undefined, sessionId) 107 | const command = new PutItemCommand({ 108 | TableName: this.#tableName, 109 | Item: marshall({ 110 | [this.#keyAttribute]: sessionId, 111 | [this.#valueAttribute]: message, 112 | [this.#expiresAtAttribute]: Date.now() + this.#ttlSeconds * 1000, 113 | }), 114 | }) 115 | 116 | await this.#client.send(command) 117 | } 118 | 119 | /** 120 | * Cleanup session item by removing it 121 | */ 122 | async destroy(sessionId: string): Promise { 123 | debug('dynamodb store: destroying session data %s', sessionId) 124 | 125 | const command = new DeleteItemCommand({ 126 | TableName: this.#tableName, 127 | Key: marshall({ [this.#keyAttribute]: sessionId }), 128 | }) 129 | 130 | await this.#client.send(command) 131 | } 132 | 133 | /** 134 | * Updates the value expiry 135 | */ 136 | async touch(sessionId: string): Promise { 137 | debug('dynamodb store: touching session data %s', sessionId) 138 | 139 | const command = new UpdateItemCommand({ 140 | TableName: this.#tableName, 141 | Key: marshall({ [this.#keyAttribute]: sessionId }), 142 | UpdateExpression: 'SET #expires_at = :expires_at', 143 | ExpressionAttributeNames: { 144 | '#expires_at': this.#expiresAtAttribute, 145 | }, 146 | ExpressionAttributeValues: marshall({ 147 | ':expires_at': Date.now() + this.#ttlSeconds * 1000, 148 | }), 149 | }) 150 | 151 | await this.#client.send(command) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/stores/file.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { Stats } from 'node:fs' 11 | import { dirname, join } from 'node:path' 12 | import string from '@poppinss/utils/string' 13 | import { MessageBuilder } from '@adonisjs/core/helpers' 14 | import { access, mkdir, readFile, rm, writeFile, utimes, stat } from 'node:fs/promises' 15 | 16 | import debug from '../debug.js' 17 | import type { FileStoreConfig, SessionData, SessionStoreContract } from '../types.js' 18 | 19 | /** 20 | * File store writes the session data on the file system as. Each session 21 | * id gets its own file. 22 | * 23 | */ 24 | export class FileStore implements SessionStoreContract { 25 | #config: FileStoreConfig 26 | #age: string | number 27 | 28 | /** 29 | * @param {FileStoreConfig} config 30 | * @param {string|number} The age must be in seconds or a time expression 31 | */ 32 | constructor(config: FileStoreConfig, age: string | number) { 33 | this.#config = config 34 | this.#age = age 35 | debug('initiating file store %O', this.#config) 36 | } 37 | 38 | /** 39 | * Returns an absolute path to the session id file 40 | */ 41 | #getFilePath(sessionId: string): string { 42 | return join(this.#config.location, `${sessionId}.txt`) 43 | } 44 | 45 | /** 46 | * Check if a file exists at a given path or not 47 | */ 48 | async #pathExists(path: string) { 49 | try { 50 | await access(path) 51 | return true 52 | } catch { 53 | return false 54 | } 55 | } 56 | 57 | /** 58 | * Returns stats for a file and ignoring missing 59 | * files. 60 | */ 61 | async #stats(path: string): Promise { 62 | try { 63 | const stats = await stat(path) 64 | return stats 65 | } catch { 66 | return null 67 | } 68 | } 69 | 70 | /** 71 | * Output file with contents to the given path 72 | */ 73 | async #outputFile(path: string, contents: string) { 74 | const pathDirname = dirname(path) 75 | 76 | const dirExists = await this.#pathExists(pathDirname) 77 | if (!dirExists) { 78 | await mkdir(pathDirname, { recursive: true }) 79 | } 80 | 81 | await writeFile(path, contents, 'utf-8') 82 | } 83 | 84 | /** 85 | * Reads the session data from the disk. 86 | */ 87 | async read(sessionId: string): Promise { 88 | const filePath = this.#getFilePath(sessionId) 89 | debug('file store: reading session data %', sessionId) 90 | 91 | /** 92 | * Return null when no session id file exists in first 93 | * place 94 | */ 95 | const stats = await this.#stats(filePath) 96 | if (!stats) { 97 | return null 98 | } 99 | 100 | /** 101 | * Check if the file has been expired and return null (if expired) 102 | */ 103 | const sessionWillExpireAt = stats.mtimeMs + string.seconds.parse(this.#age) * 1000 104 | if (Date.now() > sessionWillExpireAt) { 105 | debug('file store: expired session data %s', sessionId) 106 | return null 107 | } 108 | 109 | /** 110 | * Reading the file contents if the file exists 111 | */ 112 | let contents = await readFile(filePath, 'utf-8') 113 | contents = contents.trim() 114 | if (!contents) { 115 | return null 116 | } 117 | 118 | /** 119 | * Verify contents with the session id and return them as an object. The verify 120 | * method can fail when the contents is not JSON> 121 | */ 122 | try { 123 | return new MessageBuilder().verify(contents, sessionId) 124 | } catch { 125 | return null 126 | } 127 | } 128 | 129 | /** 130 | * Writes the session data to the disk as a string 131 | */ 132 | async write(sessionId: string, values: SessionData): Promise { 133 | debug('file store: writing session data %s: %O', sessionId, values) 134 | 135 | const filePath = this.#getFilePath(sessionId) 136 | const message = new MessageBuilder().build(values, undefined, sessionId) 137 | 138 | await this.#outputFile(filePath, message) 139 | } 140 | 141 | /** 142 | * Removes the session file from the disk 143 | */ 144 | async destroy(sessionId: string): Promise { 145 | debug('file store: destroying session data %s', sessionId) 146 | await rm(this.#getFilePath(sessionId), { force: true }) 147 | } 148 | 149 | /** 150 | * Updates the session expiry by rewriting it to the 151 | * persistence store 152 | */ 153 | async touch(sessionId: string): Promise { 154 | debug('file store: touching session data %s', sessionId) 155 | await utimes(this.#getFilePath(sessionId), new Date(), new Date()) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/stores/memory.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { SessionData, SessionStoreContract } from '../types.js' 11 | 12 | /** 13 | * Memory store is meant to be used for writing tests. 14 | */ 15 | export class MemoryStore implements SessionStoreContract { 16 | static sessions: Map = new Map() 17 | 18 | /** 19 | * Read session id value from the memory 20 | */ 21 | read(sessionId: string): SessionData | null { 22 | return MemoryStore.sessions.get(sessionId) || null 23 | } 24 | 25 | /** 26 | * Save in memory value for a given session id 27 | */ 28 | write(sessionId: string, values: SessionData): void { 29 | MemoryStore.sessions.set(sessionId, values) 30 | } 31 | 32 | /** 33 | * Cleanup for a single session 34 | */ 35 | destroy(sessionId: string): void { 36 | MemoryStore.sessions.delete(sessionId) 37 | } 38 | 39 | touch(): void {} 40 | } 41 | -------------------------------------------------------------------------------- /src/stores/redis.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import string from '@poppinss/utils/string' 11 | import { MessageBuilder } from '@adonisjs/core/helpers' 12 | import type { Connection } from '@adonisjs/redis/types' 13 | 14 | import debug from '../debug.js' 15 | import type { SessionStoreContract, SessionData } from '../types.js' 16 | 17 | /** 18 | * File store to read/write session to filesystem 19 | */ 20 | export class RedisStore implements SessionStoreContract { 21 | #connection: Connection 22 | #ttlSeconds: number 23 | 24 | constructor(connection: Connection, age: string | number) { 25 | this.#connection = connection 26 | this.#ttlSeconds = string.seconds.parse(age) 27 | debug('initiating redis store') 28 | } 29 | 30 | /** 31 | * Returns file contents. A new file will be created if it's 32 | * missing. 33 | */ 34 | async read(sessionId: string): Promise { 35 | debug('redis store: reading session data %s', sessionId) 36 | 37 | const contents = await this.#connection.get(sessionId) 38 | if (!contents) { 39 | return null 40 | } 41 | 42 | /** 43 | * Verify contents with the session id and return them as an object. The verify 44 | * method can fail when the contents is not JSON> 45 | */ 46 | try { 47 | return new MessageBuilder().verify(contents, sessionId) 48 | } catch { 49 | return null 50 | } 51 | } 52 | 53 | /** 54 | * Write session values to a file 55 | */ 56 | async write(sessionId: string, values: Object): Promise { 57 | debug('redis store: writing session data %s, %O', sessionId, values) 58 | 59 | const message = new MessageBuilder().build(values, undefined, sessionId) 60 | await this.#connection.setex(sessionId, this.#ttlSeconds, message) 61 | } 62 | 63 | /** 64 | * Cleanup session file by removing it 65 | */ 66 | async destroy(sessionId: string): Promise { 67 | debug('redis store: destroying session data %s', sessionId) 68 | await this.#connection.del(sessionId) 69 | } 70 | 71 | /** 72 | * Updates the value expiry 73 | */ 74 | async touch(sessionId: string): Promise { 75 | debug('redis store: touching session data %s', sessionId) 76 | await this.#connection.expire(sessionId, this.#ttlSeconds) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { HttpContext } from '@adonisjs/core/http' 11 | import { RedisConnections } from '@adonisjs/redis/types' 12 | import type { CookieOptions } from '@adonisjs/core/types/http' 13 | import type { DynamoDBClient, DynamoDBClientConfig } from '@aws-sdk/client-dynamodb' 14 | 15 | /** 16 | * The values allowed by the `session.put` method 17 | */ 18 | export type AllowedSessionValues = string | boolean | number | object | Date | Array 19 | export type SessionData = Record 20 | 21 | /** 22 | * Session stores must implement the session store contract. 23 | */ 24 | export interface SessionStoreContract { 25 | /** 26 | * The read method is used to read the data from the persistence 27 | * store and return it back as an object 28 | */ 29 | read(sessionId: string): Promise | SessionData | null 30 | 31 | /** 32 | * The write method is used to write the session data into the 33 | * persistence store. 34 | */ 35 | write(sessionId: string, data: SessionData): Promise | void 36 | 37 | /** 38 | * The destroy method is used to destroy the session by removing 39 | * its data from the persistence store 40 | */ 41 | destroy(sessionId: string): Promise | void 42 | 43 | /** 44 | * The touch method should update the lifetime of session id without 45 | * making changes to the session data. 46 | */ 47 | touch(sessionId: string): Promise | void 48 | } 49 | 50 | /** 51 | * Base configuration for managing sessions without 52 | * stores. 53 | */ 54 | export interface SessionConfig { 55 | /** 56 | * Enable/disable sessions temporarily 57 | */ 58 | enabled: boolean 59 | 60 | /** 61 | * The name of the cookie for storing the session id. 62 | */ 63 | cookieName: string 64 | 65 | /** 66 | * When set to true, the session id cookie will be removed 67 | * when the user closes the browser. 68 | * 69 | * However, the persisted data will continue to exist until 70 | * it gets expired. 71 | */ 72 | clearWithBrowser: boolean 73 | 74 | /** 75 | * How long the session data should be kept alive without any 76 | * activity. 77 | * 78 | * The session id cookie will also live for the same duration, unless 79 | * "clearWithBrowser" is enabled 80 | * 81 | * The value should be a time expression or a number in seconds 82 | */ 83 | age: string | number 84 | 85 | /** 86 | * Configuration used by the cookie driver and for storing the 87 | * session id cookie. 88 | */ 89 | cookie: Omit, 'maxAge' | 'expires'> 90 | } 91 | 92 | /** 93 | * Configuration used by the file store. 94 | */ 95 | export type FileStoreConfig = { 96 | location: string 97 | } 98 | 99 | /** 100 | * Configuration used by the redis store. 101 | */ 102 | export type RedisStoreConfig = { 103 | connection: keyof RedisConnections 104 | } 105 | 106 | /** 107 | * Configuration used by the dynamodb store. 108 | */ 109 | export type DynamoDBStoreConfig = ( 110 | | { 111 | client: DynamoDBClient 112 | } 113 | | { 114 | clientConfig: DynamoDBClientConfig 115 | } 116 | ) & { 117 | tableName?: string 118 | keyAttribute?: string 119 | } 120 | 121 | /** 122 | * Factory function to instantiate session store 123 | */ 124 | export type SessionStoreFactory = ( 125 | ctx: HttpContext, 126 | sessionConfig: SessionConfig 127 | ) => SessionStoreContract 128 | -------------------------------------------------------------------------------- /src/values_store.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/redis 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import lodash from '@poppinss/utils/lodash' 11 | import { RuntimeException } from '@poppinss/utils' 12 | import type { AllowedSessionValues, SessionData } from './types.js' 13 | 14 | /** 15 | * Readonly session store 16 | */ 17 | export class ReadOnlyValuesStore { 18 | /** 19 | * Underlying store values 20 | */ 21 | protected values: SessionData 22 | 23 | /** 24 | * Find if store is empty or not 25 | */ 26 | get isEmpty() { 27 | return !this.values || Object.keys(this.values).length === 0 28 | } 29 | 30 | constructor(values: SessionData | null) { 31 | this.values = values || {} 32 | } 33 | 34 | /** 35 | * Get value for a given key 36 | */ 37 | get(key: string | string[], defaultValue?: any): any { 38 | const value = lodash.get(this.values, key) 39 | if (defaultValue !== undefined && (value === null || value === undefined)) { 40 | return defaultValue 41 | } 42 | 43 | return value 44 | } 45 | 46 | /** 47 | * A boolean to know if value exists. Extra guards to check 48 | * arrays for it's length as well. 49 | */ 50 | has(key: string | string[], checkForArraysLength: boolean = true): boolean { 51 | const value = this.get(key) 52 | if (!Array.isArray(value)) { 53 | return !!value 54 | } 55 | 56 | return checkForArraysLength ? value.length > 0 : !!value 57 | } 58 | 59 | /** 60 | * Get all values 61 | */ 62 | all(): any { 63 | return this.values 64 | } 65 | 66 | /** 67 | * Returns object representation of values 68 | */ 69 | toObject() { 70 | return this.all() 71 | } 72 | 73 | /** 74 | * Returns the store values 75 | */ 76 | toJSON(): any { 77 | return this.all() 78 | } 79 | 80 | /** 81 | * Returns string representation of the store 82 | */ 83 | toString() { 84 | return JSON.stringify(this.all()) 85 | } 86 | } 87 | 88 | /** 89 | * Session store encapsulates the session data and offers a 90 | * declarative API to mutate it. 91 | */ 92 | export class ValuesStore extends ReadOnlyValuesStore { 93 | /** 94 | * A boolean to know if store has been 95 | * modified 96 | */ 97 | #modified: boolean = false 98 | 99 | constructor(values: SessionData | null) { 100 | super(values) 101 | } 102 | 103 | /** 104 | * Find if the store has been modified. 105 | */ 106 | get hasBeenModified(): boolean { 107 | return this.#modified 108 | } 109 | 110 | /** 111 | * Set key/value pair 112 | */ 113 | set(key: string | string[], value: AllowedSessionValues): void { 114 | this.#modified = true 115 | lodash.set(this.values, key, value) 116 | } 117 | 118 | /** 119 | * Remove key 120 | */ 121 | unset(key: string | string[]): void { 122 | this.#modified = true 123 | lodash.unset(this.values, key) 124 | } 125 | 126 | /** 127 | * Pull value from the store. It is same as calling 128 | * store.get and then store.unset 129 | */ 130 | pull(key: string | string[], defaultValue?: any): any { 131 | return ((value): any => { 132 | this.unset(key) 133 | return value 134 | })(this.get(key, defaultValue)) 135 | } 136 | 137 | /** 138 | * Increment number. The method raises an error when 139 | * nderlying value is not a number 140 | */ 141 | increment(key: string | string[], steps: number = 1): void { 142 | const value = this.get(key, 0) 143 | if (typeof value !== 'number') { 144 | throw new RuntimeException(`Cannot increment "${key}". Existing value is not a number`) 145 | } 146 | 147 | this.set(key, value + steps) 148 | } 149 | 150 | /** 151 | * Increment number. The method raises an error when 152 | * nderlying value is not a number 153 | */ 154 | decrement(key: string | string[], steps: number = 1): void { 155 | const value = this.get(key, 0) 156 | if (typeof value !== 'number') { 157 | throw new RuntimeException(`Cannot decrement "${key}". Existing value is not a number`) 158 | } 159 | 160 | this.set(key, value - steps) 161 | } 162 | 163 | /** 164 | * Overwrite existing store data with new values. 165 | */ 166 | update(values: { [key: string]: any }): void { 167 | this.#modified = true 168 | this.values = values 169 | } 170 | 171 | /** 172 | * Update to merge values 173 | */ 174 | merge(values: { [key: string]: any }): any { 175 | this.#modified = true 176 | lodash.merge(this.values, values) 177 | } 178 | 179 | /** 180 | * Reset store by clearing it's values. 181 | */ 182 | clear(): void { 183 | this.update({}) 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /stubs/config/session.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.configPath('session.ts') }) 3 | }}} 4 | import env from '#start/env' 5 | import app from '@adonisjs/core/services/app' 6 | import { defineConfig, stores } from '@adonisjs/session' 7 | 8 | const sessionConfig = defineConfig({ 9 | enabled: true, 10 | cookieName: 'adonis-session', 11 | 12 | /** 13 | * When set to true, the session id cookie will be deleted 14 | * once the user closes the browser. 15 | */ 16 | clearWithBrowser: false, 17 | 18 | /** 19 | * Define how long to keep the session data alive without 20 | * any activity. 21 | */ 22 | age: '2h', 23 | 24 | /** 25 | * Configuration for session cookie and the 26 | * cookie store 27 | */ 28 | cookie: { 29 | path: '/', 30 | httpOnly: true, 31 | secure: app.inProduction, 32 | sameSite: 'lax', 33 | }, 34 | 35 | /** 36 | * The store to use. Make sure to validate the environment 37 | * variable in order to infer the store name without any 38 | * errors. 39 | */ 40 | store: env.get('SESSION_DRIVER'), 41 | 42 | /** 43 | * List of configured stores. Refer documentation to see 44 | * list of available stores and their config. 45 | */ 46 | stores: { 47 | cookie: stores.cookie(), 48 | } 49 | }) 50 | 51 | export default sessionConfig 52 | -------------------------------------------------------------------------------- /stubs/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { getDirname } from '@poppinss/utils' 11 | 12 | export const stubsRoot = getDirname(import.meta.url) 13 | -------------------------------------------------------------------------------- /tests/concurrent_session.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import supertest from 'supertest' 11 | import { test } from '@japa/runner' 12 | import { cuid } from '@adonisjs/core/helpers' 13 | import { defineConfig } from '@adonisjs/redis' 14 | import setCookieParser from 'set-cookie-parser' 15 | import { Emitter } from '@adonisjs/core/events' 16 | import { setTimeout } from 'node:timers/promises' 17 | import { EventsList } from '@adonisjs/core/types' 18 | import { AppFactory } from '@adonisjs/core/factories/app' 19 | import { IncomingMessage, ServerResponse } from 'node:http' 20 | import { RedisManagerFactory } from '@adonisjs/redis/factories' 21 | import { CookieClient, HttpContext } from '@adonisjs/core/http' 22 | import { EncryptionFactory } from '@adonisjs/core/factories/encryption' 23 | import { HttpContextFactory, RequestFactory, ResponseFactory } from '@adonisjs/core/factories/http' 24 | 25 | import { Session } from '../src/session.js' 26 | import { FileStore } from '../src/stores/file.js' 27 | import { RedisStore } from '../src/stores/redis.js' 28 | import { httpServer } from '../tests_helpers/index.js' 29 | import { CookieStore } from '../src/stores/cookie.js' 30 | import type { SessionConfig, SessionStoreContract } from '../src/types.js' 31 | 32 | const app = new AppFactory().create(new URL('./', import.meta.url), () => {}) 33 | const emitter = new Emitter(app) 34 | const encryption = new EncryptionFactory().create() 35 | const cookieClient = new CookieClient(encryption) 36 | const sessionConfig: SessionConfig = { 37 | enabled: true, 38 | age: '2 hours', 39 | clearWithBrowser: false, 40 | cookieName: 'adonis_session', 41 | cookie: {}, 42 | } 43 | 44 | const redisConfig = defineConfig({ 45 | connection: 'main', 46 | connections: { 47 | main: { 48 | host: process.env.REDIS_HOST || '0.0.0.0', 49 | port: process.env.REDIS_PORT || 6379, 50 | }, 51 | }, 52 | }) 53 | 54 | const redis = new RedisManagerFactory(redisConfig).create() 55 | 56 | /** 57 | * Re-usable request handler that creates different session scanerios 58 | * based upon the request URL. 59 | */ 60 | async function requestHandler( 61 | req: IncomingMessage, 62 | res: ServerResponse, 63 | driver: (ctx: HttpContext) => SessionStoreContract 64 | ) { 65 | try { 66 | const request = new RequestFactory().merge({ req, res, encryption }).create() 67 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 68 | const ctx = new HttpContextFactory().merge({ request, response }).create() 69 | 70 | const session = new Session(sessionConfig, driver, emitter, ctx) 71 | await session.initiate(false) 72 | 73 | if (req.url === '/read-data') { 74 | await session.commit() 75 | 76 | response.json(session.all()) 77 | return response.finish() 78 | } 79 | 80 | if (req.url === '/read-data-slowly') { 81 | await setTimeout(2000) 82 | await session.commit() 83 | 84 | response.json(session.all()) 85 | return response.finish() 86 | } 87 | 88 | if (req.url === '/write-data') { 89 | session.put('username', 'virk') 90 | await session.commit() 91 | 92 | response.json(session.all()) 93 | return response.finish() 94 | } 95 | 96 | if (req.url === '/write-data-slowly') { 97 | await setTimeout(2000) 98 | 99 | session.put('email', 'foo@bar.com') 100 | await session.commit() 101 | 102 | response.json(session.all()) 103 | return response.finish() 104 | } 105 | } catch (error) { 106 | res.writeHead(500) 107 | res.write(error.stack) 108 | res.end() 109 | } 110 | } 111 | 112 | test.group('Concurrency | cookie driver', () => { 113 | test('concurrently read and read slowly', async ({ assert }) => { 114 | let sessionId = cuid() 115 | 116 | const server = httpServer.create((req, res) => 117 | requestHandler(req, res, (ctx) => new CookieStore(sessionConfig.cookie, ctx)) 118 | ) 119 | 120 | const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` 121 | const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` 122 | 123 | const responses = await Promise.all([ 124 | supertest(server) 125 | .get('/read-data') 126 | .set('Cookie', `${sessionIdCookie}; ${sessionStoreCookie}`), 127 | 128 | supertest(server) 129 | .get('/read-data-slowly') 130 | .set('Cookie', `${sessionIdCookie}; ${sessionStoreCookie}`), 131 | ]) 132 | 133 | /** 134 | * Asserting store data when using cookie driver 135 | */ 136 | const cookies = setCookieParser.parse(responses[0].headers['set-cookie'], { map: true }) 137 | const cookies1 = setCookieParser.parse(responses[1].headers['set-cookie'], { map: true }) 138 | assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { 139 | age: 22, 140 | }) 141 | assert.deepEqual(cookieClient.decrypt(sessionId!, cookies1[sessionId!].value), { 142 | age: 22, 143 | }) 144 | }).timeout(6000) 145 | 146 | test('HAS RACE CONDITION: concurrently write and read slowly', async ({ assert }) => { 147 | let sessionId = cuid() 148 | 149 | const server = httpServer.create((req, res) => 150 | requestHandler(req, res, (ctx) => new CookieStore(sessionConfig.cookie, ctx)) 151 | ) 152 | 153 | const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` 154 | const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` 155 | 156 | const responses = await Promise.all([ 157 | supertest(server) 158 | .get('/read-data-slowly') 159 | .set('Cookie', `${sessionIdCookie}; ${sessionStoreCookie}`), 160 | 161 | supertest(server) 162 | .get('/write-data') 163 | .set('Cookie', `${sessionIdCookie}; ${sessionStoreCookie}`), 164 | ]) 165 | 166 | const cookies = setCookieParser.parse(responses[0].headers['set-cookie'], { map: true }) 167 | const cookies1 = setCookieParser.parse(responses[1].headers['set-cookie'], { map: true }) 168 | 169 | /** 170 | * Since this request finishes afterwards, it will overwrite the mutations 171 | * from the /write-data endpoint. THIS IS A CONCURRENCY CONCERN 172 | */ 173 | assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { 174 | age: 22, 175 | }) 176 | 177 | assert.deepEqual(cookieClient.decrypt(sessionId!, cookies1[sessionId!].value), { 178 | age: 22, 179 | username: 'virk', 180 | }) 181 | }).timeout(6000) 182 | 183 | test('HAS RACE CONDITION: concurrently write and write slowly', async ({ assert }) => { 184 | let sessionId = cuid() 185 | 186 | const server = httpServer.create((req, res) => 187 | requestHandler(req, res, (ctx) => new CookieStore(sessionConfig.cookie, ctx)) 188 | ) 189 | 190 | const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` 191 | const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` 192 | 193 | const responses = await Promise.all([ 194 | supertest(server) 195 | .get('/write-data-slowly') 196 | .set('Cookie', `${sessionIdCookie}; ${sessionStoreCookie}`), 197 | 198 | supertest(server) 199 | .get('/write-data') 200 | .set('Cookie', `${sessionIdCookie}; ${sessionStoreCookie}`), 201 | ]) 202 | 203 | const cookies = setCookieParser.parse(responses[0].headers['set-cookie'], { map: true }) 204 | const cookies1 = setCookieParser.parse(responses[1].headers['set-cookie'], { map: true }) 205 | 206 | /** 207 | * Since this request finishes afterwards, it will overwrite the mutations 208 | * from the /write-data endpoint. THIS IS A CONCURRENCY CONCERN 209 | */ 210 | assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { 211 | age: 22, 212 | email: 'foo@bar.com', 213 | }) 214 | 215 | /** 216 | * Same applies here. In short two concurrent write requests will mess up 217 | * all the time 218 | */ 219 | assert.deepEqual(cookieClient.decrypt(sessionId!, cookies1[sessionId!].value), { 220 | age: 22, 221 | username: 'virk', 222 | }) 223 | }).timeout(6000) 224 | }) 225 | 226 | test.group('Concurrency | file driver', () => { 227 | test('concurrently read and read slowly', async ({ fs, assert }) => { 228 | let sessionId = cuid() 229 | 230 | const fileDriver = new FileStore({ location: fs.basePath }, sessionConfig.age) 231 | await fileDriver.write(sessionId, { age: 22 }) 232 | 233 | const server = httpServer.create((req, res) => requestHandler(req, res, () => fileDriver)) 234 | 235 | const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` 236 | await Promise.all([ 237 | supertest(server).get('/read-data').set('Cookie', `${sessionIdCookie};`), 238 | supertest(server).get('/read-data-slowly').set('Cookie', `${sessionIdCookie};`), 239 | ]) 240 | 241 | /** 242 | * Asserting store data when using file driver 243 | */ 244 | await assert.fileEquals( 245 | `${sessionId}.txt`, 246 | JSON.stringify({ 247 | message: { age: 22 }, 248 | purpose: sessionId, 249 | }) 250 | ) 251 | }).timeout(6000) 252 | 253 | test('concurrently write and read slowly', async ({ fs, assert }) => { 254 | let sessionId = cuid() 255 | 256 | const fileDriver = new FileStore({ location: fs.basePath }, sessionConfig.age) 257 | await fileDriver.write(sessionId, { age: 22 }) 258 | 259 | const server = httpServer.create((req, res) => requestHandler(req, res, () => fileDriver)) 260 | const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` 261 | 262 | await Promise.all([ 263 | supertest(server).get('/read-data-slowly').set('Cookie', `${sessionIdCookie}`), 264 | supertest(server).get('/write-data').set('Cookie', `${sessionIdCookie}`), 265 | ]) 266 | 267 | await assert.fileEquals( 268 | `${sessionId}.txt`, 269 | JSON.stringify({ 270 | message: { age: 22, username: 'virk' }, 271 | purpose: sessionId, 272 | }) 273 | ) 274 | }).timeout(6000) 275 | 276 | test('HAS RACE CONDITON: concurrently write and write slowly', async ({ fs, assert }) => { 277 | let sessionId = cuid() 278 | 279 | const fileDriver = new FileStore({ location: fs.basePath }, sessionConfig.age) 280 | await fileDriver.write(sessionId, { age: 22 }) 281 | 282 | const server = httpServer.create((req, res) => requestHandler(req, res, () => fileDriver)) 283 | 284 | const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` 285 | 286 | await Promise.all([ 287 | supertest(server).get('/write-data-slowly').set('Cookie', `${sessionIdCookie}`), 288 | supertest(server).get('/write-data').set('Cookie', `${sessionIdCookie}`), 289 | ]) 290 | 291 | await assert.fileEquals( 292 | `${sessionId}.txt`, 293 | JSON.stringify({ 294 | message: { age: 22, email: 'foo@bar.com' }, 295 | purpose: sessionId, 296 | }) 297 | ) 298 | }).timeout(6000) 299 | }) 300 | 301 | test.group('Concurrency | redis driver', (group) => { 302 | group.tap((t) => { 303 | t.skip(!!process.env.NO_REDIS, 'Redis not available in windows env') 304 | }) 305 | 306 | test('concurrently read and read slowly', async ({ assert, cleanup }) => { 307 | let sessionId = cuid() 308 | cleanup(async () => { 309 | await redisDriver.destroy(sessionId) 310 | }) 311 | 312 | const redisDriver = new RedisStore(redis.connection('main'), sessionConfig.age) 313 | await redisDriver.write(sessionId, { age: 22 }) 314 | 315 | const server = httpServer.create((req, res) => requestHandler(req, res, () => redisDriver)) 316 | 317 | const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` 318 | await Promise.all([ 319 | supertest(server).get('/read-data').set('Cookie', `${sessionIdCookie};`), 320 | supertest(server).get('/read-data-slowly').set('Cookie', `${sessionIdCookie};`), 321 | ]) 322 | 323 | /** 324 | * Asserting store data when using file driver 325 | */ 326 | assert.deepEqual(await redisDriver.read(sessionId), { 327 | age: 22, 328 | }) 329 | }).timeout(6000) 330 | 331 | test('concurrently write and read slowly', async ({ assert, cleanup }) => { 332 | let sessionId = cuid() 333 | cleanup(async () => { 334 | await redisDriver.destroy(sessionId) 335 | }) 336 | 337 | const redisDriver = new RedisStore(redis.connection('main'), sessionConfig.age) 338 | await redisDriver.write(sessionId, { age: 22 }) 339 | 340 | const server = httpServer.create((req, res) => requestHandler(req, res, () => redisDriver)) 341 | const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` 342 | 343 | await Promise.all([ 344 | supertest(server).get('/read-data-slowly').set('Cookie', `${sessionIdCookie}`), 345 | supertest(server).get('/write-data').set('Cookie', `${sessionIdCookie}`), 346 | ]) 347 | 348 | assert.deepEqual(await redisDriver.read(sessionId), { age: 22, username: 'virk' }) 349 | }).timeout(6000) 350 | 351 | test('HAS RACE CONDITON: concurrently write and write slowly', async ({ assert, cleanup }) => { 352 | let sessionId = cuid() 353 | cleanup(async () => { 354 | await redisDriver.destroy(sessionId) 355 | }) 356 | 357 | const redisDriver = new RedisStore(redis.connection('main'), sessionConfig.age) 358 | await redisDriver.write(sessionId, { age: 22 }) 359 | 360 | const server = httpServer.create((req, res) => requestHandler(req, res, () => redisDriver)) 361 | 362 | const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` 363 | 364 | await Promise.all([ 365 | supertest(server).get('/write-data-slowly').set('Cookie', `${sessionIdCookie}`), 366 | supertest(server).get('/write-data').set('Cookie', `${sessionIdCookie}`), 367 | ]) 368 | 369 | assert.deepEqual(await redisDriver.read(sessionId), { age: 22, email: 'foo@bar.com' }) 370 | }).timeout(6000) 371 | }) 372 | -------------------------------------------------------------------------------- /tests/configure.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { fileURLToPath } from 'node:url' 12 | import { IgnitorFactory } from '@adonisjs/core/factories' 13 | import Configure from '@adonisjs/core/commands/configure' 14 | 15 | const BASE_URL = new URL('./tmp/', import.meta.url) 16 | 17 | test.group('Configure', (group) => { 18 | group.each.setup(({ context }) => { 19 | context.fs.baseUrl = BASE_URL 20 | context.fs.basePath = fileURLToPath(BASE_URL) 21 | }) 22 | 23 | test('create config file and register provider', async ({ fs, assert }) => { 24 | const ignitor = new IgnitorFactory() 25 | .withCoreProviders() 26 | .withCoreConfig() 27 | .create(BASE_URL, { 28 | importer: (filePath) => { 29 | if (filePath.startsWith('./') || filePath.startsWith('../')) { 30 | return import(new URL(filePath, BASE_URL).href) 31 | } 32 | 33 | return import(filePath) 34 | }, 35 | }) 36 | 37 | await fs.create('.env', '') 38 | await fs.createJson('tsconfig.json', {}) 39 | await fs.create('start/env.ts', `export default Env.create(new URL('./'), {})`) 40 | await fs.create('start/kernel.ts', `router.use([])`) 41 | await fs.create('adonisrc.ts', `export default defineConfig({}) {}`) 42 | 43 | const app = ignitor.createApp('web') 44 | await app.init() 45 | await app.boot() 46 | 47 | const ace = await app.container.make('ace') 48 | const command = await ace.create(Configure, ['../../index.js']) 49 | await command.exec() 50 | 51 | await assert.fileExists('config/session.ts') 52 | await assert.fileExists('adonisrc.ts') 53 | await assert.fileContains('adonisrc.ts', '@adonisjs/session/session_provider') 54 | await assert.fileContains('config/session.ts', 'defineConfig') 55 | await assert.fileContains('.env', 'SESSION_DRIVER=cookie') 56 | await assert.fileContains( 57 | 'start/env.ts', 58 | `SESSION_DRIVER: Env.schema.enum(['cookie', 'memory'] as const)` 59 | ) 60 | }).timeout(60 * 1000) 61 | }) 62 | -------------------------------------------------------------------------------- /tests/define_config.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/redis 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { fileURLToPath } from 'node:url' 12 | import { AppFactory } from '@adonisjs/core/factories/app' 13 | import { defineConfig as redisConfig } from '@adonisjs/redis' 14 | import type { ApplicationService } from '@adonisjs/core/types' 15 | import { HttpContextFactory } from '@adonisjs/core/factories/http' 16 | 17 | import { FileStore } from '../src/stores/file.js' 18 | import { RedisStore } from '../src/stores/redis.js' 19 | import { CookieStore } from '../src/stores/cookie.js' 20 | import { defineConfig, stores } from '../src/define_config.js' 21 | 22 | const BASE_URL = new URL('./', import.meta.url) 23 | const app = new AppFactory().create(BASE_URL, () => {}) as ApplicationService 24 | 25 | test.group('Define config', () => { 26 | test('throw error when store is not defined', async () => { 27 | await defineConfig({} as any).resolver(app) 28 | }).throws('Missing "store" property inside the session config') 29 | 30 | test('define maxAge when clearWithBrowser is not defined', async ({ assert }) => { 31 | const config = await defineConfig({ store: 'memory', stores: {} }).resolver(app) 32 | assert.equal(config.cookie.maxAge, 7200) 33 | }) 34 | 35 | test('define maxAge when clearWithBrowser is not enabled', async ({ assert }) => { 36 | const config = await defineConfig({ 37 | clearWithBrowser: false, 38 | store: 'memory', 39 | stores: {}, 40 | }).resolver(app) 41 | assert.equal(config.cookie.maxAge, 7200) 42 | }) 43 | 44 | test('define maxAge when clearWithBrowser is enabled', async ({ assert }) => { 45 | const config = await defineConfig({ 46 | clearWithBrowser: true, 47 | store: 'memory', 48 | stores: {}, 49 | }).resolver(app) 50 | 51 | assert.isUndefined(config.cookie.maxAge) 52 | }) 53 | 54 | test('transform config with no stores', async ({ assert }) => { 55 | const config = await defineConfig({ store: 'memory', stores: {} }).resolver(app) 56 | assert.snapshot(config).matchInline(` 57 | { 58 | "age": "2h", 59 | "clearWithBrowser": false, 60 | "cookie": { 61 | "maxAge": 7200, 62 | }, 63 | "cookieName": "adonis_session", 64 | "enabled": true, 65 | "store": "memory", 66 | "stores": { 67 | "memory": [Function], 68 | }, 69 | } 70 | `) 71 | }) 72 | 73 | test('transform config with file store', async ({ assert }) => { 74 | const config = await defineConfig({ 75 | store: 'file', 76 | stores: { 77 | file: stores.file({ location: fileURLToPath(new URL('./sessions', BASE_URL)) }), 78 | }, 79 | }).resolver(app) 80 | 81 | assert.snapshot(config).matchInline(` 82 | { 83 | "age": "2h", 84 | "clearWithBrowser": false, 85 | "cookie": { 86 | "maxAge": 7200, 87 | }, 88 | "cookieName": "adonis_session", 89 | "enabled": true, 90 | "store": "file", 91 | "stores": { 92 | "file": [Function], 93 | "memory": [Function], 94 | }, 95 | } 96 | `) 97 | 98 | const ctx = new HttpContextFactory().create() 99 | assert.instanceOf(config.stores.file(ctx, config), FileStore) 100 | }) 101 | 102 | test('transform config with redis store', async ({ assert }) => { 103 | const appForRedis = new AppFactory().create(BASE_URL, () => {}) as ApplicationService 104 | appForRedis.rcContents({ 105 | providers: [ 106 | () => import('@adonisjs/core/providers/app_provider'), 107 | () => import('@adonisjs/redis/redis_provider'), 108 | ], 109 | }) 110 | appForRedis.useConfig({ 111 | logger: { 112 | default: 'main', 113 | loggers: { 114 | main: {}, 115 | }, 116 | }, 117 | redis: redisConfig({ 118 | connection: 'main', 119 | connections: { 120 | main: {}, 121 | }, 122 | }), 123 | }) 124 | await appForRedis.init() 125 | await appForRedis.boot() 126 | 127 | const config = await defineConfig({ 128 | store: 'redis', 129 | stores: { 130 | redis: stores.redis({ 131 | connection: 'main', 132 | } as any), 133 | }, 134 | }).resolver(appForRedis) 135 | 136 | assert.snapshot(config).matchInline(` 137 | { 138 | "age": "2h", 139 | "clearWithBrowser": false, 140 | "cookie": { 141 | "maxAge": 7200, 142 | }, 143 | "cookieName": "adonis_session", 144 | "enabled": true, 145 | "store": "redis", 146 | "stores": { 147 | "memory": [Function], 148 | "redis": [Function], 149 | }, 150 | } 151 | `) 152 | 153 | const ctx = new HttpContextFactory().create() 154 | assert.instanceOf(config.stores.redis(ctx, config), RedisStore) 155 | }) 156 | 157 | test('transform config with cookie store', async ({ assert }) => { 158 | const config = await defineConfig({ 159 | store: 'cookie', 160 | stores: { 161 | cookie: stores.cookie(), 162 | }, 163 | }).resolver(app) 164 | 165 | assert.snapshot(config).matchInline(` 166 | { 167 | "age": "2h", 168 | "clearWithBrowser": false, 169 | "cookie": { 170 | "maxAge": 7200, 171 | }, 172 | "cookieName": "adonis_session", 173 | "enabled": true, 174 | "store": "cookie", 175 | "stores": { 176 | "cookie": [Function], 177 | "memory": [Function], 178 | }, 179 | } 180 | `) 181 | 182 | const ctx = new HttpContextFactory().create() 183 | assert.instanceOf(config.stores.cookie(ctx, config), CookieStore) 184 | }) 185 | }) 186 | -------------------------------------------------------------------------------- /tests/plugins/api_client.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import getPort from 'get-port' 11 | import { test } from '@japa/runner' 12 | import { Emitter } from '@adonisjs/core/events' 13 | import { AppFactory } from '@adonisjs/core/factories/app' 14 | import { ApplicationService, EventsList } from '@adonisjs/core/types' 15 | import { EncryptionFactory } from '@adonisjs/core/factories/encryption' 16 | import { HttpContextFactory, RequestFactory, ResponseFactory } from '@adonisjs/core/factories/http' 17 | 18 | import { Session } from '../../src/session.js' 19 | import { SessionConfig } from '../../src/types.js' 20 | import { defineConfig } from '../../src/define_config.js' 21 | import { MemoryStore } from '../../src/stores/memory.js' 22 | import { httpServer, runJapaTest } from '../../tests_helpers/index.js' 23 | 24 | const app = new AppFactory().create(new URL('./', import.meta.url), () => {}) as ApplicationService 25 | 26 | const emitter = new Emitter(app) 27 | const encryption = new EncryptionFactory().create() 28 | 29 | const sessionConfig: SessionConfig = { 30 | enabled: true, 31 | age: '2 hours', 32 | clearWithBrowser: false, 33 | cookieName: 'adonis_session', 34 | cookie: {}, 35 | } 36 | 37 | test.group('Api client', (group) => { 38 | group.setup(async () => { 39 | app.useConfig({ 40 | session: defineConfig({ 41 | store: 'memory', 42 | stores: {}, 43 | }), 44 | }) 45 | await app.init() 46 | await app.boot() 47 | app.container.singleton('encryption', () => encryption) 48 | }) 49 | 50 | test('set session from the client', async ({ assert }) => { 51 | const server = httpServer.create(async (req, res) => { 52 | const request = new RequestFactory().merge({ req, res, encryption }).create() 53 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 54 | const ctx = new HttpContextFactory().merge({ request, response }).create() 55 | 56 | const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) 57 | 58 | await session.initiate(false) 59 | assert.deepEqual(session.all(), { username: 'virk' }) 60 | 61 | await session.commit() 62 | response.finish() 63 | }) 64 | 65 | const port = await getPort({ port: 3333 }) 66 | const url = `http://localhost:${port}` 67 | server.listen(port) 68 | 69 | await runJapaTest(app, async ({ client }) => { 70 | await client.get(url).withSession({ username: 'virk' }) 71 | assert.lengthOf(MemoryStore.sessions, 0) 72 | }) 73 | }).timeout(4000) 74 | 75 | test('set flash messages from the client', async ({ assert }) => { 76 | const server = httpServer.create(async (req, res) => { 77 | const request = new RequestFactory().merge({ req, res, encryption }).create() 78 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 79 | const ctx = new HttpContextFactory().merge({ request, response }).create() 80 | 81 | const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) 82 | 83 | await session.initiate(false) 84 | assert.deepEqual(session.flashMessages.all(), { username: 'virk' }) 85 | 86 | await session.commit() 87 | response.finish() 88 | }) 89 | 90 | const port = await getPort({ port: 3333 }) 91 | const url = `http://localhost:${port}` 92 | server.listen(port) 93 | 94 | await runJapaTest(app, async ({ client }) => { 95 | await client.get(url).withFlashMessages({ username: 'virk' }) 96 | assert.lengthOf(MemoryStore.sessions, 0) 97 | }) 98 | }) 99 | 100 | test('read response session data', async ({ assert }) => { 101 | const server = httpServer.create(async (req, res) => { 102 | const request = new RequestFactory().merge({ req, res, encryption }).create() 103 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 104 | const ctx = new HttpContextFactory().merge({ request, response }).create() 105 | 106 | const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) 107 | 108 | await session.initiate(false) 109 | session.put('name', 'virk') 110 | 111 | await session.commit() 112 | response.finish() 113 | }) 114 | 115 | const port = await getPort({ port: 3333 }) 116 | const url = `http://localhost:${port}` 117 | server.listen(port) 118 | 119 | await runJapaTest(app, async ({ client }) => { 120 | const response = await client.get(url) 121 | assert.deepEqual(response.session(), { name: 'virk' }) 122 | assert.lengthOf(MemoryStore.sessions, 0) 123 | }) 124 | }) 125 | 126 | test('read response flashMessages', async ({ assert }) => { 127 | const server = httpServer.create(async (req, res) => { 128 | const request = new RequestFactory().merge({ req, res, encryption }).create() 129 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 130 | const ctx = new HttpContextFactory().merge({ request, response }).create() 131 | 132 | const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) 133 | 134 | await session.initiate(false) 135 | session.flash('name', 'virk') 136 | 137 | await session.commit() 138 | response.finish() 139 | }) 140 | 141 | const port = await getPort({ port: 3333 }) 142 | const url = `http://localhost:${port}` 143 | server.listen(port) 144 | 145 | await runJapaTest(app, async ({ client }) => { 146 | const response = await client.get(url) 147 | assert.deepEqual(response.flashMessages(), { name: 'virk' }) 148 | assert.lengthOf(MemoryStore.sessions, 0) 149 | }) 150 | }) 151 | 152 | test('assert session and flash messages', async ({ assert }) => { 153 | const server = httpServer.create(async (req, res) => { 154 | const request = new RequestFactory().merge({ req, res, encryption }).create() 155 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 156 | const ctx = new HttpContextFactory().merge({ request, response }).create() 157 | 158 | const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) 159 | 160 | await session.initiate(false) 161 | session.put('name', 'virk') 162 | session.flash({ 163 | succeed: false, 164 | hasErrors: true, 165 | errors: { username: ['field is required', 'field must be alpha numeric'] }, 166 | }) 167 | 168 | await session.commit() 169 | response.finish() 170 | }) 171 | 172 | const port = await getPort({ port: 3333 }) 173 | const url = `http://localhost:${port}` 174 | server.listen(port) 175 | 176 | await runJapaTest(app, async ({ client }) => { 177 | const response = await client.get(url) 178 | assert.lengthOf(MemoryStore.sessions, 0) 179 | 180 | response.assertSession('name') 181 | response.assertSession('name', 'virk') 182 | response.assertSessionMissing('age') 183 | 184 | response.assertFlashMessage('succeed') 185 | response.assertFlashMessage('hasErrors') 186 | response.assertFlashMessage('hasErrors', true) 187 | response.assertFlashMessage('succeed', false) 188 | response.assertFlashMissing('notifications') 189 | 190 | response.assertValidationError('username', 'field is required') 191 | response.assertValidationErrors('username', [ 192 | 'field is required', 193 | 'field must be alpha numeric', 194 | ]) 195 | response.assertDoesNotHaveValidationError('email') 196 | 197 | assert.throws(() => response.assertSession('name', 'foo')) 198 | assert.throws(() => response.assertSessionMissing('name')) 199 | assert.throws(() => response.assertFlashMissing('succeed')) 200 | assert.throws(() => response.assertFlashMessage('succeed', true)) 201 | assert.throws(() => response.assertDoesNotHaveValidationError('username')) 202 | assert.throws(() => response.assertValidationError('username', 'field is missing')) 203 | }) 204 | }) 205 | }) 206 | -------------------------------------------------------------------------------- /tests/plugins/browser_client.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import getPort from 'get-port' 11 | import { test } from '@japa/runner' 12 | import { Emitter } from '@adonisjs/core/events' 13 | import { AppFactory } from '@adonisjs/core/factories/app' 14 | import { ApplicationService, EventsList } from '@adonisjs/core/types' 15 | import { EncryptionFactory } from '@adonisjs/core/factories/encryption' 16 | import { HttpContextFactory, RequestFactory, ResponseFactory } from '@adonisjs/core/factories/http' 17 | 18 | import { Session } from '../../src/session.js' 19 | import { SessionConfig } from '../../src/types.js' 20 | import { MemoryStore } from '../../src/stores/memory.js' 21 | import { defineConfig } from '../../src/define_config.js' 22 | import { httpServer, runJapaTest } from '../../tests_helpers/index.js' 23 | 24 | const app = new AppFactory().create(new URL('./', import.meta.url), () => {}) as ApplicationService 25 | 26 | const emitter = new Emitter(app) 27 | const encryption = new EncryptionFactory().create() 28 | 29 | const sessionConfig: SessionConfig = { 30 | enabled: true, 31 | age: '2 hours', 32 | clearWithBrowser: false, 33 | cookieName: 'adonis_session', 34 | cookie: {}, 35 | } 36 | 37 | test.group('Browser client', (group) => { 38 | group.setup(async () => { 39 | app.useConfig({ 40 | session: defineConfig({ 41 | store: 'memory', 42 | stores: {}, 43 | }), 44 | }) 45 | await app.init() 46 | await app.boot() 47 | app.container.singleton('encryption', () => encryption) 48 | }) 49 | 50 | test('set session from the client', async ({ assert }) => { 51 | const server = httpServer.create(async (req, res) => { 52 | const request = new RequestFactory().merge({ req, res, encryption }).create() 53 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 54 | const ctx = new HttpContextFactory().merge({ request, response }).create() 55 | 56 | const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) 57 | 58 | await session.initiate(false) 59 | assert.deepEqual(session.all(), { username: 'virk' }) 60 | 61 | await session.commit() 62 | response.finish() 63 | }) 64 | 65 | const port = await getPort({ port: 3333 }) 66 | const url = `http://localhost:${port}` 67 | server.listen(port) 68 | 69 | await runJapaTest(app, async ({ visit, browserContext }) => { 70 | await browserContext.initiateSession({ domain: new URL(url).host, path: '/' }) 71 | await browserContext.setSession({ username: 'virk' }) 72 | await visit(url) 73 | 74 | assert.lengthOf(MemoryStore.sessions, 1) 75 | await browserContext.close() 76 | assert.lengthOf(MemoryStore.sessions, 0) 77 | }) 78 | }) 79 | 80 | test('set flash messages from the client', async ({ assert }) => { 81 | const server = httpServer.create(async (req, res) => { 82 | const request = new RequestFactory().merge({ req, res, encryption }).create() 83 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 84 | const ctx = new HttpContextFactory().merge({ request, response }).create() 85 | 86 | const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) 87 | 88 | await session.initiate(false) 89 | assert.deepEqual(session.flashMessages.all(), { username: 'virk' }) 90 | 91 | await session.commit() 92 | response.finish() 93 | }) 94 | 95 | const port = await getPort({ port: 3333 }) 96 | const url = `http://localhost:${port}` 97 | server.listen(port) 98 | 99 | await runJapaTest(app, async ({ browserContext, visit }) => { 100 | await browserContext.initiateSession({ domain: new URL(url).host, path: '/' }) 101 | await browserContext.setFlashMessages({ username: 'virk' }) 102 | await visit(url) 103 | 104 | /** 105 | * Since the server clears the session after 106 | * reading the flash messages, the store 107 | * should be empty post visit 108 | */ 109 | assert.lengthOf(MemoryStore.sessions, 0) 110 | await browserContext.close() 111 | assert.lengthOf(MemoryStore.sessions, 0) 112 | }) 113 | }) 114 | 115 | test('read response session data', async ({ assert }) => { 116 | const server = httpServer.create(async (req, res) => { 117 | const request = new RequestFactory().merge({ req, res, encryption }).create() 118 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 119 | const ctx = new HttpContextFactory().merge({ request, response }).create() 120 | 121 | const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) 122 | 123 | await session.initiate(false) 124 | session.put('name', 'virk') 125 | 126 | await session.commit() 127 | response.finish() 128 | }) 129 | 130 | const port = await getPort({ port: 3333 }) 131 | const url = `http://localhost:${port}` 132 | server.listen(port) 133 | 134 | await runJapaTest(app, async ({ browserContext, visit }) => { 135 | await browserContext.initiateSession({ domain: new URL(url).host, path: '/' }) 136 | await browserContext.setFlashMessages({ username: 'virk' }) 137 | await visit(url) 138 | 139 | assert.deepEqual(await browserContext.getSession(), { name: 'virk' }) 140 | 141 | assert.lengthOf(MemoryStore.sessions, 1) 142 | await browserContext.close() 143 | assert.lengthOf(MemoryStore.sessions, 0) 144 | }) 145 | }) 146 | 147 | test('read response flashMessages', async ({ assert }) => { 148 | const server = httpServer.create(async (req, res) => { 149 | const request = new RequestFactory().merge({ req, res, encryption }).create() 150 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 151 | const ctx = new HttpContextFactory().merge({ request, response }).create() 152 | 153 | const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) 154 | 155 | await session.initiate(false) 156 | session.flash('name', 'virk') 157 | 158 | await session.commit() 159 | response.finish() 160 | }) 161 | 162 | const port = await getPort({ port: 3333 }) 163 | const url = `http://localhost:${port}` 164 | server.listen(port) 165 | 166 | await runJapaTest(app, async ({ browserContext, visit }) => { 167 | await browserContext.initiateSession({ domain: new URL(url).host, path: '/' }) 168 | await browserContext.setFlashMessages({ username: 'virk' }) 169 | await visit(url) 170 | 171 | assert.deepEqual(await browserContext.getFlashMessages(), { name: 'virk' }) 172 | 173 | assert.lengthOf(MemoryStore.sessions, 1) 174 | await browserContext.close() 175 | assert.lengthOf(MemoryStore.sessions, 0) 176 | }) 177 | }) 178 | }) 179 | -------------------------------------------------------------------------------- /tests/session_client.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { SessionClient } from '../src/client.js' 12 | import { MemoryStore } from '../src/stores/memory.js' 13 | 14 | test.group('Session Client', (group) => { 15 | group.each.teardown(async () => { 16 | MemoryStore.sessions.clear() 17 | }) 18 | 19 | test('define session data using session id', async ({ assert }) => { 20 | const driver = new MemoryStore() 21 | const client = new SessionClient(driver) 22 | 23 | client.merge({ foo: 'bar' }) 24 | client.flash({ success: true }) 25 | await client.commit() 26 | 27 | assert.deepEqual(driver.read(client.sessionId), { 28 | foo: 'bar', 29 | __flash__: { 30 | success: true, 31 | }, 32 | }) 33 | }) 34 | 35 | test('load data from the store', async ({ assert }) => { 36 | const driver = new MemoryStore() 37 | const client = new SessionClient(driver) 38 | 39 | client.merge({ foo: 'bar' }) 40 | client.flash({ success: true }) 41 | await client.commit() 42 | 43 | assert.deepEqual(await client.load(), { 44 | values: { 45 | foo: 'bar', 46 | }, 47 | flashMessages: { 48 | success: true, 49 | }, 50 | }) 51 | }) 52 | 53 | test('destroy session', async ({ assert }) => { 54 | const driver = new MemoryStore() 55 | const client = new SessionClient(driver) 56 | 57 | client.merge({ foo: 'bar' }) 58 | client.flash({ success: true }) 59 | await client.commit() 60 | 61 | assert.deepEqual(await client.load(), { 62 | values: { 63 | foo: 'bar', 64 | }, 65 | flashMessages: { 66 | success: true, 67 | }, 68 | }) 69 | 70 | await client.destroy() 71 | 72 | assert.deepEqual(await client.load(), { 73 | values: {}, 74 | flashMessages: {}, 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /tests/session_middleware.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import supertest from 'supertest' 11 | import { test } from '@japa/runner' 12 | import setCookieParser from 'set-cookie-parser' 13 | import { CookieClient } from '@adonisjs/core/http' 14 | import { EncryptionFactory } from '@adonisjs/core/factories/encryption' 15 | import { HttpContextFactory, RequestFactory, ResponseFactory } from '@adonisjs/core/factories/http' 16 | 17 | import { httpServer } from '../tests_helpers/index.js' 18 | import { CookieStore } from '../src/stores/cookie.js' 19 | import type { SessionConfig } from '../src/types.js' 20 | import { SessionMiddlewareFactory } from '../factories/session_middleware_factory.js' 21 | 22 | const encryption = new EncryptionFactory().create() 23 | const cookieClient = new CookieClient(encryption) 24 | const sessionConfig: SessionConfig = { 25 | enabled: true, 26 | age: '2 hours', 27 | clearWithBrowser: false, 28 | cookieName: 'adonis_session', 29 | cookie: {}, 30 | } 31 | 32 | test.group('Session middleware', () => { 33 | test('initiate and commit session around request', async ({ assert }) => { 34 | let sessionId: string | undefined 35 | 36 | const server = httpServer.create(async (req, res) => { 37 | const request = new RequestFactory().merge({ req, res, encryption }).create() 38 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 39 | const ctx = new HttpContextFactory().merge({ request, response }).create() 40 | 41 | const middleware = await new SessionMiddlewareFactory() 42 | .merge({ 43 | config: Object.assign( 44 | { 45 | store: 'cookie', 46 | stores: { 47 | cookie: () => new CookieStore(sessionConfig.cookie, ctx), 48 | }, 49 | }, 50 | sessionConfig 51 | ), 52 | }) 53 | .create() 54 | 55 | await middleware.handle(ctx, () => { 56 | sessionId = ctx.session.sessionId 57 | ctx.session.put('username', 'virk') 58 | ctx.session.flash({ status: 'Completed' }) 59 | }) 60 | 61 | ctx.response.finish() 62 | }) 63 | 64 | const { headers } = await supertest(server).get('/') 65 | 66 | const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) 67 | assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { 68 | username: 'virk', 69 | __flash__: { 70 | status: 'Completed', 71 | }, 72 | }) 73 | }) 74 | 75 | test('do not initiate session when not enabled', async ({ assert }) => { 76 | const server = httpServer.create(async (req, res) => { 77 | const request = new RequestFactory().merge({ req, res, encryption }).create() 78 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 79 | const ctx = new HttpContextFactory().merge({ request, response }).create() 80 | 81 | const middleware = await new SessionMiddlewareFactory() 82 | .merge({ 83 | config: Object.assign( 84 | { 85 | store: 'cookie', 86 | stores: { 87 | cookie: () => new CookieStore(sessionConfig.cookie, ctx), 88 | }, 89 | }, 90 | sessionConfig, 91 | { 92 | enabled: false, 93 | } 94 | ), 95 | }) 96 | .create() 97 | 98 | await middleware.handle(ctx, () => {}) 99 | assert.isUndefined(ctx.session) 100 | ctx.response.finish() 101 | }) 102 | 103 | const { headers } = await supertest(server).get('/') 104 | 105 | const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) 106 | assert.deepEqual(cookies, {}) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /tests/session_provider.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/redis 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { IgnitorFactory } from '@adonisjs/core/factories' 12 | 13 | import { defineConfig } from '../index.js' 14 | import SessionMiddleware from '../src/session_middleware.js' 15 | 16 | const BASE_URL = new URL('./tmp/', import.meta.url) 17 | 18 | test.group('Session Provider', () => { 19 | test('register session provider', async ({ assert }) => { 20 | const ignitor = new IgnitorFactory() 21 | .merge({ 22 | rcFileContents: { 23 | providers: [() => import('../providers/session_provider.js')], 24 | }, 25 | }) 26 | .withCoreConfig() 27 | .withCoreProviders() 28 | .merge({ 29 | config: { 30 | session: defineConfig({ 31 | store: 'memory', 32 | stores: {}, 33 | }), 34 | }, 35 | }) 36 | .create(BASE_URL) 37 | 38 | const app = ignitor.createApp('web') 39 | await app.init() 40 | await app.boot() 41 | 42 | assert.instanceOf(await app.container.make(SessionMiddleware), SessionMiddleware) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /tests/stores/cookie_store.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import supertest from 'supertest' 11 | import { test } from '@japa/runner' 12 | import setCookieParser from 'set-cookie-parser' 13 | import { CookieClient } from '@adonisjs/core/http' 14 | import type { CookieOptions } from '@adonisjs/core/types/http' 15 | import { EncryptionFactory } from '@adonisjs/core/factories/encryption' 16 | import { HttpContextFactory, RequestFactory, ResponseFactory } from '@adonisjs/core/factories/http' 17 | 18 | import { httpServer } from '../../tests_helpers/index.js' 19 | import { CookieStore } from '../../src/stores/cookie.js' 20 | 21 | const encryption = new EncryptionFactory().create() 22 | const cookieClient = new CookieClient(encryption) 23 | const cookieConfig: Partial = { 24 | sameSite: 'strict', 25 | maxAge: '5mins', 26 | } 27 | 28 | test.group('Cookie store', () => { 29 | test('return null when session data cookie does not exists', async ({ assert }) => { 30 | const sessionId = '1234' 31 | 32 | const server = httpServer.create(async (req, res) => { 33 | const request = new RequestFactory().merge({ req, res, encryption }).create() 34 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 35 | const ctx = new HttpContextFactory().merge({ request, response }).create() 36 | 37 | const session = new CookieStore(cookieConfig, ctx) 38 | const value = session.read(sessionId) 39 | response.json(value) 40 | response.finish() 41 | }) 42 | 43 | const { body, text } = await supertest(server).get('/') 44 | assert.deepEqual(body, {}) 45 | assert.equal(text, '') 46 | }) 47 | 48 | test('return session data from the cookie', async ({ assert }) => { 49 | const sessionId = '1234' 50 | 51 | const server = httpServer.create(async (req, res) => { 52 | const request = new RequestFactory().merge({ req, res, encryption }).create() 53 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 54 | const ctx = new HttpContextFactory().merge({ request, response }).create() 55 | 56 | const session = new CookieStore(cookieConfig, ctx) 57 | const value = session.read(sessionId) 58 | response.json(value) 59 | response.finish() 60 | }) 61 | 62 | const { body } = await supertest(server) 63 | .get('/') 64 | .set('cookie', `${sessionId}=${cookieClient.encrypt(sessionId, { visits: 1 })}`) 65 | 66 | assert.deepEqual(body, { visits: 1 }) 67 | }) 68 | 69 | test('persist session data inside a cookie', async ({ assert }) => { 70 | const sessionId = '1234' 71 | 72 | const server = httpServer.create(async (req, res) => { 73 | const request = new RequestFactory().merge({ req, res, encryption }).create() 74 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 75 | const ctx = new HttpContextFactory().merge({ request, response }).create() 76 | 77 | const session = new CookieStore(cookieConfig, ctx) 78 | session.write(sessionId, { visits: 0 }) 79 | response.finish() 80 | }) 81 | 82 | const { headers } = await supertest(server).get('/') 83 | const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) 84 | assert.deepEqual(cookieClient.decrypt(sessionId, cookies[sessionId].value), { 85 | visits: 0, 86 | }) 87 | }) 88 | 89 | test('touch cookie by re-updating its attributes', async ({ assert }) => { 90 | const sessionId = '1234' 91 | 92 | const server = httpServer.create(async (req, res) => { 93 | const request = new RequestFactory().merge({ req, res, encryption }).create() 94 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 95 | const ctx = new HttpContextFactory().merge({ request, response }).create() 96 | 97 | const session = new CookieStore(cookieConfig, ctx) 98 | session.touch(sessionId) 99 | response.finish() 100 | }) 101 | 102 | const { headers } = await supertest(server) 103 | .get('/') 104 | .set('Cookie', `${sessionId}=${cookieClient.encrypt(sessionId, { visits: 1 })}`) 105 | 106 | const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) 107 | assert.deepEqual(cookieClient.decrypt(sessionId, cookies[sessionId].value), { 108 | visits: 1, 109 | }) 110 | }) 111 | 112 | test('do not write cookie to response unless touch or write methods are called', async ({ 113 | assert, 114 | }) => { 115 | const sessionId = '1234' 116 | 117 | const server = httpServer.create(async (req, res) => { 118 | const request = new RequestFactory().merge({ req, res, encryption }).create() 119 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 120 | const ctx = new HttpContextFactory().merge({ request, response }).create() 121 | 122 | const session = new CookieStore(cookieConfig, ctx) 123 | response.json(session.read(sessionId)) 124 | response.finish() 125 | }) 126 | 127 | const { headers, body } = await supertest(server) 128 | .get('/') 129 | .set('Cookie', `${sessionId}=${cookieClient.encrypt(sessionId, { visits: 1 })}`) 130 | 131 | const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) 132 | assert.deepEqual(cookies, {}) 133 | assert.deepEqual(body, { visits: 1 }) 134 | }) 135 | 136 | test('delete session data cookie', async ({ assert }) => { 137 | const sessionId = '1234' 138 | 139 | const server = httpServer.create(async (req, res) => { 140 | const request = new RequestFactory().merge({ req, res, encryption }).create() 141 | const response = new ResponseFactory().merge({ req, res, encryption }).create() 142 | const ctx = new HttpContextFactory().merge({ request, response }).create() 143 | 144 | const session = new CookieStore(cookieConfig, ctx) 145 | response.json(session.read(sessionId)) 146 | session.destroy(sessionId) 147 | response.finish() 148 | }) 149 | 150 | const { headers, body } = await supertest(server) 151 | .get('/') 152 | .set('Cookie', `${sessionId}=${cookieClient.encrypt(sessionId, { visits: 1 })}`) 153 | 154 | const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) 155 | assert.equal(cookies[sessionId].maxAge, -1) 156 | assert.equal(cookies[sessionId].expires, new Date('1970-01-01').toString()) 157 | assert.deepEqual(body, { visits: 1 }) 158 | }) 159 | }) 160 | -------------------------------------------------------------------------------- /tests/stores/dynamodb_store.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { setTimeout } from 'node:timers/promises' 12 | import { marshall } from '@aws-sdk/util-dynamodb' 13 | import { PutItemCommand, DeleteItemCommand } from '@aws-sdk/client-dynamodb' 14 | 15 | import { DynamoDBStore } from '../../src/stores/dynamodb.js' 16 | import { dynamodbClient, getExpiry, getSession } from '../../tests_helpers/index.js' 17 | 18 | const sessionId = '1234' 19 | const defaultTableName = 'Session' 20 | const defaultKeyName = 'key' 21 | const customKeyAttribute = 'sessionId' 22 | const customTableName = 'CustomKeySession' 23 | const client = dynamodbClient.create() 24 | 25 | test.group('DynamoDB store', (group) => { 26 | group.tap((t) => { 27 | t.skip(!!process.env.NO_DYNAMODB, 'DynamoDB not available in this environment') 28 | }) 29 | 30 | group.each.setup(() => { 31 | return async () => { 32 | await client.send( 33 | new DeleteItemCommand({ 34 | TableName: defaultTableName, 35 | Key: marshall({ [defaultKeyName]: sessionId }), 36 | }) 37 | ) 38 | } 39 | }) 40 | 41 | test('return null when value is missing', async ({ assert }) => { 42 | const session = new DynamoDBStore(client, '2 hours') 43 | 44 | const value = await session.read(sessionId) 45 | assert.isNull(value) 46 | }) 47 | 48 | test('get session existing value', async ({ assert }) => { 49 | const session = new DynamoDBStore(client, '2 hours') 50 | await session.write(sessionId, { message: 'hello-world' }) 51 | 52 | const value = await session.read(sessionId) 53 | assert.deepEqual(value, { message: 'hello-world' }) 54 | }) 55 | 56 | test('return null when session data is expired', async ({ assert }) => { 57 | const session = new DynamoDBStore(client, 1) 58 | await session.write(sessionId, { message: 'hello-world' }) 59 | 60 | await setTimeout(2000) 61 | 62 | const value = await session.read(sessionId) 63 | assert.isNull(value) 64 | }).timeout(3000) 65 | 66 | test('ignore malformed contents', async ({ assert }) => { 67 | const session = new DynamoDBStore(client, '2 hours') 68 | 69 | await client.send( 70 | new PutItemCommand({ 71 | TableName: defaultTableName, 72 | Item: { [defaultKeyName]: marshall(sessionId), value: marshall('foo') }, 73 | }) 74 | ) 75 | 76 | const value = await session.read(sessionId) 77 | assert.isNull(value) 78 | }) 79 | 80 | test('ignore items with missing value attribute', async ({ assert }) => { 81 | const session = new DynamoDBStore(client, '2 hours') 82 | 83 | await client.send( 84 | new PutItemCommand({ 85 | TableName: defaultTableName, 86 | Item: { [defaultKeyName]: marshall(sessionId) }, 87 | }) 88 | ) 89 | 90 | const value = await session.read(sessionId) 91 | assert.isNull(value) 92 | }) 93 | 94 | test('delete key on destroy', async ({ assert }) => { 95 | const session = new DynamoDBStore(client, '2 hours') 96 | 97 | await session.write(sessionId, { message: 'hello-world' }) 98 | await session.destroy(sessionId) 99 | 100 | const storedValue = await getSession(client, defaultTableName, defaultKeyName, sessionId) 101 | assert.isNull(storedValue) 102 | }) 103 | 104 | test('update session expiry on touch', async ({ assert }) => { 105 | const session = new DynamoDBStore(client, 10) 106 | 107 | await session.write(sessionId, { message: 'hello-world' }) 108 | const expiry = await getExpiry(client, defaultTableName, defaultKeyName, sessionId) 109 | 110 | /** 111 | * Waiting a bit 112 | */ 113 | await setTimeout(2000) 114 | 115 | /** 116 | * Update the expiry 117 | */ 118 | await session.touch(sessionId) 119 | 120 | /** 121 | * Ensuring the new expiry time is greater than the old expiry time 122 | */ 123 | const expiryPostTouch = await getExpiry(client, defaultTableName, defaultKeyName, sessionId) 124 | assert.isAbove(expiryPostTouch, expiry) 125 | }).timeout(3000) 126 | }) 127 | 128 | test.group('DynamoDB store | Custom table name', (group) => { 129 | group.tap((t) => { 130 | t.skip(!!process.env.NO_DYNAMODB, 'DynamoDB not available in this environment') 131 | }) 132 | 133 | group.each.setup(() => { 134 | return async () => { 135 | await client.send( 136 | new DeleteItemCommand({ 137 | TableName: customTableName, 138 | Key: marshall({ [customKeyAttribute]: sessionId }), 139 | }) 140 | ) 141 | } 142 | }) 143 | 144 | test('return null when value is missing', async ({ assert }) => { 145 | const session = new DynamoDBStore(client, '2 hours', { 146 | tableName: customTableName, 147 | keyAttribute: customKeyAttribute, 148 | }) 149 | 150 | const value = await session.read(sessionId) 151 | assert.isNull(value) 152 | }) 153 | 154 | test('get session existing value', async ({ assert }) => { 155 | const session = new DynamoDBStore(client, '2 hours', { 156 | tableName: customTableName, 157 | keyAttribute: customKeyAttribute, 158 | }) 159 | 160 | await session.write(sessionId, { message: 'hello-world' }) 161 | 162 | const value = await session.read(sessionId) 163 | assert.deepEqual(value, { message: 'hello-world' }) 164 | }) 165 | 166 | test('return null when session data is expired', async ({ assert }) => { 167 | const session = new DynamoDBStore(client, 1, { 168 | tableName: customTableName, 169 | keyAttribute: customKeyAttribute, 170 | }) 171 | 172 | await session.write(sessionId, { message: 'hello-world' }) 173 | 174 | await setTimeout(2000) 175 | 176 | const value = await session.read(sessionId) 177 | assert.isNull(value) 178 | }).timeout(3000) 179 | 180 | test('ignore malformed contents', async ({ assert }) => { 181 | const session = new DynamoDBStore(client, '2 hours', { 182 | tableName: customTableName, 183 | keyAttribute: customKeyAttribute, 184 | }) 185 | 186 | await client.send( 187 | new PutItemCommand({ 188 | TableName: customTableName, 189 | Item: { [customKeyAttribute]: marshall(sessionId), value: marshall('foo') }, 190 | }) 191 | ) 192 | 193 | const value = await session.read(sessionId) 194 | assert.isNull(value) 195 | }) 196 | 197 | test('ignore items with missing value attribute', async ({ assert }) => { 198 | const session = new DynamoDBStore(client, '2 hours', { 199 | tableName: customTableName, 200 | keyAttribute: customKeyAttribute, 201 | }) 202 | 203 | await client.send( 204 | new PutItemCommand({ 205 | TableName: customTableName, 206 | Item: { [customKeyAttribute]: marshall(sessionId) }, 207 | }) 208 | ) 209 | 210 | const value = await session.read(sessionId) 211 | assert.isNull(value) 212 | }) 213 | 214 | test('delete key on destroy', async ({ assert }) => { 215 | const session = new DynamoDBStore(client, '2 hours', { 216 | tableName: customTableName, 217 | keyAttribute: customKeyAttribute, 218 | }) 219 | 220 | await session.write(sessionId, { message: 'hello-world' }) 221 | await session.destroy(sessionId) 222 | 223 | const storedValue = await getSession(client, customTableName, customKeyAttribute, sessionId) 224 | assert.isNull(storedValue) 225 | }) 226 | 227 | test('update session expiry on touch', async ({ assert }) => { 228 | const session = new DynamoDBStore(client, '2 hours', { 229 | tableName: customTableName, 230 | keyAttribute: customKeyAttribute, 231 | }) 232 | 233 | await session.write(sessionId, { message: 'hello-world' }) 234 | const expiry = await getExpiry(client, customTableName, customKeyAttribute, sessionId) 235 | 236 | /** 237 | * Waiting a bit 238 | */ 239 | await setTimeout(2000) 240 | 241 | /** 242 | * Update the expiry 243 | */ 244 | await session.touch(sessionId) 245 | 246 | /** 247 | * Ensuring the new expiry time is greater than the old expiry time 248 | */ 249 | const expiryPostTouch = await getExpiry(client, customTableName, customKeyAttribute, sessionId) 250 | assert.isAbove(expiryPostTouch, expiry) 251 | }).timeout(3000) 252 | }) 253 | -------------------------------------------------------------------------------- /tests/stores/file_store.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'node:path' 11 | import { test } from '@japa/runner' 12 | import { stat } from 'node:fs/promises' 13 | import { setTimeout } from 'node:timers/promises' 14 | 15 | import { FileStore } from '../../src/stores/file.js' 16 | 17 | test.group('File store', () => { 18 | test('do not create file for a new session', async ({ fs, assert }) => { 19 | const sessionId = '1234' 20 | const session = new FileStore({ location: fs.basePath }, '2 hours') 21 | 22 | const value = await session.read(sessionId) 23 | assert.isNull(value) 24 | 25 | await assert.fileNotExists('1234.txt') 26 | }) 27 | 28 | test('create intermediate directories when missing', async ({ fs, assert }) => { 29 | const sessionId = '1234' 30 | const session = new FileStore( 31 | { 32 | location: join(fs.basePath, 'app/sessions'), 33 | }, 34 | '2 hours' 35 | ) 36 | 37 | await session.write(sessionId, { message: 'hello-world' }) 38 | 39 | await assert.fileExists('app/sessions/1234.txt') 40 | await assert.fileEquals( 41 | 'app/sessions/1234.txt', 42 | JSON.stringify({ message: { message: 'hello-world' }, purpose: '1234' }) 43 | ) 44 | }) 45 | 46 | test('update existing session', async ({ fs, assert }) => { 47 | const sessionId = '1234' 48 | const session = new FileStore( 49 | { 50 | location: fs.basePath, 51 | }, 52 | '2 hours' 53 | ) 54 | 55 | await session.write(sessionId, { message: 'hello-world' }) 56 | await assert.fileEquals( 57 | '1234.txt', 58 | JSON.stringify({ message: { message: 'hello-world' }, purpose: '1234' }) 59 | ) 60 | 61 | await session.write(sessionId, { message: 'hi-world' }) 62 | await assert.fileEquals( 63 | '1234.txt', 64 | JSON.stringify({ message: { message: 'hi-world' }, purpose: '1234' }) 65 | ) 66 | }) 67 | 68 | test('get session existing value', async ({ assert, fs }) => { 69 | const sessionId = '1234' 70 | const session = new FileStore({ location: fs.basePath }, '2 hours') 71 | await session.write(sessionId, { message: 'hello-world' }) 72 | 73 | const value = await session.read(sessionId) 74 | assert.deepEqual(value, { message: 'hello-world' }) 75 | }) 76 | 77 | test('return null when session data is expired', async ({ assert, fs }) => { 78 | const sessionId = '1234' 79 | const session = new FileStore({ location: fs.basePath }, 1) 80 | await session.write(sessionId, { message: 'hello-world' }) 81 | 82 | await setTimeout(2000) 83 | 84 | const value = await session.read(sessionId) 85 | assert.isNull(value) 86 | }).disableTimeout() 87 | 88 | test('ignore malformed file contents', async ({ fs, assert }) => { 89 | const sessionId = '1234' 90 | const session = new FileStore({ location: fs.basePath }, '2 hours') 91 | 92 | await fs.create('1234.txt', '') 93 | assert.isNull(await session.read(sessionId)) 94 | 95 | await fs.create('1234.txt', 'foo') 96 | assert.isNull(await session.read(sessionId)) 97 | 98 | await fs.create('1234.txt', JSON.stringify({ foo: 'bar' })) 99 | assert.isNull(await session.read(sessionId)) 100 | }) 101 | 102 | test('remove file on destroy', async ({ assert, fs }) => { 103 | const sessionId = '1234' 104 | 105 | const session = new FileStore({ location: fs.basePath }, '2 hours') 106 | await session.write(sessionId, { message: 'hello-world' }) 107 | await session.destroy(sessionId) 108 | 109 | await assert.fileNotExists('1234.txt') 110 | }) 111 | 112 | test('do not fail when destroying a non-existing session', async ({ assert, fs }) => { 113 | const sessionId = '1234' 114 | 115 | await assert.fileNotExists('1234.txt') 116 | 117 | const session = new FileStore({ location: fs.basePath }, '2 hours') 118 | await session.destroy(sessionId) 119 | 120 | await assert.fileNotExists('1234.txt') 121 | }) 122 | 123 | test('update session expiry on touch', async ({ assert, fs }) => { 124 | const sessionId = '1234' 125 | 126 | const session = new FileStore({ location: fs.basePath }, '2 hours') 127 | await session.write(sessionId, { message: 'hello-world' }) 128 | 129 | /** 130 | * Waiting a bit 131 | */ 132 | await setTimeout(2000) 133 | 134 | /** 135 | * Making sure the original mTime of the file was smaller 136 | * than the current time after wait 137 | */ 138 | const { mtimeMs } = await stat(join(fs.basePath, '1234.txt')) 139 | assert.isBelow(mtimeMs, Date.now()) 140 | 141 | await session.touch(sessionId) 142 | 143 | /** 144 | * Ensuring the new mTime is greater than the old mTime 145 | */ 146 | let { mtimeMs: newMtimeMs } = await stat(join(fs.basePath, '1234.txt')) 147 | assert.isAbove(newMtimeMs, mtimeMs) 148 | 149 | await assert.fileEquals( 150 | '1234.txt', 151 | JSON.stringify({ message: { message: 'hello-world' }, purpose: '1234' }) 152 | ) 153 | }).disableTimeout() 154 | }) 155 | -------------------------------------------------------------------------------- /tests/stores/memory_store.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { MemoryStore } from '../../src/stores/memory.js' 12 | 13 | test.group('Memory store', (group) => { 14 | group.each.setup(() => { 15 | return () => MemoryStore.sessions.clear() 16 | }) 17 | 18 | test('return null when session does not exists', async ({ assert }) => { 19 | const sessionId = '1234' 20 | const session = new MemoryStore() 21 | 22 | assert.isNull(session.read(sessionId)) 23 | }) 24 | 25 | test('write to session store', async ({ assert }) => { 26 | const sessionId = '1234' 27 | const session = new MemoryStore() 28 | session.write(sessionId, { message: 'hello-world' }) 29 | 30 | assert.isTrue(MemoryStore.sessions.has(sessionId)) 31 | assert.deepEqual(MemoryStore.sessions.get(sessionId), { message: 'hello-world' }) 32 | }) 33 | 34 | test('update existing session', async ({ assert }) => { 35 | const sessionId = '1234' 36 | const session = new MemoryStore() 37 | 38 | session.write(sessionId, { message: 'hello-world' }) 39 | assert.isTrue(MemoryStore.sessions.has(sessionId)) 40 | assert.deepEqual(MemoryStore.sessions.get(sessionId), { message: 'hello-world' }) 41 | 42 | session.write(sessionId, { foo: 'bar' }) 43 | assert.isTrue(MemoryStore.sessions.has(sessionId)) 44 | assert.deepEqual(MemoryStore.sessions.get(sessionId), { foo: 'bar' }) 45 | }) 46 | 47 | test('get session existing value', async ({ assert }) => { 48 | const sessionId = '1234' 49 | const session = new MemoryStore() 50 | 51 | session.write(sessionId, { message: 'hello-world' }) 52 | assert.isTrue(MemoryStore.sessions.has(sessionId)) 53 | assert.deepEqual(session.read(sessionId), { message: 'hello-world' }) 54 | }) 55 | 56 | test('remove session on destroy', async ({ assert }) => { 57 | const sessionId = '1234' 58 | const session = new MemoryStore() 59 | 60 | session.write(sessionId, { message: 'hello-world' }) 61 | session.destroy(sessionId) 62 | 63 | assert.isFalse(MemoryStore.sessions.has(sessionId)) 64 | }) 65 | 66 | test('noop on touch', async ({ assert }) => { 67 | const sessionId = '1234' 68 | const session = new MemoryStore() 69 | 70 | session.write(sessionId, { message: 'hello-world' }) 71 | session.touch() 72 | 73 | assert.deepEqual(MemoryStore.sessions.get(sessionId), { message: 'hello-world' }) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /tests/stores/redis_store.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { defineConfig } from '@adonisjs/redis' 12 | import { setTimeout } from 'node:timers/promises' 13 | import { RedisManagerFactory } from '@adonisjs/redis/factories' 14 | 15 | import { RedisStore } from '../../src/stores/redis.js' 16 | 17 | const sessionId = '1234' 18 | const redisConfig = defineConfig({ 19 | connection: 'main', 20 | connections: { 21 | main: { 22 | host: process.env.REDIS_HOST || '0.0.0.0', 23 | port: process.env.REDIS_PORT || 6379, 24 | }, 25 | }, 26 | }) 27 | const redis = new RedisManagerFactory(redisConfig).create() 28 | 29 | test.group('Redis store', (group) => { 30 | group.tap((t) => { 31 | t.skip(!!process.env.NO_REDIS, 'Redis not available in windows env') 32 | }) 33 | 34 | group.each.setup(() => { 35 | return async () => { 36 | await redis.del(sessionId) 37 | } 38 | }) 39 | 40 | group.teardown(async () => { 41 | await redis.disconnectAll() 42 | }) 43 | 44 | test('return null when value is missing', async ({ assert }) => { 45 | const session = new RedisStore(redis.connection('main'), '2 hours') 46 | const value = await session.read(sessionId) 47 | assert.isNull(value) 48 | }) 49 | 50 | test('save session data in a set', async ({ assert }) => { 51 | const session = new RedisStore(redis.connection('main'), '2 hours') 52 | await session.write(sessionId, { message: 'hello-world' }) 53 | 54 | assert.equal( 55 | await redis.get(sessionId), 56 | JSON.stringify({ 57 | message: { message: 'hello-world' }, 58 | purpose: sessionId, 59 | }) 60 | ) 61 | }) 62 | 63 | test('return null when session data is expired', async ({ assert }) => { 64 | const session = new RedisStore(redis.connection('main'), 1) 65 | await session.write(sessionId, { message: 'hello-world' }) 66 | 67 | await setTimeout(2000) 68 | 69 | const value = await session.read(sessionId) 70 | assert.isNull(value) 71 | }).disableTimeout() 72 | 73 | test('ignore malformed contents', async ({ assert }) => { 74 | const session = new RedisStore(redis.connection('main'), 1) 75 | await redis.set(sessionId, 'foo') 76 | 77 | const value = await session.read(sessionId) 78 | assert.isNull(value) 79 | }) 80 | 81 | test('delete key on destroy', async ({ assert }) => { 82 | const session = new RedisStore(redis.connection('main'), '2 hours') 83 | 84 | await session.write(sessionId, { message: 'hello-world' }) 85 | await session.destroy(sessionId) 86 | 87 | assert.isNull(await redis.get(sessionId)) 88 | }) 89 | 90 | test('update session expiry on touch', async ({ assert }) => { 91 | const session = new RedisStore(redis.connection('main'), 10) 92 | await session.write(sessionId, { message: 'hello-world' }) 93 | 94 | /** 95 | * Waiting a bit 96 | */ 97 | await setTimeout(2000) 98 | 99 | /** 100 | * After waiting for a couple of seconds, the ttl should be 101 | * under 9 already 102 | */ 103 | const expiry = await redis.ttl(sessionId) 104 | assert.isBelow(expiry, 9) 105 | 106 | await session.touch(sessionId) 107 | 108 | /** 109 | * Ensuring the new mTime is greater than the old mTime 110 | */ 111 | const expiryPostTouch = await redis.ttl(sessionId) 112 | assert.isAtLeast(expiryPostTouch, 9) 113 | }).disableTimeout() 114 | }) 115 | -------------------------------------------------------------------------------- /tests/values_store.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { ValuesStore } from '../src/values_store.js' 12 | 13 | test.group('Store', () => { 14 | test('return empty object for empty store', ({ assert }) => { 15 | const store = new ValuesStore(null) 16 | assert.deepEqual(store.toJSON(), {}) 17 | assert.isTrue(store.isEmpty) 18 | assert.isFalse(store.hasBeenModified) 19 | }) 20 | 21 | test('return default value when original value is null', ({ assert }) => { 22 | const store = new ValuesStore({ title: null } as any) 23 | assert.equal(store.get('title', ''), '') 24 | }) 25 | 26 | test('mutate values inside store', ({ assert }) => { 27 | const store = new ValuesStore({}) 28 | store.set('username', 'virk') 29 | 30 | assert.isFalse(store.isEmpty) 31 | assert.isTrue(store.hasBeenModified) 32 | assert.deepEqual(store.toJSON(), { username: 'virk' }) 33 | }) 34 | 35 | test('mutate nested values inside store', ({ assert }) => { 36 | const store = new ValuesStore({}) 37 | store.set('user.username', 'virk') 38 | 39 | assert.isFalse(store.isEmpty) 40 | assert.isTrue(store.hasBeenModified) 41 | assert.deepEqual(store.toJSON(), { user: { username: 'virk' } }) 42 | }) 43 | 44 | test('remove value from store', ({ assert }) => { 45 | const store = new ValuesStore(null) 46 | store.set('user.username', 'virk') 47 | store.unset('user.username') 48 | 49 | assert.isFalse(store.isEmpty) 50 | assert.isTrue(store.hasBeenModified) 51 | assert.deepEqual(store.toJSON(), { user: {} }) 52 | }) 53 | 54 | test('increment value inside store', ({ assert }) => { 55 | const store = new ValuesStore(null) 56 | store.set('user.age', 22) 57 | store.increment('user.age') 58 | 59 | assert.isFalse(store.isEmpty) 60 | assert.isTrue(store.hasBeenModified) 61 | assert.deepEqual(store.toJSON(), { user: { age: 23 } }) 62 | }) 63 | 64 | test('throw when incrementing a non integer value', () => { 65 | const store = new ValuesStore(null) 66 | store.set('user.age', 'foo') 67 | store.increment('user.age') 68 | }).throws('Cannot increment "user.age". Existing value is not a number') 69 | 70 | test('decrement value inside store', ({ assert }) => { 71 | const store = new ValuesStore(null) 72 | store.set('user.age', 22) 73 | store.decrement('user.age') 74 | 75 | assert.isFalse(store.isEmpty) 76 | assert.isTrue(store.hasBeenModified) 77 | assert.deepEqual(store.toJSON(), { user: { age: 21 } }) 78 | }) 79 | 80 | test('throw when decrementing a non integer value', () => { 81 | const store = new ValuesStore(null) 82 | store.set('user.age', 'foo') 83 | store.decrement('user.age') 84 | }).throws('Cannot decrement "user.age". Existing value is not a number') 85 | 86 | test('find if value exists in the store', ({ assert }) => { 87 | const store = new ValuesStore({}) 88 | assert.isFalse(store.has('username')) 89 | 90 | store.update({ username: 'virk' }) 91 | 92 | assert.isTrue(store.has('username')) 93 | assert.isFalse(store.isEmpty) 94 | assert.isTrue(store.hasBeenModified) 95 | }) 96 | 97 | test('check for arrays length', ({ assert }) => { 98 | const store = new ValuesStore({}) 99 | assert.isFalse(store.has('users')) 100 | 101 | store.update({ users: [] }) 102 | assert.isFalse(store.has('users')) 103 | 104 | store.update({ users: ['virk'] }) 105 | assert.isTrue(store.has('users')) 106 | }) 107 | 108 | test('do not check for array length when explicitly said no', ({ assert }) => { 109 | const store = new ValuesStore({}) 110 | assert.isFalse(store.has('users')) 111 | 112 | store.update({ users: [] }) 113 | assert.isTrue(store.has('users', false)) 114 | 115 | store.update({ users: ['virk'] }) 116 | assert.isTrue(store.has('users')) 117 | }) 118 | 119 | test('pull key from the store', ({ assert }) => { 120 | const store = new ValuesStore({}) 121 | store.set('username', 'virk') 122 | 123 | assert.equal(store.pull('username'), 'virk') 124 | 125 | assert.isTrue(store.isEmpty) 126 | assert.isTrue(store.hasBeenModified) 127 | assert.deepEqual(store.toJSON(), {}) 128 | }) 129 | 130 | test('deep merge with existing values', ({ assert }) => { 131 | const store = new ValuesStore({}) 132 | store.set('user', { profile: { username: 'virk' }, id: 1 }) 133 | store.merge({ user: { profile: { age: 32 } } }) 134 | 135 | assert.isFalse(store.isEmpty) 136 | assert.isTrue(store.hasBeenModified) 137 | 138 | assert.deepEqual(store.toJSON(), { user: { id: 1, profile: { age: 32, username: 'virk' } } }) 139 | }) 140 | 141 | test('clear store', ({ assert }) => { 142 | const store = new ValuesStore({}) 143 | store.set('user', { profile: { username: 'virk' }, id: 1 }) 144 | store.clear() 145 | 146 | assert.isTrue(store.isEmpty) 147 | assert.isTrue(store.hasBeenModified) 148 | assert.deepEqual(store.toObject(), {}) 149 | }) 150 | 151 | test('stringify store data object', ({ assert }) => { 152 | const store = new ValuesStore({}) 153 | store.set('user', { profile: { username: 'virk' }, id: 1 }) 154 | store.merge({ user: { profile: { age: 32 } } }) 155 | 156 | assert.isFalse(store.isEmpty) 157 | assert.isTrue(store.hasBeenModified) 158 | 159 | assert.equal( 160 | store.toString(), 161 | JSON.stringify({ user: { profile: { username: 'virk', age: 32 }, id: 1 } }) 162 | ) 163 | }) 164 | }) 165 | -------------------------------------------------------------------------------- /tests_helpers/dynamodb_schemas/custom-key-sessions-table.json: -------------------------------------------------------------------------------- 1 | { 2 | "TableName": "CustomKeySession", 3 | "KeySchema": [ 4 | { 5 | "AttributeName": "sessionId", 6 | "KeyType": "HASH" 7 | } 8 | ], 9 | "AttributeDefinitions": [ 10 | { 11 | "AttributeName": "sessionId", 12 | "AttributeType": "S" 13 | } 14 | ], 15 | "ProvisionedThroughput": { 16 | "ReadCapacityUnits": 5, 17 | "WriteCapacityUnits": 5 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests_helpers/dynamodb_schemas/sessions-table.json: -------------------------------------------------------------------------------- 1 | { 2 | "TableName": "Session", 3 | "KeySchema": [ 4 | { 5 | "AttributeName": "key", 6 | "KeyType": "HASH" 7 | } 8 | ], 9 | "AttributeDefinitions": [ 10 | { 11 | "AttributeName": "key", 12 | "AttributeType": "S" 13 | } 14 | ], 15 | "ProvisionedThroughput": { 16 | "ReadCapacityUnits": 5, 17 | "WriteCapacityUnits": 5 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests_helpers/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { getActiveTest } from '@japa/runner' 11 | import type { Test } from '@japa/runner/core' 12 | import { browserClient } from '@japa/browser-client' 13 | import { pluginAdonisJS } from '@japa/plugin-adonisjs' 14 | import { ApiClient, apiClient } from '@japa/api-client' 15 | import { NamedReporterContract } from '@japa/runner/types' 16 | import { runner, syncReporter } from '@japa/runner/factories' 17 | import type { ApplicationService } from '@adonisjs/core/types' 18 | import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb' 19 | import { IncomingMessage, ServerResponse, createServer } from 'node:http' 20 | 21 | import { sessionApiClient } from '../src/plugins/japa/api_client.js' 22 | import { sessionBrowserClient } from '../src/plugins/japa/browser_client.js' 23 | import { marshall, unmarshall } from '@aws-sdk/util-dynamodb' 24 | 25 | export const httpServer = { 26 | create(callback: (req: IncomingMessage, res: ServerResponse) => any) { 27 | const server = createServer(callback) 28 | getActiveTest()?.cleanup(async () => { 29 | await new Promise((resolve) => { 30 | server.close(() => resolve()) 31 | }) 32 | }) 33 | return server 34 | }, 35 | } 36 | 37 | /** 38 | * Runs a japa test in isolation 39 | */ 40 | export async function runJapaTest(app: ApplicationService, callback: Parameters[0]) { 41 | ApiClient.clearSetupHooks() 42 | ApiClient.clearTeardownHooks() 43 | ApiClient.clearRequestHandlers() 44 | 45 | await runner() 46 | .configure({ 47 | reporters: { 48 | activated: [syncReporter.name], 49 | list: [syncReporter as NamedReporterContract], 50 | }, 51 | plugins: [ 52 | apiClient(), 53 | browserClient({}), 54 | pluginAdonisJS(app), 55 | sessionApiClient(app), 56 | sessionBrowserClient(app), 57 | ], 58 | files: [], 59 | }) 60 | .runTest('testing japa integration', callback) 61 | } 62 | 63 | /** 64 | * Helper to create a dynamo DB client instance 65 | */ 66 | export const dynamodbClient = { 67 | create() { 68 | const client = new DynamoDBClient({ 69 | region: 'us-east-1', 70 | endpoint: 'http://localhost:8000', 71 | credentials: { 72 | accessKeyId: 'accessKeyId', 73 | secretAccessKey: 'secretAccessKey', 74 | }, 75 | }) 76 | return client 77 | }, 78 | } 79 | 80 | /** 81 | * Returns the session id value from the dynamoDB store 82 | */ 83 | export async function getSession( 84 | client: DynamoDBClient, 85 | tableName: string, 86 | key: string, 87 | sessionId: string 88 | ) { 89 | const result = await client.send( 90 | new GetItemCommand({ 91 | TableName: tableName, 92 | Key: marshall({ [key]: sessionId }), 93 | }) 94 | ) 95 | 96 | if (!result.Item) { 97 | return null 98 | } 99 | 100 | const item = unmarshall(result.Item) 101 | return JSON.parse(item.value) ?? null 102 | } 103 | 104 | /** 105 | * Returns expiry for the session id from the dynamoDB store 106 | */ 107 | export async function getExpiry( 108 | client: DynamoDBClient, 109 | tableName: string, 110 | key: string, 111 | sessionId: string 112 | ) { 113 | const result = await client.send( 114 | new GetItemCommand({ 115 | TableName: tableName, 116 | Key: marshall({ [key]: sessionId }), 117 | }) 118 | ) 119 | 120 | if (!result.Item) { 121 | return 0 122 | } 123 | 124 | const item = unmarshall(result.Item) 125 | return item.expires_at as number 126 | } 127 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build" 6 | } 7 | } 8 | --------------------------------------------------------------------------------