├── .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 |
--------------------------------------------------------------------------------