diff --git a/.github/workflows/deno.yml b/.github/workflows/deno.yml index 014089b..f6c398a 100644 --- a/.github/workflows/deno.yml +++ b/.github/workflows/deno.yml @@ -5,9 +5,9 @@ name: Deno Deploy & Test on: push: - branches: ["main","test"] + branches: ["main", "test"] pull_request: - branches: ["main","test"] + branches: ["main", "test"] permissions: id-token: write # This is required to allow the GitHub Action to authenticate with Deno Deploy. @@ -28,11 +28,11 @@ jobs: - name: Setup Deno uses: denoland/setup-deno@v1 with: - deno-version: v1.35.2 - - - name: Verify formatting + deno-version: v2.6.6 + + - name: Verify formatting run: deno fmt --check - + - name: Cache Deno dependencies uses: actions/cache@v3 with: @@ -40,4 +40,7 @@ jobs: path: ${{ env.DENO_DIR }} - name: Run tests - run: deno test -A --quiet + run: deno task test + + - name: Run tests doc + run: deno task test:doc diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d682d84 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,16 @@ +name: Publish +on: + push: + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + - name: Publish package + run: npx jsr publish diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c2f433 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +tmp \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 09cf720..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "recommendations": [ - "denoland.vscode-deno" - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json index 7fe6ae6..cbac569 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,3 @@ { - "deno.enable": true, - "deno.lint": true, - "editor.defaultFormatter": "denoland.vscode-deno", - "editor.formatOnSave": true + "deno.enable": true } diff --git a/README.md b/README.md index c72ce8e..950a201 100644 --- a/README.md +++ b/README.md @@ -1,175 +1,362 @@ # Fresh Session 🍋 -Dead simple cookie-based session for [Deno Fresh](https://fresh.deno.dev). +Dead simple cookie-based session for [Deno Fresh v2](https://fresh.deno.dev). -## Get started +## Features -Fresh Session comes with a simple middleware to add at the root of your project, -which will create or resolve a session from the request cookie. +- 🔐 **AES-GCM 256-bit encryption** - Secure session data with encrypted cookies +- 💾 **Multiple storage backends** - Memory, Cookie, Deno KV, Redis, SQL +- ⚡ **Flash messages** - One-time messages for redirects +- 🔄 **Session rotation** - Regenerate session ID for security +- 🎯 **TypeScript first** - Full type safety -### Install / Import - -You can import Fresh Session like so: +## Installation ```ts import { - cookieSession, - CookieSessionStorage, - createCookieSessionStorage, - Session, - WithSession, -} from "https://deno.land/x/fresh_session@0.2.0/mod.ts"; + CookieSessionStore, + KvSessionStore, + MemorySessionStore, + RedisSessionStore, + session, + type SessionState, + SqlSessionStore, +} from "@octo/fresh-session"; ``` -### Setup secret key +## Quick Start + +Sample app notes: -Fresh Session currently uses -[iron-webcrypto](https://github.com/brc-dd/iron-webcrypto) encrypted cookie -contents. +- `sample/session_memory.ts` uses `MemorySessionStore` +- `sample/session_cookie.ts` shows the cookie store pattern +- `sample/session_kv.ts` shows the Deno KV store pattern +- `sample/session_redis.ts` shows the Redis store pattern +- `sample/session_mysql.ts` shows the MySQL store pattern +- `sample/session_postgres.ts` shows the PostgreSQL store pattern -iron-webcrypto requires a secret key to encrypt the session payload. Fresh -Session uses the secret key from your -[environment variable](https://deno.land/std/dotenv/load.ts) `APP_KEY`. +Redis/MySQL/PostgreSQL sample notes: -If you don't know how to setup environment variable locally, I wrote -[an article about .env file in Deno Fresh](https://xstevenyung.com/blog/read-.env-file-in-deno-fresh). +- Samples read `REDIS_HOST`, `REDIS_PORT`, `MYSQL_HOST`, `MYSQL_PORT`, + `POSTGRES_HOST`, `POSTGRES_PORT` +- Defaults (when using `with-resource`): + - Redis: `127.0.0.1:6380` + - MySQL: `127.0.0.1:3307` + - PostgreSQL: `127.0.0.1:5433` -### Create a root middleware (`./routes/_middleware.ts`) +### 1. Create a session middleware ```ts -import { MiddlewareHandlerContext } from "$fresh/server.ts"; -import { cookieSession, WithSession } from "fresh-session"; +// routes/_middleware.ts +import { App } from "@fresh/core"; +import { + MemorySessionStore, + session, + type SessionState, +} from "@octo/fresh-session"; + +// Define your app state +interface State extends SessionState { + // your other state properties +} -export type State = {} & WithSession; +const app = new App(); -const session = cookieSession(); +// Create a store instance +const store = new MemorySessionStore(); -function sessionHandler(req: Request, ctx: MiddlewareHandlerContext) { - return session(req, ctx); -} -export const handler = [sessionHandler]; -``` - -Learn more about -[Fresh route middleware](https://fresh.deno.dev/docs/concepts/middleware). - -### Interact with the session in your routes - -Now that the middleware is setup, it's going to handle creating/resolving -session based on the request cookie. So all that you need to worry about is -interacting with your session. - -```tsx -// ./routes/dashboard.tsx -import { Handlers, PageProps } from "$fresh/server.ts"; -import { WithSession } from "https://deno.land/x/fresh_session@0.2.0/mod.ts"; - -export type Data = { session: Record }; - -export const handler: Handlers< - Data, - WithSession // indicate with Typescript that the session is in the `ctx.state` -> = { - GET(_req, ctx) { - // The session is accessible via the `ctx.state` - const { session } = ctx.state; - - // Access data stored in the session - session.get("email"); - // Set new value in the session - session.set("email", "hello@deno.dev"); - // returns `true` if the session has a value with a specific key, else `false` - session.has("email"); - // clear all the session data - session.clear(); - // Access all session data value as an object - session.data; - // Add flash data which will disappear after accessing it - session.flash("success", "Successfully flashed a message!"); - // Accessing the flashed data - // /!\ This flashed data will disappear after accessing it one time. - session.flash("success"); - // Session Key Rotation only kv store and redis store. - // Is not work in cookie store. - - // Rotate the session key. Only supported by the kv store and redis store, not the cookie store. - // If using the session for authentication, with a kv or redis store, it is recommended to rotate the key at login to prevent session fixation attack. - // The cookie store is immune from this issue. - session.keyRotate(); - - return ctx.render({ - session: session.data, // You can pass the whole session data to the page - }); - }, -}; +// Add session middleware +// Secret key must be at least 32 characters for AES-256 +app.use(session(store, "your-secret-key-at-least-32-characters-long")); +``` -export default function Dashboard({ data }: PageProps) { - return
You are logged in as {data.session.email}
; -} +### 2. Use session in your routes + +```tsx ignore +// routes/index.tsx +app.get("/", (ctx) => { + const { session } = ctx.state; + + // Get value from session + const count = (session.get("count") as number) ?? 0; + + // Set value to session + session.set("count", count + 1); + + // Check if session is new + const isNew = session.isNew(); + + // Get session ID + const sessionId = session.sessionId(); + + return ctx.render(
Visit count: {count + 1}
); +}); ``` -## Usage Cookie Options +## Session API -session value is cookie. can set the option for cookie. +```ts ignore +const { session } = ctx.state; -```ts -import { cookieSession } from "fresh-session"; +// Basic operations +session.get("key"); // Get a value +session.set("key", value); // Set a value +session.isNew(); // Check if session is new +session.sessionId(); // Get session ID -export const handler = [ - cookieSession({ - maxAge: 30, //Session keep is 30 seconds. - httpOnly: true, - }), -]; +// Flash messages (one-time data) +session.flash.set("message", "Success!"); // Set flash data +session.flash.get("message"); // Get & consume flash data +session.flash.has("message"); // Check if flash exists + +// Security +session.destroy(); // Destroy session +session.rotate(); // Rotate session ID (recommended after login) ``` -## cookie session based on Redis +## Storage Backends -In addition to storing session data in cookies, values can be stored in Redis. +### Memory Store (Development) -```ts -import { redisSession } from "fresh-session/mod.ts"; -import { connect } from "redis/mod.ts"; +Simple in-memory storage. Data is lost when the server restarts. + +```ts ignore +import { MemorySessionStore } from "@octo/fresh-session"; + +const store = new MemorySessionStore(); +``` + +### Cookie Store + +Stores session data in the cookie itself. No server-side storage needed. + +> ⚠️ Cookie size limit is ~4KB. Use for small session data only. + +```ts ignore +import { CookieSessionStore } from "@octo/fresh-session"; + +const store = new CookieSessionStore(); +``` + +### Deno KV Store + +Persistent storage using Deno KV. Recommended for Deno Deploy. + +```ts ignore +import { KvSessionStore } from "@octo/fresh-session"; + +const kv = await Deno.openKv(); +const store = new KvSessionStore({ kv, keyPrefix: ["my_sessions"] }); +``` + +### Redis Store + +For distributed environments with Redis. + +```ts ignore +import { type RedisClient, RedisSessionStore } from "@octo/fresh-session"; +import { connect } from "jsr:@db/redis"; const redis = await connect({ - hostname: "something redis server", + hostname: "127.0.0.1", port: 6379, }); -export const handler = [redisSession(redis)]; +// Adapt to RedisClient interface +const client: RedisClient = { + get: (key) => redis.get(key), + set: (key, value, options) => + redis.set(key, value, options?.ex ? { ex: options.ex } : undefined) + .then(() => {}), + del: (key) => redis.del(key).then(() => {}), +}; -// or Customizable cookie options and Redis key prefix +const store = new RedisSessionStore({ client, keyPrefix: "session:" }); +``` -export const handler = [ - redisSession(redis, { - keyPrefix: "S_", - maxAge: 10, - }), -]; +### SQL Store (MySQL, PostgreSQL, etc.) + +For applications using relational databases. + +```sql +-- Required table structure (MySQL) +CREATE TABLE sessions ( + session_id VARCHAR(36) PRIMARY KEY, + data TEXT NOT NULL, + expires_at DATETIME NULL +); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); + +-- PostgreSQL example +CREATE TABLE sessions ( + session_id VARCHAR(36) PRIMARY KEY, + data TEXT NOT NULL, + expires_at TIMESTAMP NULL +); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); ``` -## FAQ & Troubleshooting Errors +```ts ignore +import { type SqlClient, SqlSessionStore } from "@octo/fresh-session"; -Some common questions and troubleshooting errors. +// Adapt your SQL client to SqlClient interface +const client: SqlClient = { + execute: async (sql, params) => { + const result = await yourDbClient.query(sql, params); + return { rows: result.rows }; + }, +}; + +const store = new SqlSessionStore({ client, tableName: "sessions" }); +// For PostgreSQL: +// const store = new SqlSessionStore({ client, tableName: "sessions", dialect: "postgres" }); +``` + +## Samples + +```sh +deno task sample:memory +deno task sample:cookie +deno task sample:kv +deno task sample:redis +deno task sample:mysql +deno task sample:postgres +``` + +All Redis/MySQL/PostgreSQL samples use `scripts/with-resource.ts`, which starts +Docker containers and injects environment variables. + +## Tasks and Permissions + +This repo uses named permissions for the resource wrapper: + +```json +"permissions": { + "with-resource": { + "run": ["docker", "deno"], + "env": [ + "MYSQL_DATABASE", + "MYSQL_USER", + "MYSQL_PASSWORD", + "POSTGRES_DATABASE", + "POSTGRES_USER", + "POSTGRES_PASSWORD" + ] + } +} +``` + +Tasks that start containers use `-P=with-resource`. + +## Configuration Options + +```ts ignore +app.use(session(store, secret, { + // Cookie name + cookieName: "fresh_session", // default + + // Cookie options + cookieOptions: { + path: "/", + httpOnly: true, + secure: true, // Set to false for local development + sameSite: "Lax", // "Strict" | "Lax" | "None" + maxAge: 60 * 60 * 24, // 1 day in seconds + domain: "", + }, + + // Session expiration in milliseconds + sessionExpires: 1000 * 60 * 60 * 24, // 1 day (default) +})); +``` + +## Flash Messages + +Flash messages are one-time data that get cleared after being read. Perfect for +success/error messages after redirects. + +```tsx ignore +// In your form handler +app.post("/login", async (ctx) => { + const form = await ctx.req.formData(); + // ... validate login + + if (success) { + ctx.state.session.flash.set("message", "Login successful!"); + ctx.state.session.flash.set("type", "success"); + } else { + ctx.state.session.flash.set("message", "Invalid credentials"); + ctx.state.session.flash.set("type", "error"); + } + + return new Response(null, { + status: 302, + headers: { Location: "/" }, + }); +}); + +// In your page +app.get("/", (ctx) => { + const message = ctx.state.session.flash.get("message"); // Read & clear + const type = ctx.state.session.flash.get("type"); + + // message is now cleared and won't appear on next request + return ctx.render( +
{message && {message}}
, + ); +}); +``` + +## Session Rotation + +Regenerate session ID while keeping session data. Recommended after +authentication to prevent session fixation attacks. + +```ts ignore +app.post("/login", async (ctx) => { + // ... validate credentials + + if (authenticated) { + // Rotate session ID for security + ctx.state.session.rotate(); + ctx.state.session.set("userId", user.id); + } + + return new Response(null, { + status: 302, + headers: { Location: "/dashboard" }, + }); +}); +``` + +## FAQ & Troubleshooting ### "TypeError: Headers are immutable." -If you are receiving this error, you are likely using a Response.redirect, which -makes the headers immutable. A workaround for this is to use the following -instead: +This occurs when using `Response.redirect()`. Use this workaround instead: -```ts -new Response(null, { +```ts ignore +// ❌ Don't use Response.redirect() +return Response.redirect("/dashboard"); + +// ✅ Use this instead +return new Response(null, { status: 302, - headers: { - Location: "your-url", - }, + headers: { Location: "/dashboard" }, }); ``` -## Credit +### Session not persisting + +1. Make sure your secret key is at least 32 characters +2. Check that `secure: false` is set for local development (non-HTTPS) +3. Verify the session middleware is added before your routes + +## License + +MIT -Initial work done by [@xstevenyung](https://github.com/xstevenyung) +## Credits -Inspiration taken from [Oak Sessions](https://github.com/jcs224/oak_sessions) & -thanks to [@jcs224](https://github.com/jcs224) for all the insight! +- Initial work by [@xstevenyung](https://github.com/xstevenyung) +- Inspiration from [Oak Sessions](https://github.com/jcs224/oak_sessions) diff --git a/deno.json b/deno.json index 5d82a83..c7bf68c 100644 --- a/deno.json +++ b/deno.json @@ -1,12 +1,77 @@ { + "name": "@octo/fresh-session", + "version": "0.9.0", + "exports": "./mod.ts", "tasks": { - "fixture": "deno run -A --watch=tests/fixture/static/,tests/fixture/routes/ tests/fixture/dev.ts", - "kv": "deno run -A --unstable --watch=example/use_kv_store/static/,example/use_kv_store/routes/ example/use_kv_store/dev.ts" + "dev": "deno test -E --watch", + "sample": "deno run -EN --watch sample/main.ts", + "sample:memory": "deno run -EN --watch sample/main.ts memory", + "sample:cookie": "deno run -EN --watch sample/main.ts cookie", + "sample:redis": "deno run -P=with-resource scripts/with-resource.ts -- deno run -EN --watch sample/main.ts redis", + "sample:kv": "deno run -EN --watch sample/main.ts kv", + "sample:mysql": "deno run -P=with-resource scripts/with-resource.ts -- deno run -EN --watch sample/main.ts mysql", + "sample:postgres": "deno run -P=with-resource scripts/with-resource.ts -- deno run -EN --watch sample/main.ts postgres", + "test": "deno run -P=with-resource scripts/with-resource.ts -- deno test -EN", + "test:doc": "deno run -P=with-resource scripts/with-resource.ts -- deno test --doc -EN README.md" }, + "test": { + "include": ["mod_test.ts", "src/**/*_test.ts"] + }, + "permissions": { + "with-resource": { + "run": ["docker", "deno"], + "env": [ + "MYSQL_DATABASE", + "MYSQL_USER", + "MYSQL_PASSWORD", + "POSTGRES_DATABASE", + "POSTGRES_USER", + "POSTGRES_PASSWORD" + ] + } + }, + "license": "MIT", "imports": { - "$fresh/": "https://deno.land/x/fresh@1.3.1/", - "$std/": "https://deno.land/std@0.195.0/", - "cookiejar": "https://deno.land/x/another_cookiejar@v5.0.3/mod.ts" + "@octo/fresh-session": "./mod.ts", + "@db/redis": "jsr:@db/redis@^0.40.0", + "@fresh/core": "jsr:@fresh/core@^2.2.0", + "@std/assert": "jsr:@std/assert@1", + "@std/http/cookie": "jsr:@std/http@1/cookie", + "mysql2": "npm:mysql2@^3.16.2", + "pg": "npm:pg@^8.11.5", + "preact": "npm:preact@^10.28.3" }, - "lock": false + "unstable": [ + "kv" + ], + "compilerOptions": { + "lib": [ + "dom", + "dom.asynciterable", + "dom.iterable", + "deno.unstable", + "deno.ns" + ], + "jsx": "precompile", + "jsxImportSource": "preact", + "jsxPrecompileSkipElements": [ + "a", + "img", + "source", + "body", + "html", + "head", + "title", + "meta", + "script", + "link", + "style", + "base", + "noscript", + "template" + ], + "types": [ + "vite/client" + ] + } } diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..bebc725 --- /dev/null +++ b/deno.lock @@ -0,0 +1,2405 @@ +{ + "version": "5", + "specifiers": { + "jsr:@db/redis@0.40": "0.40.0", + "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.0", + "jsr:@deno/loader@~0.3.3": "0.3.10", + "jsr:@fresh/build-id@1": "1.0.1", + "jsr:@fresh/core@^2.2.0": "2.2.0", + "jsr:@std/assert@1": "1.0.17", + "jsr:@std/async@1": "1.1.0", + "jsr:@std/bytes@1": "1.0.6", + "jsr:@std/bytes@^1.0.2-rc.3": "1.0.6", + "jsr:@std/bytes@^1.0.6": "1.0.6", + "jsr:@std/collections@1": "1.1.3", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@^1.0.8": "1.0.9", + "jsr:@std/fs@^1.0.19": "1.0.21", + "jsr:@std/html@^1.0.5": "1.0.5", + "jsr:@std/http@1": "1.0.22", + "jsr:@std/http@^1.0.21": "1.0.22", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/io@0.224.5": "0.224.5", + "jsr:@std/json@^1.0.2": "1.0.2", + "jsr:@std/jsonc@^1.0.2": "1.0.2", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/path@^1.1.1": "1.1.4", + "jsr:@std/path@^1.1.2": "1.1.4", + "jsr:@std/path@^1.1.4": "1.1.4", + "jsr:@std/random@0.1.0": "0.1.0", + "jsr:@std/semver@^1.0.6": "1.0.8", + "jsr:@std/uuid@^1.0.9": "1.0.9", + "npm:@opentelemetry/api@^1.9.0": "1.9.0", + "npm:@preact/signals@^2.2.1": "2.6.1_preact@10.28.3", + "npm:@types/ioredis-mock@*": "8.2.6_ioredis@5.9.2", + "npm:alasql@*": "4.17.0", + "npm:alasql@4": "4.17.0", + "npm:cluster-key-slot@1.1.0": "1.1.0", + "npm:esbuild-wasm@~0.25.11": "0.25.12", + "npm:esbuild@0.25.7": "0.25.7", + "npm:esbuild@~0.25.5": "0.25.7", + "npm:ioredis@*": "5.9.2", + "npm:mysql2@*": "3.16.2", + "npm:mysql2@^3.16.2": "3.16.2", + "npm:pg@*": "8.17.2", + "npm:pg@^8.11.5": "8.17.2", + "npm:preact-render-to-string@^6.6.3": "6.6.5_preact@10.28.3", + "npm:preact@^10.27.0": "10.28.3", + "npm:preact@^10.27.2": "10.28.3", + "npm:preact@^10.28.3": "10.28.3" + }, + "jsr": { + "@db/redis@0.40.0": { + "integrity": "98bef146ad2d1262c0a5388c32e9d62e83ada410c9cc49cee3868085037e930b", + "dependencies": [ + "jsr:@std/async", + "jsr:@std/bytes@1", + "jsr:@std/collections", + "jsr:@std/io", + "jsr:@std/random", + "npm:cluster-key-slot" + ] + }, + "@deno/esbuild-plugin@1.2.0": { + "integrity": "04ddd0fca9416d8a2866263928a53b9d5ed08dfca064d64504a0aaf9800c709e", + "dependencies": [ + "jsr:@deno/loader", + "jsr:@std/path@^1.1.1", + "npm:esbuild@~0.25.5" + ] + }, + "@deno/loader@0.3.10": { + "integrity": "a9c0aa44a0499e7fecef52c29fbc206c1c8f8946388f25d9d0789a23313bfd43" + }, + "@fresh/build-id@1.0.1": { + "integrity": "12a2ec25fd52ae9ec68c26848a5696cd1c9b537f7c983c7e56e4fb1e7e816c20", + "dependencies": [ + "jsr:@std/encoding" + ] + }, + "@fresh/core@2.2.0": { + "integrity": "b3c00f82288a2c4c8ec85e4abb67b080b366ec5971860f2f2898eb281ea1a80f", + "dependencies": [ + "jsr:@deno/esbuild-plugin", + "jsr:@fresh/build-id", + "jsr:@std/encoding", + "jsr:@std/fmt", + "jsr:@std/fs", + "jsr:@std/html", + "jsr:@std/http@^1.0.21", + "jsr:@std/jsonc", + "jsr:@std/media-types", + "jsr:@std/path@^1.1.2", + "jsr:@std/semver", + "jsr:@std/uuid", + "npm:@opentelemetry/api", + "npm:@preact/signals", + "npm:esbuild-wasm", + "npm:esbuild@0.25.7", + "npm:preact-render-to-string", + "npm:preact@^10.27.0", + "npm:preact@^10.27.2" + ] + }, + "@std/assert@1.0.17": { + "integrity": "df5ebfffe77c03b3fa1401e11c762cc8f603d51021c56c4d15a8c7ab45e90dbe", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/async@1.1.0": { + "integrity": "72418df08d1be84668a53e48aab3520d68ae6882182f8a5ca75c6d1f087220d1" + }, + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, + "@std/collections@1.1.3": { + "integrity": "bf8b0818886df6a32b64c7d3b037a425111f28278d69fd0995aeb62777c986b0" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fmt@1.0.9": { + "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" + }, + "@std/fs@1.0.21": { + "integrity": "d720fe1056d78d43065a4d6e0eeb2b19f34adb8a0bc7caf3a4dbf1d4178252cd", + "dependencies": [ + "jsr:@std/path@^1.1.4" + ] + }, + "@std/html@1.0.5": { + "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" + }, + "@std/http@1.0.22": { + "integrity": "53f0bb70e23a2eec3e17c4240a85bb23d185b2e20635adb37ce0f03cc4ca012a", + "dependencies": [ + "jsr:@std/encoding" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/io@0.224.5": { + "integrity": "cb84fe655d1273fca94efcff411465027a8b0b4225203f19d6ee98d9c8920a2d", + "dependencies": [ + "jsr:@std/bytes@^1.0.2-rc.3" + ] + }, + "@std/json@1.0.2": { + "integrity": "d9e5497801c15fb679f55a2c01c7794ad7a5dfda4dd1bebab5e409cb5e0d34d4" + }, + "@std/jsonc@1.0.2": { + "integrity": "909605dae3af22bd75b1cbda8d64a32cf1fd2cf6efa3f9e224aba6d22c0f44c7", + "dependencies": [ + "jsr:@std/json" + ] + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/random@0.1.0": { + "integrity": "70a006be0ffb77d036bab54aa8ae6bd0119ba77ace0f2f56f63273d4262a5667" + }, + "@std/semver@1.0.8": { + "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7" + }, + "@std/uuid@1.0.9": { + "integrity": "44b627bf2d372fe1bd099e2ad41b2be41a777fc94e62a3151006895a037f1642", + "dependencies": [ + "jsr:@std/bytes@^1.0.6" + ] + } + }, + "npm": { + "@babel/code-frame@7.28.6": { + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dependencies": [ + "@babel/helper-validator-identifier", + "js-tokens", + "picocolors" + ] + }, + "@babel/compat-data@7.28.6": { + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==" + }, + "@babel/core@7.28.6": { + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dependencies": [ + "@babel/code-frame", + "@babel/generator", + "@babel/helper-compilation-targets", + "@babel/helper-module-transforms", + "@babel/helpers", + "@babel/parser", + "@babel/template", + "@babel/traverse", + "@babel/types", + "@jridgewell/remapping", + "convert-source-map", + "debug@4.4.3", + "gensync", + "json5", + "semver@6.3.1" + ] + }, + "@babel/generator@7.28.6": { + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "dependencies": [ + "@babel/parser", + "@babel/types", + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping", + "jsesc" + ] + }, + "@babel/helper-compilation-targets@7.28.6": { + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dependencies": [ + "@babel/compat-data", + "@babel/helper-validator-option", + "browserslist", + "lru-cache", + "semver@6.3.1" + ] + }, + "@babel/helper-globals@7.28.0": { + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==" + }, + "@babel/helper-module-imports@7.28.6": { + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dependencies": [ + "@babel/traverse", + "@babel/types" + ] + }, + "@babel/helper-module-transforms@7.28.6_@babel+core@7.28.6": { + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dependencies": [ + "@babel/core", + "@babel/helper-module-imports", + "@babel/helper-validator-identifier", + "@babel/traverse" + ] + }, + "@babel/helper-plugin-utils@7.28.6": { + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==" + }, + "@babel/helper-string-parser@7.27.1": { + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==" + }, + "@babel/helper-validator-identifier@7.28.5": { + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==" + }, + "@babel/helper-validator-option@7.27.1": { + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==" + }, + "@babel/helpers@7.28.6": { + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dependencies": [ + "@babel/template", + "@babel/types" + ] + }, + "@babel/parser@7.28.6": { + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dependencies": [ + "@babel/types" + ], + "bin": true + }, + "@babel/plugin-syntax-async-generators@7.8.4_@babel+core@7.28.6": { + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-bigint@7.8.3_@babel+core@7.28.6": { + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-class-properties@7.12.13_@babel+core@7.28.6": { + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-class-static-block@7.14.5_@babel+core@7.28.6": { + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-import-attributes@7.28.6_@babel+core@7.28.6": { + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-import-meta@7.10.4_@babel+core@7.28.6": { + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-json-strings@7.8.3_@babel+core@7.28.6": { + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-logical-assignment-operators@7.10.4_@babel+core@7.28.6": { + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-nullish-coalescing-operator@7.8.3_@babel+core@7.28.6": { + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-numeric-separator@7.10.4_@babel+core@7.28.6": { + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-object-rest-spread@7.8.3_@babel+core@7.28.6": { + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-optional-catch-binding@7.8.3_@babel+core@7.28.6": { + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-optional-chaining@7.8.3_@babel+core@7.28.6": { + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-private-property-in-object@7.14.5_@babel+core@7.28.6": { + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-top-level-await@7.14.5_@babel+core@7.28.6": { + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/runtime@7.28.4": { + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==" + }, + "@babel/template@7.28.6": { + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dependencies": [ + "@babel/code-frame", + "@babel/parser", + "@babel/types" + ] + }, + "@babel/traverse@7.28.6": { + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "dependencies": [ + "@babel/code-frame", + "@babel/generator", + "@babel/helper-globals", + "@babel/parser", + "@babel/template", + "@babel/types", + "debug@4.4.3" + ] + }, + "@babel/types@7.28.6": { + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dependencies": [ + "@babel/helper-string-parser", + "@babel/helper-validator-identifier" + ] + }, + "@esbuild/aix-ppc64@0.25.7": { + "integrity": "sha512-uD0kKFHh6ETr8TqEtaAcV+dn/2qnYbH/+8wGEdY70Qf7l1l/jmBUbrmQqwiPKAQE6cOQ7dTj6Xr0HzQDGHyceQ==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/android-arm64@0.25.7": { + "integrity": "sha512-p0ohDnwyIbAtztHTNUTzN5EGD/HJLs1bwysrOPgSdlIA6NDnReoVfoCyxG6W1d85jr2X80Uq5KHftyYgaK9LPQ==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm@0.25.7": { + "integrity": "sha512-Jhuet0g1k9rAJHrXGIh7sFknFuT4sfytYZpZpuZl7YKDhnPByVAm5oy2LEBmMbuYf3ejWVYCc2seX81Mk+madA==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-x64@0.25.7": { + "integrity": "sha512-mMxIJFlSgVK23HSsII3ZX9T2xKrBCDGyk0qiZnIW10LLFFtZLkFD6imZHu7gUo2wkNZwS9Yj3mOtZD3ZPcjCcw==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/darwin-arm64@0.25.7": { + "integrity": "sha512-jyOFLGP2WwRwxM8F1VpP6gcdIJc8jq2CUrURbbTouJoRO7XCkU8GdnTDFIHdcifVBT45cJlOYsZ1kSlfbKjYUQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-x64@0.25.7": { + "integrity": "sha512-m9bVWqZCwQ1BthruifvG64hG03zzz9gE2r/vYAhztBna1/+qXiHyP9WgnyZqHgGeXoimJPhAmxfbeU+nMng6ZA==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-arm64@0.25.7": { + "integrity": "sha512-Bss7P4r6uhr3kDzRjPNEnTm/oIBdTPRNQuwaEFWT/uvt6A1YzK/yn5kcx5ZxZ9swOga7LqeYlu7bDIpDoS01bA==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-x64@0.25.7": { + "integrity": "sha512-S3BFyjW81LXG7Vqmr37ddbThrm3A84yE7ey/ERBlK9dIiaWgrjRlre3pbG7txh1Uaxz8N7wGGQXmC9zV+LIpBQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/linux-arm64@0.25.7": { + "integrity": "sha512-HfQZQqrNOfS1Okn7PcsGUqHymL1cWGBslf78dGvtrj8q7cN3FkapFgNA4l/a5lXDwr7BqP2BSO6mz9UremNPbg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm@0.25.7": { + "integrity": "sha512-JZMIci/1m5vfQuhKoFXogCKVYVfYQmoZJg8vSIMR4TUXbF+0aNlfXH3DGFEFMElT8hOTUF5hisdZhnrZO/bkDw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-ia32@0.25.7": { + "integrity": "sha512-9Jex4uVpdeofiDxnwHRgen+j6398JlX4/6SCbbEFEXN7oMO2p0ueLN+e+9DdsdPLUdqns607HmzEFnxwr7+5wQ==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-loong64@0.25.7": { + "integrity": "sha512-TG1KJqjBlN9IHQjKVUYDB0/mUGgokfhhatlay8aZ/MSORMubEvj/J1CL8YGY4EBcln4z7rKFbsH+HeAv0d471w==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-mips64el@0.25.7": { + "integrity": "sha512-Ty9Hj/lx7ikTnhOfaP7ipEm/ICcBv94i/6/WDg0OZ3BPBHhChsUbQancoWYSO0WNkEiSW5Do4febTTy4x1qYQQ==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-ppc64@0.25.7": { + "integrity": "sha512-MrOjirGQWGReJl3BNQ58BLhUBPpWABnKrnq8Q/vZWWwAB1wuLXOIxS2JQ1LT3+5T+3jfPh0tyf5CpbyQHqnWIQ==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-riscv64@0.25.7": { + "integrity": "sha512-9pr23/pqzyqIZEZmQXnFyqp3vpa+KBk5TotfkzGMqpw089PGm0AIowkUppHB9derQzqniGn3wVXgck19+oqiOw==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-s390x@0.25.7": { + "integrity": "sha512-4dP11UVGh9O6Y47m8YvW8eoA3r8qL2toVZUbBKyGta8j6zdw1cn9F/Rt59/Mhv0OgY68pHIMjGXWOUaykCnx+w==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-x64@0.25.7": { + "integrity": "sha512-ghJMAJTdw/0uhz7e7YnpdX1xVn7VqA0GrWrAO2qKMuqbvgHT2VZiBv1BQ//VcHsPir4wsL3P2oPggfKPzTKoCA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-arm64@0.25.7": { + "integrity": "sha512-bwXGEU4ua45+u5Ci/a55B85KWaDSRS8NPOHtxy2e3etDjbz23wlry37Ffzapz69JAGGc4089TBo+dGzydQmydg==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-x64@0.25.7": { + "integrity": "sha512-tUZRvLtgLE5OyN46sPSYlgmHoBS5bx2URSrgZdW1L1teWPYVmXh+QN/sKDqkzBo/IHGcKcHLKDhBeVVkO7teEA==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-arm64@0.25.7": { + "integrity": "sha512-bTJ50aoC+WDlDGBReWYiObpYvQfMjBNlKztqoNUL0iUkYtwLkBQQeEsTq/I1KyjsKA5tyov6VZaPb8UdD6ci6Q==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-x64@0.25.7": { + "integrity": "sha512-TA9XfJrgzAipFUU895jd9j2SyDh9bbNkK2I0gHcvqb/o84UeQkBpi/XmYX3cO1q/9hZokdcDqQxIi6uLVrikxg==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openharmony-arm64@0.25.7": { + "integrity": "sha512-5VTtExUrWwHHEUZ/N+rPlHDwVFQ5aME7vRJES8+iQ0xC/bMYckfJ0l2n3yGIfRoXcK/wq4oXSItZAz5wslTKGw==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/sunos-x64@0.25.7": { + "integrity": "sha512-umkbn7KTxsexhv2vuuJmj9kggd4AEtL32KodkJgfhNOHMPtQ55RexsaSrMb+0+jp9XL4I4o2y91PZauVN4cH3A==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/win32-arm64@0.25.7": { + "integrity": "sha512-j20JQGP/gz8QDgzl5No5Gr4F6hurAZvtkFxAKhiv2X49yi/ih8ECK4Y35YnjlMogSKJk931iNMcd35BtZ4ghfw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-ia32@0.25.7": { + "integrity": "sha512-4qZ6NUfoiiKZfLAXRsvFkA0hoWVM+1y2bSHXHkpdLAs/+r0LgwqYohmfZCi985c6JWHhiXP30mgZawn/XrqAkQ==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-x64@0.25.7": { + "integrity": "sha512-FaPsAHTwm+1Gfvn37Eg3E5HIpfR3i6x1AIcla/MkqAIupD4BW3MrSeUqfoTzwwJhk3WE2/KqUn4/eenEJC76VA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@ioredis/commands@1.5.0": { + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==" + }, + "@isaacs/ttlcache@1.4.1": { + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==" + }, + "@istanbuljs/load-nyc-config@1.1.0": { + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dependencies": [ + "camelcase@5.3.1", + "find-up", + "get-package-type", + "js-yaml", + "resolve-from" + ] + }, + "@istanbuljs/schema@0.1.3": { + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==" + }, + "@jest/create-cache-key-function@29.7.0": { + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "dependencies": [ + "@jest/types" + ] + }, + "@jest/environment@29.7.0": { + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dependencies": [ + "@jest/fake-timers", + "@jest/types", + "@types/node", + "jest-mock" + ] + }, + "@jest/fake-timers@29.7.0": { + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dependencies": [ + "@jest/types", + "@sinonjs/fake-timers", + "@types/node", + "jest-message-util", + "jest-mock", + "jest-util" + ] + }, + "@jest/schemas@29.6.3": { + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dependencies": [ + "@sinclair/typebox" + ] + }, + "@jest/transform@29.7.0": { + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dependencies": [ + "@babel/core", + "@jest/types", + "@jridgewell/trace-mapping", + "babel-plugin-istanbul", + "chalk", + "convert-source-map", + "fast-json-stable-stringify", + "graceful-fs", + "jest-haste-map", + "jest-regex-util", + "jest-util", + "micromatch", + "pirates", + "slash", + "write-file-atomic" + ] + }, + "@jest/types@29.6.3": { + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dependencies": [ + "@jest/schemas", + "@types/istanbul-lib-coverage", + "@types/istanbul-reports", + "@types/node", + "@types/yargs", + "chalk" + ] + }, + "@jridgewell/gen-mapping@0.3.13": { + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": [ + "@jridgewell/sourcemap-codec", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/remapping@2.3.5": { + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": [ + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/resolve-uri@3.1.2": { + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + }, + "@jridgewell/source-map@0.3.11": { + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dependencies": [ + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/sourcemap-codec@1.5.5": { + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "@jridgewell/trace-mapping@0.3.31": { + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": [ + "@jridgewell/resolve-uri", + "@jridgewell/sourcemap-codec" + ] + }, + "@opentelemetry/api@1.9.0": { + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==" + }, + "@preact/signals-core@1.12.2": { + "integrity": "sha512-5Yf8h1Ke3SMHr15xl630KtwPTW4sYDFkkxS0vQ8UiQLWwZQnrF9IKaVG1mN5VcJz52EcWs2acsc/Npjha/7ysA==" + }, + "@preact/signals@2.6.1_preact@10.28.3": { + "integrity": "sha512-Gp3DI1T/0YyirwJnImR8l9xyVJgKiVzJXmEhic1/7SPw3zStrsvuBpwKnD609CzsIdzxprWa6yTNXN+VLLZPGQ==", + "dependencies": [ + "@preact/signals-core", + "preact" + ] + }, + "@react-native/assets-registry@0.83.1": { + "integrity": "sha512-AT7/T6UwQqO39bt/4UL5EXvidmrddXrt0yJa7ENXndAv+8yBzMsZn6fyiax6+ERMt9GLzAECikv3lj22cn2wJA==" + }, + "@react-native/codegen@0.83.1_@babel+core@7.28.6": { + "integrity": "sha512-FpRxenonwH+c2a5X5DZMKUD7sCudHxB3eSQPgV9R+uxd28QWslyAWrpnJM/Az96AEksHnymDzEmzq2HLX5nb+g==", + "dependencies": [ + "@babel/core", + "@babel/parser", + "glob", + "hermes-parser", + "invariant", + "nullthrows", + "yargs@17.7.2" + ] + }, + "@react-native/community-cli-plugin@0.83.1": { + "integrity": "sha512-FqR1ftydr08PYlRbrDF06eRiiiGOK/hNmz5husv19sK6iN5nHj1SMaCIVjkH/a5vryxEddyFhU6PzO/uf4kOHg==", + "dependencies": [ + "@react-native/dev-middleware", + "debug@4.4.3", + "invariant", + "metro", + "metro-config", + "metro-core", + "semver@7.7.3" + ] + }, + "@react-native/debugger-frontend@0.83.1": { + "integrity": "sha512-01Rn3goubFvPjHXONooLmsW0FLxJDKIUJNOlOS0cPtmmTIx9YIjxhe/DxwHXGk7OnULd7yl3aYy7WlBsEd5Xmg==" + }, + "@react-native/debugger-shell@0.83.1": { + "integrity": "sha512-d+0w446Hxth5OP/cBHSSxOEpbj13p2zToUy6e5e3tTERNJ8ueGlW7iGwGTrSymNDgXXFjErX+dY4P4/3WokPIQ==", + "dependencies": [ + "cross-spawn", + "fb-dotslash" + ] + }, + "@react-native/dev-middleware@0.83.1": { + "integrity": "sha512-QJaSfNRzj3Lp7MmlCRgSBlt1XZ38xaBNXypXAp/3H3OdFifnTZOeYOpFmcpjcXYnDqkxetuwZg8VL65SQhB8dg==", + "dependencies": [ + "@isaacs/ttlcache", + "@react-native/debugger-frontend", + "@react-native/debugger-shell", + "chrome-launcher", + "chromium-edge-launcher", + "connect", + "debug@4.4.3", + "invariant", + "nullthrows", + "open", + "serve-static", + "ws" + ] + }, + "@react-native/gradle-plugin@0.83.1": { + "integrity": "sha512-6ESDnwevp1CdvvxHNgXluil5OkqbjkJAkVy7SlpFsMGmVhrSxNAgD09SSRxMNdKsnLtzIvMsFCzyHLsU/S4PtQ==" + }, + "@react-native/js-polyfills@0.83.1": { + "integrity": "sha512-qgPpdWn/c5laA+3WoJ6Fak8uOm7CG50nBsLlPsF8kbT7rUHIVB9WaP6+GPsoKV/H15koW7jKuLRoNVT7c3Ht3w==" + }, + "@react-native/normalize-colors@0.83.1": { + "integrity": "sha512-84feABbmeWo1kg81726UOlMKAhcQyFXYz2SjRKYkS78QmfhVDhJ2o/ps1VjhFfBz0i/scDwT1XNv9GwmRIghkg==" + }, + "@react-native/virtualized-lists@0.83.1_react@19.2.4_react-native@0.83.1__react@19.2.4": { + "integrity": "sha512-MdmoAbQUTOdicCocm5XAFDJWsswxk7hxa6ALnm6Y88p01HFML0W593hAn6qOt9q6IM1KbAcebtH6oOd4gcQy8w==", + "dependencies": [ + "invariant", + "nullthrows", + "react", + "react-native" + ] + }, + "@sinclair/typebox@0.27.8": { + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" + }, + "@sinonjs/commons@3.0.1": { + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dependencies": [ + "type-detect" + ] + }, + "@sinonjs/fake-timers@10.3.0": { + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dependencies": [ + "@sinonjs/commons" + ] + }, + "@types/babel__core@7.20.5": { + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dependencies": [ + "@babel/parser", + "@babel/types", + "@types/babel__generator", + "@types/babel__template", + "@types/babel__traverse" + ] + }, + "@types/babel__generator@7.27.0": { + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dependencies": [ + "@babel/types" + ] + }, + "@types/babel__template@7.4.4": { + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dependencies": [ + "@babel/parser", + "@babel/types" + ] + }, + "@types/babel__traverse@7.28.0": { + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dependencies": [ + "@babel/types" + ] + }, + "@types/graceful-fs@4.1.9": { + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dependencies": [ + "@types/node" + ] + }, + "@types/ioredis-mock@8.2.6_ioredis@5.9.2": { + "integrity": "sha512-5heqtZMvQ4nXARY0o8rc8cjkJjct2ScM12yCJ/h731S9He93a2cv+kAhwPCNwTKDfNH9gjRfLG4VpAEYJU0/gQ==", + "dependencies": [ + "ioredis" + ] + }, + "@types/istanbul-lib-coverage@2.0.6": { + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==" + }, + "@types/istanbul-lib-report@3.0.3": { + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dependencies": [ + "@types/istanbul-lib-coverage" + ] + }, + "@types/istanbul-reports@3.0.4": { + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dependencies": [ + "@types/istanbul-lib-report" + ] + }, + "@types/node@25.0.10": { + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "dependencies": [ + "undici-types" + ] + }, + "@types/stack-utils@2.0.3": { + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" + }, + "@types/yargs-parser@21.0.3": { + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" + }, + "@types/yargs@17.0.35": { + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dependencies": [ + "@types/yargs-parser" + ] + }, + "abort-controller@3.0.0": { + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": [ + "event-target-shim" + ] + }, + "accepts@1.3.8": { + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": [ + "mime-types", + "negotiator" + ] + }, + "acorn@8.15.0": { + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "bin": true + }, + "agent-base@7.1.4": { + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, + "alasql@4.17.0": { + "integrity": "sha512-bU6tEjs0KVW3tesOBJBGE1Pl+LUt1epDSjb6yLo7GJZ4n9Gm+EjVgDl3HS+6rfCUARiNiWISTI1b7Zf/ljhKzA==", + "dependencies": [ + "cross-fetch", + "yargs@16.2.0" + ], + "optionalDependencies": [ + "react-native-fs" + ], + "bin": true + }, + "anser@1.4.10": { + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==" + }, + "ansi-regex@5.0.1": { + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles@4.3.0": { + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": [ + "color-convert" + ] + }, + "ansi-styles@5.2.0": { + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" + }, + "anymatch@3.1.3": { + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": [ + "normalize-path", + "picomatch" + ] + }, + "argparse@1.0.10": { + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": [ + "sprintf-js" + ] + }, + "asap@2.0.6": { + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" + }, + "aws-ssl-profiles@1.1.2": { + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==" + }, + "babel-jest@29.7.0_@babel+core@7.28.6": { + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dependencies": [ + "@babel/core", + "@jest/transform", + "@types/babel__core", + "babel-plugin-istanbul", + "babel-preset-jest", + "chalk", + "graceful-fs", + "slash" + ] + }, + "babel-plugin-istanbul@6.1.1": { + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dependencies": [ + "@babel/helper-plugin-utils", + "@istanbuljs/load-nyc-config", + "@istanbuljs/schema", + "istanbul-lib-instrument", + "test-exclude" + ] + }, + "babel-plugin-jest-hoist@29.6.3": { + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dependencies": [ + "@babel/template", + "@babel/types", + "@types/babel__core", + "@types/babel__traverse" + ] + }, + "babel-plugin-syntax-hermes-parser@0.32.0": { + "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", + "dependencies": [ + "hermes-parser" + ] + }, + "babel-preset-current-node-syntax@1.2.0_@babel+core@7.28.6": { + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dependencies": [ + "@babel/core", + "@babel/plugin-syntax-async-generators", + "@babel/plugin-syntax-bigint", + "@babel/plugin-syntax-class-properties", + "@babel/plugin-syntax-class-static-block", + "@babel/plugin-syntax-import-attributes", + "@babel/plugin-syntax-import-meta", + "@babel/plugin-syntax-json-strings", + "@babel/plugin-syntax-logical-assignment-operators", + "@babel/plugin-syntax-nullish-coalescing-operator", + "@babel/plugin-syntax-numeric-separator", + "@babel/plugin-syntax-object-rest-spread", + "@babel/plugin-syntax-optional-catch-binding", + "@babel/plugin-syntax-optional-chaining", + "@babel/plugin-syntax-private-property-in-object", + "@babel/plugin-syntax-top-level-await" + ] + }, + "babel-preset-jest@29.6.3_@babel+core@7.28.6": { + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dependencies": [ + "@babel/core", + "babel-plugin-jest-hoist", + "babel-preset-current-node-syntax" + ] + }, + "balanced-match@1.0.2": { + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "base-64@0.1.0": { + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" + }, + "base64-js@1.5.1": { + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "baseline-browser-mapping@2.9.18": { + "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", + "bin": true + }, + "brace-expansion@1.1.12": { + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dependencies": [ + "balanced-match", + "concat-map" + ] + }, + "braces@3.0.3": { + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": [ + "fill-range" + ] + }, + "browserslist@4.28.1": { + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dependencies": [ + "baseline-browser-mapping", + "caniuse-lite", + "electron-to-chromium", + "node-releases", + "update-browserslist-db" + ], + "bin": true + }, + "bser@2.1.1": { + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dependencies": [ + "node-int64" + ] + }, + "buffer-from@1.1.2": { + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "camelcase@5.3.1": { + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + }, + "camelcase@6.3.0": { + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" + }, + "caniuse-lite@1.0.30001766": { + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==" + }, + "chalk@4.1.2": { + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": [ + "ansi-styles@4.3.0", + "supports-color@7.2.0" + ] + }, + "chrome-launcher@0.15.2": { + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "dependencies": [ + "@types/node", + "escape-string-regexp@4.0.0", + "is-wsl", + "lighthouse-logger" + ], + "bin": true + }, + "chromium-edge-launcher@0.2.0": { + "integrity": "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==", + "dependencies": [ + "@types/node", + "escape-string-regexp@4.0.0", + "is-wsl", + "lighthouse-logger", + "mkdirp", + "rimraf" + ] + }, + "ci-info@2.0.0": { + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==" + }, + "ci-info@3.9.0": { + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==" + }, + "cliui@7.0.4": { + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dependencies": [ + "string-width", + "strip-ansi", + "wrap-ansi" + ] + }, + "cliui@8.0.1": { + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": [ + "string-width", + "strip-ansi", + "wrap-ansi" + ] + }, + "cluster-key-slot@1.1.0": { + "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==" + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "commander@12.1.0": { + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==" + }, + "commander@2.20.3": { + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "concat-map@0.0.1": { + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "connect@3.7.0": { + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dependencies": [ + "debug@2.6.9", + "finalhandler", + "parseurl", + "utils-merge" + ] + }, + "convert-source-map@2.0.0": { + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + }, + "cross-fetch@4.1.0": { + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "dependencies": [ + "node-fetch" + ] + }, + "cross-spawn@7.0.6": { + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": [ + "path-key", + "shebang-command", + "which" + ] + }, + "debug@2.6.9": { + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": [ + "ms@2.0.0" + ] + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms@2.1.3" + ] + }, + "denque@2.1.0": { + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==" + }, + "depd@2.0.0": { + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "destroy@1.2.0": { + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" + }, + "ee-first@1.1.1": { + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "electron-to-chromium@1.5.279": { + "integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==" + }, + "emoji-regex@8.0.0": { + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "encodeurl@1.0.2": { + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + }, + "encodeurl@2.0.0": { + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" + }, + "error-stack-parser@2.1.4": { + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dependencies": [ + "stackframe" + ] + }, + "esbuild-wasm@0.25.12": { + "integrity": "sha512-rZqkjL3Y6FwLpSHzLnaEy8Ps6veCNo1kZa9EOfJvmWtBq5dJH4iVjfmOO6Mlkv9B0tt9WFPFmb/VxlgJOnueNg==", + "bin": true + }, + "esbuild@0.25.7": { + "integrity": "sha512-daJB0q2dmTzo90L9NjRaohhRWrCzYxWNFTjEi72/h+p5DcY3yn4MacWfDakHmaBaDzDiuLJsCh0+6LK/iX+c+Q==", + "optionalDependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64", + "@esbuild/openharmony-arm64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ], + "scripts": true, + "bin": true + }, + "escalade@3.2.0": { + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + }, + "escape-html@1.0.3": { + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "escape-string-regexp@2.0.0": { + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" + }, + "escape-string-regexp@4.0.0": { + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, + "esprima@4.0.1": { + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": true + }, + "etag@1.8.1": { + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "event-target-shim@5.0.1": { + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, + "exponential-backoff@3.1.3": { + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==" + }, + "fast-json-stable-stringify@2.1.0": { + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "fb-dotslash@0.5.8": { + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "bin": true + }, + "fb-watchman@2.0.2": { + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dependencies": [ + "bser" + ] + }, + "fill-range@7.1.1": { + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": [ + "to-regex-range" + ] + }, + "finalhandler@1.1.2": { + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dependencies": [ + "debug@2.6.9", + "encodeurl@1.0.2", + "escape-html", + "on-finished@2.3.0", + "parseurl", + "statuses@1.5.0", + "unpipe" + ] + }, + "find-up@4.1.0": { + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": [ + "locate-path", + "path-exists" + ] + }, + "flow-enums-runtime@0.0.6": { + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==" + }, + "fresh@0.5.2": { + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + }, + "fs.realpath@1.0.0": { + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "os": ["darwin"], + "scripts": true + }, + "generate-function@2.3.1": { + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dependencies": [ + "is-property" + ] + }, + "gensync@1.0.0-beta.2": { + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" + }, + "get-caller-file@2.0.5": { + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "get-package-type@0.1.0": { + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==" + }, + "glob@7.2.3": { + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": [ + "fs.realpath", + "inflight", + "inherits", + "minimatch", + "once", + "path-is-absolute" + ], + "deprecated": true + }, + "graceful-fs@4.2.11": { + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "has-flag@4.0.0": { + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "hermes-compiler@0.14.0": { + "integrity": "sha512-clxa193o+GYYwykWVFfpHduCATz8fR5jvU7ngXpfKHj+E9hr9vjLNtdLSEe8MUbObvVexV3wcyxQ00xTPIrB1Q==" + }, + "hermes-estree@0.32.0": { + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==" + }, + "hermes-parser@0.32.0": { + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "dependencies": [ + "hermes-estree" + ] + }, + "http-errors@2.0.0": { + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": [ + "depd", + "inherits", + "setprototypeof", + "statuses@2.0.1", + "toidentifier" + ] + }, + "https-proxy-agent@7.0.6": { + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": [ + "agent-base", + "debug@4.4.3" + ] + }, + "iconv-lite@0.7.2": { + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dependencies": [ + "safer-buffer" + ] + }, + "image-size@1.2.1": { + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "dependencies": [ + "queue" + ], + "bin": true + }, + "imurmurhash@0.1.4": { + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" + }, + "inflight@1.0.6": { + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dependencies": [ + "once", + "wrappy" + ], + "deprecated": true + }, + "inherits@2.0.4": { + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "invariant@2.2.4": { + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": [ + "loose-envify" + ] + }, + "ioredis@5.9.2": { + "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "dependencies": [ + "@ioredis/commands", + "cluster-key-slot", + "debug@4.4.3", + "denque", + "lodash.defaults", + "lodash.isarguments", + "redis-errors", + "redis-parser", + "standard-as-callback" + ] + }, + "is-docker@2.2.1": { + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "bin": true + }, + "is-fullwidth-code-point@3.0.0": { + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "is-number@7.0.0": { + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "is-property@1.0.2": { + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, + "is-wsl@2.2.0": { + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dependencies": [ + "is-docker" + ] + }, + "isexe@2.0.0": { + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "istanbul-lib-coverage@3.2.2": { + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==" + }, + "istanbul-lib-instrument@5.2.1": { + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dependencies": [ + "@babel/core", + "@babel/parser", + "@istanbuljs/schema", + "istanbul-lib-coverage", + "semver@6.3.1" + ] + }, + "jest-environment-node@29.7.0": { + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dependencies": [ + "@jest/environment", + "@jest/fake-timers", + "@jest/types", + "@types/node", + "jest-mock", + "jest-util" + ] + }, + "jest-get-type@29.6.3": { + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==" + }, + "jest-haste-map@29.7.0": { + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dependencies": [ + "@jest/types", + "@types/graceful-fs", + "@types/node", + "anymatch", + "fb-watchman", + "graceful-fs", + "jest-regex-util", + "jest-util", + "jest-worker", + "micromatch", + "walker" + ], + "optionalDependencies": [ + "fsevents" + ] + }, + "jest-message-util@29.7.0": { + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dependencies": [ + "@babel/code-frame", + "@jest/types", + "@types/stack-utils", + "chalk", + "graceful-fs", + "micromatch", + "pretty-format", + "slash", + "stack-utils" + ] + }, + "jest-mock@29.7.0": { + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dependencies": [ + "@jest/types", + "@types/node", + "jest-util" + ] + }, + "jest-regex-util@29.6.3": { + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==" + }, + "jest-util@29.7.0": { + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dependencies": [ + "@jest/types", + "@types/node", + "chalk", + "ci-info@3.9.0", + "graceful-fs", + "picomatch" + ] + }, + "jest-validate@29.7.0": { + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dependencies": [ + "@jest/types", + "camelcase@6.3.0", + "chalk", + "jest-get-type", + "leven", + "pretty-format" + ] + }, + "jest-worker@29.7.0": { + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dependencies": [ + "@types/node", + "jest-util", + "merge-stream", + "supports-color@8.1.1" + ] + }, + "js-tokens@4.0.0": { + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml@3.14.2": { + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dependencies": [ + "argparse", + "esprima" + ], + "bin": true + }, + "jsc-safe-url@0.2.4": { + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==" + }, + "jsesc@3.1.0": { + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "bin": true + }, + "json5@2.2.3": { + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": true + }, + "leven@3.1.0": { + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" + }, + "lighthouse-logger@1.4.2": { + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "dependencies": [ + "debug@2.6.9", + "marky" + ] + }, + "locate-path@5.0.0": { + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": [ + "p-locate" + ] + }, + "lodash.defaults@4.2.0": { + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, + "lodash.isarguments@3.1.0": { + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, + "lodash.throttle@4.1.1": { + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" + }, + "long@5.3.2": { + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "loose-envify@1.4.0": { + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": [ + "js-tokens" + ], + "bin": true + }, + "lru-cache@5.1.1": { + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dependencies": [ + "yallist" + ] + }, + "lru.min@1.1.3": { + "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==" + }, + "makeerror@1.0.12": { + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dependencies": [ + "tmpl" + ] + }, + "marky@1.3.0": { + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==" + }, + "memoize-one@5.2.1": { + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, + "merge-stream@2.0.0": { + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + }, + "metro-babel-transformer@0.83.3": { + "integrity": "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g==", + "dependencies": [ + "@babel/core", + "flow-enums-runtime", + "hermes-parser", + "nullthrows" + ] + }, + "metro-cache-key@0.83.3": { + "integrity": "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw==", + "dependencies": [ + "flow-enums-runtime" + ] + }, + "metro-cache@0.83.3": { + "integrity": "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q==", + "dependencies": [ + "exponential-backoff", + "flow-enums-runtime", + "https-proxy-agent", + "metro-core" + ] + }, + "metro-config@0.83.3": { + "integrity": "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA==", + "dependencies": [ + "connect", + "flow-enums-runtime", + "jest-validate", + "metro", + "metro-cache", + "metro-core", + "metro-runtime", + "yaml" + ] + }, + "metro-core@0.83.3": { + "integrity": "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw==", + "dependencies": [ + "flow-enums-runtime", + "lodash.throttle", + "metro-resolver" + ] + }, + "metro-file-map@0.83.3": { + "integrity": "sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA==", + "dependencies": [ + "debug@4.4.3", + "fb-watchman", + "flow-enums-runtime", + "graceful-fs", + "invariant", + "jest-worker", + "micromatch", + "nullthrows", + "walker" + ] + }, + "metro-minify-terser@0.83.3": { + "integrity": "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ==", + "dependencies": [ + "flow-enums-runtime", + "terser" + ] + }, + "metro-resolver@0.83.3": { + "integrity": "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ==", + "dependencies": [ + "flow-enums-runtime" + ] + }, + "metro-runtime@0.83.3": { + "integrity": "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw==", + "dependencies": [ + "@babel/runtime", + "flow-enums-runtime" + ] + }, + "metro-source-map@0.83.3": { + "integrity": "sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg==", + "dependencies": [ + "@babel/traverse", + "@babel/traverse--for-generate-function-map@npm:@babel/traverse@7.28.6", + "@babel/types", + "flow-enums-runtime", + "invariant", + "metro-symbolicate", + "nullthrows", + "ob1", + "source-map@0.5.7", + "vlq" + ] + }, + "metro-symbolicate@0.83.3": { + "integrity": "sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw==", + "dependencies": [ + "flow-enums-runtime", + "invariant", + "metro-source-map", + "nullthrows", + "source-map@0.5.7", + "vlq" + ], + "bin": true + }, + "metro-transform-plugins@0.83.3": { + "integrity": "sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A==", + "dependencies": [ + "@babel/core", + "@babel/generator", + "@babel/template", + "@babel/traverse", + "flow-enums-runtime", + "nullthrows" + ] + }, + "metro-transform-worker@0.83.3": { + "integrity": "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA==", + "dependencies": [ + "@babel/core", + "@babel/generator", + "@babel/parser", + "@babel/types", + "flow-enums-runtime", + "metro", + "metro-babel-transformer", + "metro-cache", + "metro-cache-key", + "metro-minify-terser", + "metro-source-map", + "metro-transform-plugins", + "nullthrows" + ] + }, + "metro@0.83.3": { + "integrity": "sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q==", + "dependencies": [ + "@babel/code-frame", + "@babel/core", + "@babel/generator", + "@babel/parser", + "@babel/template", + "@babel/traverse", + "@babel/types", + "accepts", + "chalk", + "ci-info@2.0.0", + "connect", + "debug@4.4.3", + "error-stack-parser", + "flow-enums-runtime", + "graceful-fs", + "hermes-parser", + "image-size", + "invariant", + "jest-worker", + "jsc-safe-url", + "lodash.throttle", + "metro-babel-transformer", + "metro-cache", + "metro-cache-key", + "metro-config", + "metro-core", + "metro-file-map", + "metro-resolver", + "metro-runtime", + "metro-source-map", + "metro-symbolicate", + "metro-transform-plugins", + "metro-transform-worker", + "mime-types", + "nullthrows", + "serialize-error", + "source-map@0.5.7", + "throat", + "ws", + "yargs@17.7.2" + ], + "bin": true + }, + "micromatch@4.0.8": { + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": [ + "braces", + "picomatch" + ] + }, + "mime-db@1.52.0": { + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types@2.1.35": { + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": [ + "mime-db" + ] + }, + "mime@1.6.0": { + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": true + }, + "minimatch@3.1.2": { + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": [ + "brace-expansion" + ] + }, + "mkdirp@1.0.4": { + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": true + }, + "ms@2.0.0": { + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "mysql2@3.16.2": { + "integrity": "sha512-JsqBpYNy7pH20lGfPuSyRSIcCxSeAIwxWADpV64nP9KeyN3ZKpHZgjKXuBKsh7dH6FbOvf1bOgoVKjSUPXRMTw==", + "dependencies": [ + "aws-ssl-profiles", + "denque", + "generate-function", + "iconv-lite", + "long", + "lru.min", + "named-placeholders", + "seq-queue", + "sqlstring" + ] + }, + "named-placeholders@1.1.6": { + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "dependencies": [ + "lru.min" + ] + }, + "negotiator@0.6.3": { + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" + }, + "node-fetch@2.7.0": { + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": [ + "whatwg-url" + ] + }, + "node-int64@0.4.0": { + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==" + }, + "node-releases@2.0.27": { + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==" + }, + "normalize-path@3.0.0": { + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + }, + "nullthrows@1.1.1": { + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==" + }, + "ob1@0.83.3": { + "integrity": "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==", + "dependencies": [ + "flow-enums-runtime" + ] + }, + "on-finished@2.3.0": { + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dependencies": [ + "ee-first" + ] + }, + "on-finished@2.4.1": { + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": [ + "ee-first" + ] + }, + "once@1.4.0": { + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": [ + "wrappy" + ] + }, + "open@7.4.2": { + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dependencies": [ + "is-docker", + "is-wsl" + ] + }, + "p-limit@2.3.0": { + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": [ + "p-try" + ] + }, + "p-locate@4.1.0": { + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": [ + "p-limit" + ] + }, + "p-try@2.2.0": { + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" + }, + "parseurl@1.3.3": { + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-exists@4.0.0": { + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, + "path-is-absolute@1.0.1": { + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" + }, + "path-key@3.1.1": { + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "pg-cloudflare@1.3.0": { + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==" + }, + "pg-connection-string@2.10.1": { + "integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==" + }, + "pg-int8@1.0.1": { + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" + }, + "pg-pool@3.11.0_pg@8.17.2": { + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "dependencies": [ + "pg" + ] + }, + "pg-protocol@1.11.0": { + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==" + }, + "pg-types@2.2.0": { + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": [ + "pg-int8", + "postgres-array", + "postgres-bytea", + "postgres-date", + "postgres-interval" + ] + }, + "pg@8.17.2": { + "integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==", + "dependencies": [ + "pg-connection-string", + "pg-pool", + "pg-protocol", + "pg-types", + "pgpass" + ], + "optionalDependencies": [ + "pg-cloudflare" + ] + }, + "pgpass@1.0.5": { + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": [ + "split2" + ] + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch@2.3.1": { + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + }, + "pirates@4.0.7": { + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==" + }, + "postgres-array@2.0.0": { + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" + }, + "postgres-bytea@1.0.1": { + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==" + }, + "postgres-date@1.0.7": { + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" + }, + "postgres-interval@1.2.0": { + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": [ + "xtend" + ] + }, + "preact-render-to-string@6.6.5_preact@10.28.3": { + "integrity": "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==", + "dependencies": [ + "preact" + ] + }, + "preact@10.28.3": { + "integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==" + }, + "pretty-format@29.7.0": { + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dependencies": [ + "@jest/schemas", + "ansi-styles@5.2.0", + "react-is" + ] + }, + "promise@8.3.0": { + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "dependencies": [ + "asap" + ] + }, + "queue@6.0.2": { + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "dependencies": [ + "inherits" + ] + }, + "range-parser@1.2.1": { + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "react-devtools-core@6.1.5": { + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "dependencies": [ + "shell-quote", + "ws" + ] + }, + "react-is@18.3.1": { + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, + "react-native-fs@2.20.0_react-native@0.83.1__react@19.2.4": { + "integrity": "sha512-VkTBzs7fIDUiy/XajOSNk0XazFE9l+QlMAce7lGuebZcag5CnjszB+u4BdqzwaQOdcYb5wsJIsqq4kxInIRpJQ==", + "dependencies": [ + "base-64", + "react-native", + "utf8" + ] + }, + "react-native@0.83.1_react@19.2.4": { + "integrity": "sha512-mL1q5HPq5cWseVhWRLl+Fwvi5z1UO+3vGOpjr+sHFwcUletPRZ5Kv+d0tUfqHmvi73/53NjlQqX1Pyn4GguUfA==", + "dependencies": [ + "@jest/create-cache-key-function", + "@react-native/assets-registry", + "@react-native/codegen", + "@react-native/community-cli-plugin", + "@react-native/gradle-plugin", + "@react-native/js-polyfills", + "@react-native/normalize-colors", + "@react-native/virtualized-lists", + "abort-controller", + "anser", + "ansi-regex", + "babel-jest", + "babel-plugin-syntax-hermes-parser", + "base64-js", + "commander@12.1.0", + "flow-enums-runtime", + "glob", + "hermes-compiler", + "invariant", + "jest-environment-node", + "memoize-one", + "metro-runtime", + "metro-source-map", + "nullthrows", + "pretty-format", + "promise", + "react", + "react-devtools-core", + "react-refresh", + "regenerator-runtime", + "scheduler", + "semver@7.7.3", + "stacktrace-parser", + "whatwg-fetch", + "ws", + "yargs@17.7.2" + ], + "bin": true + }, + "react-refresh@0.14.2": { + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==" + }, + "react@19.2.4": { + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==" + }, + "redis-errors@1.2.0": { + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==" + }, + "redis-parser@3.0.0": { + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": [ + "redis-errors" + ] + }, + "regenerator-runtime@0.13.11": { + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, + "require-directory@2.1.1": { + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + }, + "resolve-from@5.0.0": { + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==" + }, + "rimraf@3.0.2": { + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dependencies": [ + "glob" + ], + "deprecated": true, + "bin": true + }, + "safer-buffer@2.1.2": { + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "scheduler@0.27.0": { + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "semver@6.3.1": { + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": true + }, + "semver@7.7.3": { + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "bin": true + }, + "send@0.19.0": { + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dependencies": [ + "debug@2.6.9", + "depd", + "destroy", + "encodeurl@1.0.2", + "escape-html", + "etag", + "fresh", + "http-errors", + "mime", + "ms@2.1.3", + "on-finished@2.4.1", + "range-parser", + "statuses@2.0.1" + ] + }, + "seq-queue@0.0.5": { + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "serialize-error@2.1.0": { + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==" + }, + "serve-static@1.16.2": { + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dependencies": [ + "encodeurl@2.0.0", + "escape-html", + "parseurl", + "send" + ] + }, + "setprototypeof@1.2.0": { + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "shebang-command@2.0.0": { + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": [ + "shebang-regex" + ] + }, + "shebang-regex@3.0.0": { + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "shell-quote@1.8.3": { + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==" + }, + "signal-exit@3.0.7": { + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "slash@3.0.0": { + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==" + }, + "source-map-support@0.5.21": { + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dependencies": [ + "buffer-from", + "source-map@0.6.1" + ] + }, + "source-map@0.5.7": { + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" + }, + "source-map@0.6.1": { + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "split2@4.2.0": { + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" + }, + "sprintf-js@1.0.3": { + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + }, + "sqlstring@2.3.3": { + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==" + }, + "stack-utils@2.0.6": { + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dependencies": [ + "escape-string-regexp@2.0.0" + ] + }, + "stackframe@1.3.4": { + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" + }, + "stacktrace-parser@0.1.11": { + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "dependencies": [ + "type-fest" + ] + }, + "standard-as-callback@2.1.0": { + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, + "statuses@1.5.0": { + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==" + }, + "statuses@2.0.1": { + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" + }, + "string-width@4.2.3": { + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": [ + "emoji-regex", + "is-fullwidth-code-point", + "strip-ansi" + ] + }, + "strip-ansi@6.0.1": { + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": [ + "ansi-regex" + ] + }, + "supports-color@7.2.0": { + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": [ + "has-flag" + ] + }, + "supports-color@8.1.1": { + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dependencies": [ + "has-flag" + ] + }, + "terser@5.46.0": { + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "dependencies": [ + "@jridgewell/source-map", + "acorn", + "commander@2.20.3", + "source-map-support" + ], + "bin": true + }, + "test-exclude@6.0.0": { + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dependencies": [ + "@istanbuljs/schema", + "glob", + "minimatch" + ] + }, + "throat@5.0.0": { + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==" + }, + "tmpl@1.0.5": { + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==" + }, + "to-regex-range@5.0.1": { + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": [ + "is-number" + ] + }, + "toidentifier@1.0.1": { + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, + "tr46@0.0.3": { + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "type-detect@4.0.8": { + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==" + }, + "type-fest@0.7.1": { + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==" + }, + "undici-types@7.16.0": { + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" + }, + "unpipe@1.0.0": { + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, + "update-browserslist-db@1.2.3_browserslist@4.28.1": { + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dependencies": [ + "browserslist", + "escalade", + "picocolors" + ], + "bin": true + }, + "utf8@3.0.0": { + "integrity": "sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==" + }, + "utils-merge@1.0.1": { + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + }, + "vlq@1.0.1": { + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==" + }, + "walker@1.0.8": { + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dependencies": [ + "makeerror" + ] + }, + "webidl-conversions@3.0.1": { + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-fetch@3.6.20": { + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" + }, + "whatwg-url@5.0.0": { + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + }, + "which@2.0.2": { + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": [ + "isexe" + ], + "bin": true + }, + "wrap-ansi@7.0.0": { + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": [ + "ansi-styles@4.3.0", + "string-width", + "strip-ansi" + ] + }, + "wrappy@1.0.2": { + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "write-file-atomic@4.0.2": { + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dependencies": [ + "imurmurhash", + "signal-exit" + ] + }, + "ws@7.5.10": { + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==" + }, + "xtend@4.0.2": { + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, + "y18n@5.0.8": { + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, + "yallist@3.1.1": { + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, + "yaml@2.8.2": { + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "bin": true + }, + "yargs-parser@20.2.9": { + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==" + }, + "yargs-parser@21.1.1": { + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + }, + "yargs@16.2.0": { + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dependencies": [ + "cliui@7.0.4", + "escalade", + "get-caller-file", + "require-directory", + "string-width", + "y18n", + "yargs-parser@20.2.9" + ] + }, + "yargs@17.7.2": { + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": [ + "cliui@8.0.1", + "escalade", + "get-caller-file", + "require-directory", + "string-width", + "y18n", + "yargs-parser@21.1.1" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@db/redis@0.40", + "jsr:@fresh/core@^2.2.0", + "jsr:@std/assert@1", + "jsr:@std/http@1", + "npm:mysql2@^3.16.2", + "npm:pg@^8.11.5", + "npm:preact@^10.28.3" + ] + } +} diff --git a/example/use_kv_store/Dockerfile b/example/use_kv_store/Dockerfile deleted file mode 100644 index 744e513..0000000 --- a/example/use_kv_store/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM denoland/deno:1.35.1 - -RUN apt-get update - -RUN mkdir /usr/src/app -WORKDIR /usr/src/app - - -EXPOSE 8080 \ No newline at end of file diff --git a/example/use_kv_store/README.md b/example/use_kv_store/README.md deleted file mode 100644 index edfb416..0000000 --- a/example/use_kv_store/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Use KV store sample - -This project is fresh project used fresh-session with a KV store - -## Usage - -```sh -$ docker compose build - -$ docker compose up -d - -$ docker compose exec app deno task start - -# Please access with brawser to http://localhost:8000. -``` diff --git a/example/use_kv_store/deno.json b/example/use_kv_store/deno.json deleted file mode 100644 index 4556ddb..0000000 --- a/example/use_kv_store/deno.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "tasks": { - "start": "deno run -A --unstable --watch=static/,routes/ dev.ts" - }, - "compilerOptions": { - "jsx": "react-jsx", - "jsxImportSource": "preact" - }, - "imports": { - "$fresh/": "https://deno.land/x/fresh@1.3.1/", - "preact": "https://esm.sh/preact@10.15.1", - "preact/": "https://esm.sh/preact@10.15.1/", - "preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.1.0", - "@preact/signals": "https://esm.sh/*@preact/signals@1.0.3", - "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.0.1", - "twind": "https://esm.sh/twind@0.16.17", - "twind/": "https://esm.sh/twind@0.16.17/", - "fresh-session/": "https://deno.land/x/fresh_session@0.2.2/" - }, - "lock": false -} diff --git a/example/use_kv_store/dev.ts b/example/use_kv_store/dev.ts deleted file mode 100644 index 2d85d6c..0000000 --- a/example/use_kv_store/dev.ts +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env -S deno run -A --watch=static/,routes/ - -import dev from "$fresh/dev.ts"; - -await dev(import.meta.url, "./main.ts"); diff --git a/example/use_kv_store/docker-compose.yml b/example/use_kv_store/docker-compose.yml deleted file mode 100644 index b364993..0000000 --- a/example/use_kv_store/docker-compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -version: "3" -services: - app: - build: - context: . - dockerfile: Dockerfile - privileged: true - command: tail -f /dev/null - ports: - - "8000:8000" - - "35729:35729" - volumes: - - .:/usr/src/app:cached - tty: true diff --git a/example/use_kv_store/fresh.gen.ts b/example/use_kv_store/fresh.gen.ts deleted file mode 100644 index c89a085..0000000 --- a/example/use_kv_store/fresh.gen.ts +++ /dev/null @@ -1,17 +0,0 @@ -// DO NOT EDIT. This file is generated by fresh. -// This file SHOULD be checked into source version control. -// This file is automatically updated during development when running `dev.ts`. - -import * as $0 from "./routes/_middleware.ts"; -import * as $1 from "./routes/index.tsx"; - -const manifest = { - routes: { - "./routes/_middleware.ts": $0, - "./routes/index.tsx": $1, - }, - islands: {}, - baseUrl: import.meta.url, -}; - -export default manifest; diff --git a/example/use_kv_store/main.ts b/example/use_kv_store/main.ts deleted file mode 100644 index 59697cc..0000000 --- a/example/use_kv_store/main.ts +++ /dev/null @@ -1,15 +0,0 @@ -/// -/// -/// -/// -/// - -export const PORT = 8000; - -import { start } from "$fresh/server.ts"; -import manifest from "./fresh.gen.ts"; - -import twindPlugin from "$fresh/plugins/twind.ts"; -import twindConfig from "./twind.config.ts"; - -await start(manifest, { plugins: [twindPlugin(twindConfig)], port: PORT }); diff --git a/example/use_kv_store/routes/_middleware.ts b/example/use_kv_store/routes/_middleware.ts deleted file mode 100644 index 9acb80c..0000000 --- a/example/use_kv_store/routes/_middleware.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { MiddlewareHandlerContext } from "$fresh/server.ts"; -import { kvSession, WithSession } from "fresh-session/mod.ts"; -import { PORT } from "../main.ts"; -export type State = WithSession; - -async function sessionHundler( - req: Request, - ctx: MiddlewareHandlerContext, -) { - const session = kvSession(null, { - maxAge: 10, - httpOnly: true, - }); - - if (req.url === `http://localhost:${ctx.localAddr?.port}/`) { - return session(req, ctx); - } - if (req.url === `http://localhost:${PORT}/`) { - return session(req, ctx); - } - return ctx.next(); -} -export const handler = [sessionHundler]; diff --git a/example/use_kv_store/routes/index.tsx b/example/use_kv_store/routes/index.tsx deleted file mode 100644 index 9eb96be..0000000 --- a/example/use_kv_store/routes/index.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts"; -import { Head } from "$fresh/runtime.ts"; -import { WithSession } from "fresh-session/mod.ts"; -export type SessionData = { session: Record; message?: string }; - -export const handler: Handlers = { - GET(_req: Request, ctx: HandlerContext) { - const { session } = ctx.state; - // console.log(session); - const message = session.flash("message"); - - return ctx.render({ - session: session.data, - message, - }); - }, - async POST(req: Request, ctx: HandlerContext) { - const { session } = ctx.state; - const form = await req.formData(); - - if ( - typeof form.get("method") === "string" && - form.get("method") === "DELETE" - ) { - session.clear(); - session.flash("message", "Delete value!"); - } else { - const text = form.get("new_session_text_value"); - session.set("text", text); - session.flash("message", "Session value update!"); - if (form.get("session_key_rotate")) { - session.keyRotate(); - } - } - - return new Response("", { - status: 303, - headers: { Location: "/" }, - }); - }, -}; - -export default function Index({ data }: PageProps) { - return ( - <> - - frash-session example[denoKV in use] - -
- {/*
{JSON.stringify(data, null, 2)}
*/} -
Flash Message: {data.message}
-
Now Session Value: {data.session.text}
-
-
-
- -
-
- - -
-
- -
-
-
- - -
-
-
- - ); -} diff --git a/example/use_kv_store/static/favicon.ico b/example/use_kv_store/static/favicon.ico deleted file mode 100644 index 1cfaaa2..0000000 Binary files a/example/use_kv_store/static/favicon.ico and /dev/null differ diff --git a/example/use_kv_store/static/logo.svg b/example/use_kv_store/static/logo.svg deleted file mode 100644 index ef2fbe4..0000000 --- a/example/use_kv_store/static/logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/example/use_kv_store/twind.config.ts b/example/use_kv_store/twind.config.ts deleted file mode 100644 index 2a7ac27..0000000 --- a/example/use_kv_store/twind.config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Options } from "$fresh/plugins/twind.ts"; - -export default { - selfURL: import.meta.url, -} as Options; diff --git a/example/use_redis_store/Dockerfile b/example/use_redis_store/Dockerfile deleted file mode 100644 index 1a31c89..0000000 --- a/example/use_redis_store/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM denoland/deno:1.35.2 - -RUN apt-get update -RUN mkdir /usr/src/app -WORKDIR /usr/src/app - -EXPOSE 8000 \ No newline at end of file diff --git a/example/use_redis_store/README.md b/example/use_redis_store/README.md deleted file mode 100644 index d44d5c1..0000000 --- a/example/use_redis_store/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Use Redis store sample - -This project is fresh project used fresh-session with redis store. - -## Usage - -```sh -$ docker compose build - -$ docker compose up -d - -$ docker compose exec app deno task start - -# Please access with brawser to http://localhost:8000. -``` diff --git a/example/use_redis_store/components/.keep b/example/use_redis_store/components/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/example/use_redis_store/deno.json b/example/use_redis_store/deno.json deleted file mode 100644 index a0dd345..0000000 --- a/example/use_redis_store/deno.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "tasks": { - "start": "deno run -A --watch=static/,routes/ dev.ts" - }, - "compilerOptions": { - "jsx": "react-jsx", - "jsxImportSource": "preact" - }, - "imports": { - "$fresh/": "https://deno.land/x/fresh@1.3.1/", - "preact": "https://esm.sh/preact@10.15.1", - "preact/": "https://esm.sh/preact@10.15.1/", - "preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.1.0", - "@preact/signals": "https://esm.sh/*@preact/signals@1.0.3", - "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.0.1", - "redis/": "https://deno.land/x/redis@v0.25.0/", - "fresh-session/": "https://deno.land/x/fresh_session@0.2.0/" - }, - "lock": false -} diff --git a/example/use_redis_store/dev.ts b/example/use_redis_store/dev.ts deleted file mode 100644 index 2d85d6c..0000000 --- a/example/use_redis_store/dev.ts +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env -S deno run -A --watch=static/,routes/ - -import dev from "$fresh/dev.ts"; - -await dev(import.meta.url, "./main.ts"); diff --git a/example/use_redis_store/docker-compose.yml b/example/use_redis_store/docker-compose.yml deleted file mode 100644 index 0b393e2..0000000 --- a/example/use_redis_store/docker-compose.yml +++ /dev/null @@ -1,23 +0,0 @@ -version: "3" -services: - app: - build: - context: . - dockerfile: Dockerfile - privileged: true - command: tail -f /dev/null - ports: - - "8000:8000" - - "35729:35729" - volumes: - - .:/usr/src/app:cached - tty: true - redis: - image: "redis:latest" - ports: - - "6379:6379" - volumes: - - "redis-data:/data" - -volumes: - redis-data: \ No newline at end of file diff --git a/example/use_redis_store/fresh.gen.ts b/example/use_redis_store/fresh.gen.ts deleted file mode 100644 index c89a085..0000000 --- a/example/use_redis_store/fresh.gen.ts +++ /dev/null @@ -1,17 +0,0 @@ -// DO NOT EDIT. This file is generated by fresh. -// This file SHOULD be checked into source version control. -// This file is automatically updated during development when running `dev.ts`. - -import * as $0 from "./routes/_middleware.ts"; -import * as $1 from "./routes/index.tsx"; - -const manifest = { - routes: { - "./routes/_middleware.ts": $0, - "./routes/index.tsx": $1, - }, - islands: {}, - baseUrl: import.meta.url, -}; - -export default manifest; diff --git a/example/use_redis_store/islands/.keep b/example/use_redis_store/islands/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/example/use_redis_store/main.ts b/example/use_redis_store/main.ts deleted file mode 100644 index 4e5c707..0000000 --- a/example/use_redis_store/main.ts +++ /dev/null @@ -1,12 +0,0 @@ -/// -/// -/// -/// -/// - -export const PORT = 8000; - -import { start } from "$fresh/server.ts"; -import manifest from "./fresh.gen.ts"; - -await start(manifest, { port: PORT }); diff --git a/example/use_redis_store/routes/_middleware.ts b/example/use_redis_store/routes/_middleware.ts deleted file mode 100644 index 7dc2f6b..0000000 --- a/example/use_redis_store/routes/_middleware.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { MiddlewareHandlerContext } from "$fresh/server.ts"; -import { redisSession, WithSession } from "fresh-session/mod.ts"; -import { connect } from "redis/mod.ts"; -import { PORT } from "../main.ts"; -export type State = WithSession; - -const redis = await connect({ - hostname: "redis", - port: 6379, -}); - -const session = redisSession(redis, { - maxAge: 10, - httpOnly: true, -}); - -function sessionHundler(req: Request, ctx: MiddlewareHandlerContext) { - if (req.url === `http://localhost:${ctx.localAddr?.port}/`) { - return session(req, ctx); - } - if (req.url === `http://localhost:${PORT}/`) { - return session(req, ctx); - } - return ctx.next(); -} -export const handler = [sessionHundler]; diff --git a/example/use_redis_store/routes/index.tsx b/example/use_redis_store/routes/index.tsx deleted file mode 100644 index 0158846..0000000 --- a/example/use_redis_store/routes/index.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts"; -import { Head } from "$fresh/runtime.ts"; -import { WithSession } from "fresh-session/mod.ts"; -export type SessionData = { session: Record; message?: string }; - -export const handler: Handlers = { - GET(_req: Request, ctx: HandlerContext) { - const { session } = ctx.state; - const message = session.flash("message"); - - return ctx.render({ - session: session.data, - message, - }); - }, - async POST(req: Request, ctx: HandlerContext) { - const { session } = ctx.state; - const form = await req.formData(); - - if ( - typeof form.get("method") === "string" && - form.get("method") === "DELETE" - ) { - session.clear(); - session.flash("message", "Delete value!"); - } else { - const text = form.get("new_session_text_value"); - session.set("text", text); - session.flash("message", "Session value update!"); - if (form.get("session_key_rotate")) { - session.keyRotate(); - } - } - - return new Response("", { - status: 303, - headers: { Location: "/" }, - }); - }, -}; - -export default function Index({ data }: PageProps) { - return ( - <> - - frash-session example[redis in use] - -
-
Flash Message: {data.message}
-
Now Session Value: {data.session.text}
-
-
-
- -
-
- - -
-
- -
-
-
- - -
-
-
- - ); -} diff --git a/example/use_redis_store/static/favicon.ico b/example/use_redis_store/static/favicon.ico deleted file mode 100644 index 1cfaaa2..0000000 Binary files a/example/use_redis_store/static/favicon.ico and /dev/null differ diff --git a/example/use_redis_store/static/logo.svg b/example/use_redis_store/static/logo.svg deleted file mode 100644 index ef2fbe4..0000000 --- a/example/use_redis_store/static/logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/mod.ts b/mod.ts index a1ca67a..60b16f5 100644 --- a/mod.ts +++ b/mod.ts @@ -1 +1,8 @@ -export * from "./src/mod.ts"; +export * from "./src/session.ts"; +export * from "./src/types.ts"; +export * from "./src/config.ts"; +export * from "./src/storage/memory.ts"; +export * from "./src/storage/cookie.ts"; +export * from "./src/storage/kv.ts"; +export * from "./src/storage/redis.ts"; +export * from "./src/storage/sql.ts"; diff --git a/mod_test.ts b/mod_test.ts new file mode 100644 index 0000000..0050c40 --- /dev/null +++ b/mod_test.ts @@ -0,0 +1,697 @@ +import { App } from "@fresh/core"; +import { assertEquals } from "@std/assert"; +import { + CookieSessionStore, + KvSessionStore, + MemorySessionStore, + RedisSessionStore, + session, + type SessionState, + SqlSessionStore, +} from "./mod.ts"; +import { createRedisClient } from "./src/storage/redis_test.ts"; +import mysql from "mysql2/promise"; + +type State = Record & SessionState; + +function extractSessionCookie( + response: Response, + cookieName = "fresh_session", +): string | null { + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) return null; + + const match = setCookie.match(new RegExp(`${cookieName}=([^;]+)`)); + return match ? match[1] : null; +} + +// Test secret key (32+ characters required) +const TEST_SECRET = "this-is-a-test-secret-key-32chars!"; + +const MYSQL_HOST = Deno.env.get("MYSQL_HOST") ?? "127.0.0.1"; +const MYSQL_PORT = Number(Deno.env.get("MYSQL_PORT") ?? "3307"); +const MYSQL_USER = Deno.env.get("MYSQL_USER") ?? "root"; +const MYSQL_PASSWORD = Deno.env.get("MYSQL_PASSWORD") ?? "root"; +const MYSQL_DATABASE = Deno.env.get("MYSQL_DATABASE") ?? "fresh_session"; +const MYSQL_TABLE = Deno.env.get("MYSQL_TABLE") ?? "sessions_mod_test"; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function uniqueTableName(prefix: string): string { + const suffix = crypto.randomUUID().replace(/-/g, "").slice(0, 8); + return `${prefix}_${suffix}`; +} + +type MysqlExecutor = { + query: (sql: string, params?: unknown[]) => Promise<[unknown, unknown]>; + end: () => Promise; +}; + +type MysqlPool = MysqlExecutor; + +async function ensureTable( + executor: MysqlExecutor, + tableName: string, +): Promise { + await executor.query( + ` + CREATE TABLE IF NOT EXISTS ${tableName} ( + session_id VARCHAR(36) PRIMARY KEY, + data TEXT NOT NULL, + expires_at DATETIME NULL + ); + `, + ); + + try { + await executor.query( + `CREATE INDEX IF NOT EXISTS idx_${tableName}_expires_at ON ${tableName}(expires_at);`, + ); + } catch { + // Ignore if the index already exists or IF NOT EXISTS is unsupported + } +} + +async function clearTable( + executor: MysqlExecutor, + tableName: string, +): Promise { + await executor.query(`DELETE FROM ${tableName}`); +} + +async function withSqlStore( + fn: (store: SqlSessionStore) => Promise, +): Promise { + let pool: MysqlPool | undefined; + const maxAttempts = 30; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const candidate = mysql.createPool({ + host: MYSQL_HOST, + port: MYSQL_PORT, + user: MYSQL_USER, + password: MYSQL_PASSWORD, + database: MYSQL_DATABASE, + }) as unknown as MysqlPool; + + try { + await candidate.query("SELECT 1"); + pool = candidate; + break; + } catch { + await candidate.end().catch(() => {}); + await delay(1000); + } + } + + if (!pool) { + throw new Error("Failed to connect to MySQL after multiple attempts."); + } + + const tableName = uniqueTableName(MYSQL_TABLE); + await ensureTable(pool, tableName); + await clearTable(pool, tableName); + + const sqlClient = { + execute: async (sql: string, params: unknown[] = []) => { + const [rows] = await pool.query(sql, params); + return { + rows: Array.isArray(rows) ? (rows as Record[]) : [], + }; + }, + }; + + const sqlStore = new SqlSessionStore({ client: sqlClient, tableName }); + + try { + return await fn(sqlStore); + } finally { + await pool.end(); + } +} + +Deno.test("use cookie store", async () => { + const store = new CookieSessionStore(); + const sessionSaveRes = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/", (ctx) => { + ctx.state.session.set("userId", "user123"); + return new Response("Hello, World!"); + }).handler(); + const req = new Request("http://localhost"); + const res = await handler(req); + + assertEquals(await res.text(), "Hello, World!"); + + return res; + })(); + + const sessionCookie = extractSessionCookie(sessionSaveRes); + + { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/get", (ctx) => { + const userId = ctx.state.session.get("userId") as string | undefined; + return new Response(userId ?? "No User"); + }) + .handler(); + + const req = new Request("http://localhost/get", { + headers: { + "Cookie": `fresh_session=${sessionCookie}`, + }, + }); + const res = await handler(req); + + assertEquals(await res.text(), "user123"); + } +}); + +Deno.test("use kv store", async () => { + const kv = await Deno.openKv(":memory:"); + const store = new KvSessionStore({ kv }); + + const sessionSaveRes = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/", (ctx) => { + ctx.state.session.set("userId", "user123"); + return new Response("Hello, World!"); + }).handler(); + const req = new Request("http://localhost"); + const res = await handler(req); + + assertEquals(await res.text(), "Hello, World!"); + + return res; + })(); + + const sessionCookie = extractSessionCookie(sessionSaveRes); + + { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/get", (ctx) => { + const userId = ctx.state.session.get("userId") as string | undefined; + return new Response(userId ?? "No User"); + }) + .handler(); + + const req = new Request("http://localhost/get", { + headers: { + "Cookie": `fresh_session=${sessionCookie}`, + }, + }); + const res = await handler(req); + + assertEquals(await res.text(), "user123"); + } + + kv.close(); +}); + +Deno.test("use redis store", async () => { + const redisClient = await createRedisClient(); + + const store = new RedisSessionStore({ client: redisClient }); + + const sessionSaveRes = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/", (ctx) => { + ctx.state.session.set("userId", "user123"); + return new Response("Hello, World!"); + }).handler(); + const req = new Request("http://localhost"); + const res = await handler(req); + + assertEquals(await res.text(), "Hello, World!"); + + return res; + })(); + + const sessionCookie = extractSessionCookie(sessionSaveRes); + + { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/get", (ctx) => { + const userId = ctx.state.session.get("userId") as string | undefined; + return new Response(userId ?? "No User"); + }) + .handler(); + + const req = new Request("http://localhost/get", { + headers: { + "Cookie": `fresh_session=${sessionCookie}`, + }, + }); + const res = await handler(req); + + assertEquals(await res.text(), "user123"); + } + await redisClient.flushall(); + await redisClient.close(); +}); + +Deno.test({ + name: "use sql store (mysql)", + sanitizeResources: false, + sanitizeOps: false, + fn: async () => { + await withSqlStore(async (sqlStore) => { + const sessionSaveRes = await (async () => { + const handler = new App() + .use(session(sqlStore, TEST_SECRET)) + .get("/", (ctx) => { + ctx.state.session.set("userId", "user123"); + return new Response("Hello, World!"); + }).handler(); + const req = new Request("http://localhost"); + const res = await handler(req); + + assertEquals(await res.text(), "Hello, World!"); + + return res; + })(); + + const sessionCookie = extractSessionCookie(sessionSaveRes); + + { + const handler = new App() + .use(session(sqlStore, TEST_SECRET)) + .get("/get", (ctx) => { + const userId = ctx.state.session.get("userId") as + | string + | undefined; + return new Response(userId ?? "No User"); + }) + .handler(); + + const req = new Request("http://localhost/get", { + headers: { + "Cookie": `fresh_session=${sessionCookie}`, + }, + }); + const res = await handler(req); + + assertEquals(await res.text(), "user123"); + } + }); + }, +}); + +Deno.test({ + name: "flash message: set and get on next request", + sanitizeResources: false, + sanitizeOps: false, + fn: async () => { + const store = new MemorySessionStore(); + + // First request: Set flash message + const firstResponse = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/set-flash", (ctx) => { + ctx.state.session.flash.set("message", "Hello from flash!"); + ctx.state.session.flash.set("type", "success"); + return new Response("Flash set"); + }).handler(); + const req = new Request("http://localhost/set-flash"); + return await handler(req); + })(); + + const sessionCookie = extractSessionCookie(firstResponse); + assertEquals(await firstResponse.text(), "Flash set"); + + // Second request: Flash should be available + const secondResponse = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/get-flash", (ctx) => { + const message = ctx.state.session.flash.get("message") as + | string + | undefined; + const type = ctx.state.session.flash.get("type") as + | string + | undefined; + const hasMessage = ctx.state.session.flash.has("message"); + return new Response(JSON.stringify({ message, type, hasMessage })); + }).handler(); + + const req = new Request("http://localhost/get-flash", { + headers: { + "Cookie": `fresh_session=${sessionCookie}`, + }, + }); + return await handler(req); + })(); + + const secondCookie = extractSessionCookie(secondResponse); + const secondData = await secondResponse.json(); + assertEquals(secondData.message, "Hello from flash!"); + assertEquals(secondData.type, "success"); + assertEquals(secondData.hasMessage, true); + + // Third request: Flash should be gone + const thirdResponse = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/get-flash-again", (ctx) => { + const message = ctx.state.session.flash.get("message") as + | string + | undefined; + const hasMessage = ctx.state.session.flash.has("message"); + return new Response(JSON.stringify({ message, hasMessage })); + }).handler(); + + const req = new Request("http://localhost/get-flash-again", { + headers: { + "Cookie": `fresh_session=${secondCookie}`, + }, + }); + return await handler(req); + })(); + + const thirdData = await thirdResponse.json(); + assertEquals(thirdData.message, undefined); + assertEquals(thirdData.hasMessage, false); + }, +}); + +Deno.test("flash message: does not affect regular session data", async () => { + const store = new MemorySessionStore(); + + // First request: Set both regular data and flash + const firstResponse = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/set", (ctx) => { + ctx.state.session.set("persistent", "I stay"); + ctx.state.session.flash.set("temporary", "I go away"); + return new Response("Data set"); + }).handler(); + const req = new Request("http://localhost/set"); + return await handler(req); + })(); + + const sessionCookie = extractSessionCookie(firstResponse); + + // Second request: Both should be available, read flash + const secondResponse = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/get", (ctx) => { + const persistent = ctx.state.session.get("persistent") as + | string + | undefined; + const temporary = ctx.state.session.flash.get("temporary") as + | string + | undefined; + return new Response(JSON.stringify({ persistent, temporary })); + }).handler(); + + const req = new Request("http://localhost/get", { + headers: { + "Cookie": `fresh_session=${sessionCookie}`, + }, + }); + return await handler(req); + })(); + + const secondCookie = extractSessionCookie(secondResponse); + const secondData = await secondResponse.json(); + assertEquals(secondData.persistent, "I stay"); + assertEquals(secondData.temporary, "I go away"); + + // Third request: Regular data persists, flash is gone + const thirdResponse = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/get-again", (ctx) => { + const persistent = ctx.state.session.get("persistent") as + | string + | undefined; + const temporary = ctx.state.session.flash.get("temporary") as + | string + | undefined; + return new Response(JSON.stringify({ persistent, temporary })); + }).handler(); + + const req = new Request("http://localhost/get-again", { + headers: { + "Cookie": `fresh_session=${secondCookie}`, + }, + }); + return await handler(req); + })(); + + const thirdData = await thirdResponse.json(); + assertEquals(thirdData.persistent, "I stay"); + assertEquals(thirdData.temporary, undefined); +}); + +Deno.test("flash message: complex data types", async () => { + const store = new MemorySessionStore(); + + const flashData = { + errors: ["Field is required", "Invalid format"], + form: { name: "John", email: "john@example.com" }, + count: 42, + }; + + // Set flash with complex data + const firstResponse = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/set", (ctx) => { + ctx.state.session.flash.set("formErrors", flashData); + return new Response("OK"); + }).handler(); + const req = new Request("http://localhost/set"); + return await handler(req); + })(); + + const sessionCookie = extractSessionCookie(firstResponse); + + // Get flash data + const secondResponse = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/get", (ctx) => { + const formErrors = ctx.state.session.flash.get( + "formErrors", + ) as Record< + string, + unknown + >; + return new Response(JSON.stringify(formErrors)); + }).handler(); + + const req = new Request("http://localhost/get", { + headers: { + "Cookie": `fresh_session=${sessionCookie}`, + }, + }); + return await handler(req); + })(); + + const receivedData = await secondResponse.json(); + assertEquals(receivedData, flashData); +}); + +// Tests for sessionExpires configuration + +/** + * Mock store that captures expiresAt parameter + */ +class ExpiresCapturingStore extends MemorySessionStore { + public capturedExpiresAt: Date | undefined; + + override async save( + sessionId: string, + data: Record, + expiresAt?: Date, + ): Promise { + this.capturedExpiresAt = expiresAt; + return await super.save(sessionId, data, expiresAt); + } +} + +Deno.test("sessionExpires: default expiration is passed to store", async () => { + const store = new ExpiresCapturingStore(); + + const handler = new App() + .use(session(store, TEST_SECRET)) + .get("/", (ctx) => { + ctx.state.session.set("test", "value"); + return new Response("OK"); + }).handler(); + + const req = new Request("http://localhost"); + await handler(req); + + // Default sessionExpires is 1 day (86400000 ms) + const oneDayMs = 1000 * 60 * 60 * 24; + const expectedMinExpires = Date.now() + oneDayMs - 1000; // Allow 1 second tolerance + const expectedMaxExpires = Date.now() + oneDayMs + 1000; + + if (store.capturedExpiresAt) { + const expiresTime = store.capturedExpiresAt.getTime(); + assertEquals( + expiresTime >= expectedMinExpires && expiresTime <= expectedMaxExpires, + true, + `Expected expiresAt around ${ + new Date(Date.now() + oneDayMs).toISOString() + }, got ${store.capturedExpiresAt.toISOString()}`, + ); + } else { + throw new Error("expiresAt was not passed to store"); + } +}); + +Deno.test("sessionExpires: custom expiration time is respected", async () => { + const store = new ExpiresCapturingStore(); + const customExpires = 60 * 60 * 1000; // 1 hour + + const handler = new App() + .use(session(store, TEST_SECRET, { sessionExpires: customExpires })) + .get("/", (ctx) => { + ctx.state.session.set("test", "value"); + return new Response("OK"); + }).handler(); + + const req = new Request("http://localhost"); + await handler(req); + + const expectedMinExpires = Date.now() + customExpires - 1000; + const expectedMaxExpires = Date.now() + customExpires + 1000; + + if (store.capturedExpiresAt) { + const expiresTime = store.capturedExpiresAt.getTime(); + assertEquals( + expiresTime >= expectedMinExpires && expiresTime <= expectedMaxExpires, + true, + `Expected expiresAt around ${ + new Date(Date.now() + customExpires).toISOString() + }, got ${store.capturedExpiresAt.toISOString()}`, + ); + } else { + throw new Error("expiresAt was not passed to store"); + } +}); + +Deno.test("sessionExpires: short expiration for quick expiry", async () => { + const store = new ExpiresCapturingStore(); + const shortExpires = 5000; // 5 seconds + + const handler = new App() + .use(session(store, TEST_SECRET, { sessionExpires: shortExpires })) + .get("/", (ctx) => { + ctx.state.session.set("test", "value"); + return new Response("OK"); + }).handler(); + + const req = new Request("http://localhost"); + await handler(req); + + const expectedMinExpires = Date.now() + shortExpires - 1000; + const expectedMaxExpires = Date.now() + shortExpires + 1000; + + if (store.capturedExpiresAt) { + const expiresTime = store.capturedExpiresAt.getTime(); + assertEquals( + expiresTime >= expectedMinExpires && expiresTime <= expectedMaxExpires, + true, + `Expected expiresAt around ${ + new Date(Date.now() + shortExpires).toISOString() + }, got ${store.capturedExpiresAt.toISOString()}`, + ); + } else { + throw new Error("expiresAt was not passed to store"); + } +}); + +Deno.test("sessionExpires: session data expires after configured time", async () => { + const store = new MemorySessionStore(); + const shortExpires = 100; // 100ms for quick test + + // Create session + const firstResponse = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET, { sessionExpires: shortExpires })) + .get("/set", (ctx) => { + ctx.state.session.set("data", "test-value"); + return new Response("OK"); + }).handler(); + const req = new Request("http://localhost/set"); + return await handler(req); + })(); + + const sessionCookie = extractSessionCookie(firstResponse); + + // Wait for session to expire + await new Promise((resolve) => setTimeout(resolve, 150)); + + // Try to read expired session + const secondResponse = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET, { sessionExpires: shortExpires })) + .get("/get", (ctx) => { + const data = ctx.state.session.get("data"); + return new Response(data ? String(data) : "no-data"); + }).handler(); + + const req = new Request("http://localhost/get", { + headers: { + "Cookie": `fresh_session=${sessionCookie}`, + }, + }); + return await handler(req); + })(); + + const text = await secondResponse.text(); + assertEquals(text, "no-data", "Session should have expired"); +}); + +Deno.test("sessionExpires: session data persists before expiration", async () => { + const store = new MemorySessionStore(); + const longExpires = 60000; // 1 minute + + // Create session + const firstResponse = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET, { sessionExpires: longExpires })) + .get("/set", (ctx) => { + ctx.state.session.set("data", "persisted-value"); + return new Response("OK"); + }).handler(); + const req = new Request("http://localhost/set"); + return await handler(req); + })(); + + const sessionCookie = extractSessionCookie(firstResponse); + + // Read session immediately (should not be expired) + const secondResponse = await (async () => { + const handler = new App() + .use(session(store, TEST_SECRET, { sessionExpires: longExpires })) + .get("/get", (ctx) => { + const data = ctx.state.session.get("data"); + return new Response(data ? String(data) : "no-data"); + }).handler(); + + const req = new Request("http://localhost/get", { + headers: { + "Cookie": `fresh_session=${sessionCookie}`, + }, + }); + return await handler(req); + })(); + + const text = await secondResponse.text(); + assertEquals(text, "persisted-value", "Session should still be valid"); +}); diff --git a/sample/main.ts b/sample/main.ts new file mode 100644 index 0000000..f727a8f --- /dev/null +++ b/sample/main.ts @@ -0,0 +1,97 @@ +import { App, createDefine, type Middleware, staticFiles } from "@fresh/core"; +import { memorySessionMiddleware } from "./session_memory.ts"; +import { cookieSessionMiddleware } from "./session_cookie.ts"; +import { kvSessionMiddleware } from "./session_kv.ts"; +import { registerSessionDemoRoutes } from "./session_demo.tsx"; +import type { SessionState } from "../src/session.ts"; + +export interface State extends SessionState { + shared: string; +} + +export const define = createDefine(); + +export const app = new App(); + +app.use(staticFiles()); + +// select session middleware +async function selectSessionMiddleware( + sessionType?: string, +): Promise<{ storeType: string; sessionMiddleware: Middleware }> { + switch (sessionType) { + case ("cookie"): { + return { + storeType: "cookie", + sessionMiddleware: cookieSessionMiddleware, + }; + } + case ("memory"): { + return { + storeType: "memory", + sessionMiddleware: memorySessionMiddleware, + }; + } + case ("kv"): { + return { + storeType: "kv", + sessionMiddleware: kvSessionMiddleware, + }; + } + case ("redis"): { + const { redisSessionMiddleware } = await import("./session_redis.ts"); + return { + storeType: "redis", + sessionMiddleware: redisSessionMiddleware, + }; + } + case ("mysql"): { + const { mysqlSessionMiddleware } = await import("./session_mysql.ts"); + return { + storeType: "mysql", + sessionMiddleware: mysqlSessionMiddleware, + }; + } + case ("postgres"): { + const { postgresSessionMiddleware } = await import( + "./session_postgres.ts" + ); + return { + storeType: "postgres", + sessionMiddleware: postgresSessionMiddleware, + }; + } + default: { + return { + storeType: "memory", + sessionMiddleware: memorySessionMiddleware, + }; + } + } +} + +const { storeType, sessionMiddleware } = await selectSessionMiddleware( + Deno.args[0], +); + +app.use(sessionMiddleware); + +const exampleLoggerMiddleware = define.middleware((ctx) => { + console.log(`${ctx.req.method} ${ctx.req.url}`); + return ctx.next(); +}); +app.use(exampleLoggerMiddleware); + +// Register session demo routes +registerSessionDemoRoutes(app, storeType); + +// When no route matches, redirect to top page +app.use((ctx) => { + const url = new URL("/", ctx.req.url); + return new Response(null, { + status: 302, + headers: { Location: url.toString() }, + }); +}); + +app.listen(); diff --git a/sample/session_cookie.ts b/sample/session_cookie.ts new file mode 100644 index 0000000..8621603 --- /dev/null +++ b/sample/session_cookie.ts @@ -0,0 +1,24 @@ +import { CookieSessionStore, session } from "../../fresh-session/mod.ts"; +import type { State } from "./main.ts"; + +// CookieSessionStore (session data stored in encrypted cookie; ~4KB limit) +const cookieSessionStore = new CookieSessionStore(); + +/** + * Session middleware (cookie store) + * Stores session data in the cookie itself + */ +export const cookieSessionMiddleware = session( + cookieSessionStore, + "your-secret-key-at-least-32-characters-long", // In production, get from environment variable + { + cookieName: "session", + cookieOptions: { + httpOnly: true, + secure: false, + sameSite: "Lax", + path: "/", + maxAge: 60 * 60 * 24, + }, + }, +); diff --git a/sample/session_demo.tsx b/sample/session_demo.tsx new file mode 100644 index 0000000..b56d800 --- /dev/null +++ b/sample/session_demo.tsx @@ -0,0 +1,328 @@ +import type { App } from "@fresh/core"; +import type { State } from "./main.ts"; + +/** + * Register session demo page routes + */ +export function registerSessionDemoRoutes(app: App, storeType: string) { + // GET / - Display demo page + app.get("/", (ctx) => { + const accept = ctx.req.headers.get("accept") ?? ""; + const fetchDest = ctx.req.headers.get("sec-fetch-dest"); + const fetchMode = ctx.req.headers.get("sec-fetch-mode"); + const fetchUser = ctx.req.headers.get("sec-fetch-user"); + const isDocument = fetchDest === "document"; + const isNavigate = fetchMode === "navigate"; + const isUserNavigation = fetchUser === "?1"; + const shouldCount = accept.includes("text/html") && + (isUserNavigation || (fetchUser === null && (isNavigate || isDocument))); + + // Get visit count from session + const visitCount = (ctx.state.session.get("visitCount") as number) ?? 0; + const newCount = shouldCount ? visitCount + 1 : visitCount; + + console.log( + `[session-demo] visitCount: ${visitCount} -> ${newCount}, isNew: ${ctx.state.session.isNew()}`, + ); + + if (shouldCount) { + // Save visit count to session + ctx.state.session.set("visitCount", newCount); + + // Save last visit time + ctx.state.session.set("lastVisit", new Date().toISOString()); + } + + const lastVisit = ctx.state.session.get("lastVisit") as string ?? "None"; + const isNew = ctx.state.session.isNew(); + const sessionId = ctx.state.session.sessionId() ?? "Unknown"; + + // Get flash message if any + const flashMessage = ctx.state.session.flash.get("message") as + | string + | undefined; + const flashType = ctx.state.session.flash.get("type") as string | undefined; + + return ctx.render( +
+

Fresh Session Demo

+ + {/* Flash message display */} + {flashMessage && ( +
+ + {flashType === "success" + ? "✓" + : flashType === "error" + ? "✗" + : "ℹ"} + {" "} + {flashMessage} +
+ )} + +
+

Session Info

+ + + + + + + + + + + + + + + + + + + + + + + +
+ Session ID: + + {sessionId} +
+ Store Type: + + {storeType} +
+ New Session: + + {isNew ? "Yes" : "No"} +
+ Visit Count: + + {newCount} +
+ Last Visit: + + {lastVisit !== "None" + ? new Date(lastVisit).toISOString() + : lastVisit} +
+
+ +
+ + Reload Page + + +
+ +
+ +
+ +
+ +
+ +
+
+ +
+

How it works

+
    +
  • Visit count increments each time you access this page
  • +
  • Session is stored in MemoryStore
  • +
  • Click "Clear Session" to destroy the session
  • +
  • Click "Test Flash Message" to see flash messages in action
  • +
  • + Flash messages appear only once and disappear on next page load +
  • +
  • + Click "Rotate Session ID" to regenerate session ID (keeps data) +
  • +
  • Session is lost when server restarts
  • +
+
+
, + ); + }); + + // POST / - Clear session + app.post("/", (ctx) => { + // Set flash message before destroying + ctx.state.session.flash.set("message", "Session has been cleared!"); + ctx.state.session.flash.set("type", "success"); + + // Clear session + ctx.state.session.destroy(); + + // Redirect + return new Response(null, { + status: 303, + headers: { Location: "/" }, + }); + }); + + // POST /flash-demo - Demonstrate flash message + app.post("/flash-demo", (ctx) => { + // Set flash message + ctx.state.session.flash.set( + "message", + "This is a flash message! It will disappear after you reload.", + ); + ctx.state.session.flash.set("type", "info"); + + // Redirect back to home + return new Response(null, { + status: 303, + headers: { Location: "/" }, + }); + }); + + // POST /rotate-session - Rotate session ID + app.post("/rotate-session", (ctx) => { + // Rotate session ID (keeps session data, but changes the ID) + ctx.state.session.rotate(); + + // Set flash message + ctx.state.session.flash.set( + "message", + "Session ID has been rotated! Your data is preserved with a new session ID.", + ); + ctx.state.session.flash.set("type", "success"); + + // Redirect back to home + return new Response(null, { + status: 303, + headers: { Location: "/" }, + }); + }); +} diff --git a/sample/session_kv.ts b/sample/session_kv.ts new file mode 100644 index 0000000..8956e43 --- /dev/null +++ b/sample/session_kv.ts @@ -0,0 +1,24 @@ +import { KvSessionStore, session } from "@octo/fresh-session"; +import type { State } from "./main.ts"; + +// KvSessionStore (persistent storage via Deno KV) +const kvSessionStore = new KvSessionStore(); + +/** + * Session middleware (KV store) + * Stores session data in Deno KV + */ +export const kvSessionMiddleware = session( + kvSessionStore, + "your-secret-key-at-least-32-characters-long", // In production, get from environment variable + { + cookieName: "session", + cookieOptions: { + httpOnly: true, + secure: false, + sameSite: "Lax", + path: "/", + maxAge: 60 * 60 * 24, + }, + }, +); diff --git a/sample/session_memory.ts b/sample/session_memory.ts new file mode 100644 index 0000000..c233a70 --- /dev/null +++ b/sample/session_memory.ts @@ -0,0 +1,24 @@ +import { MemorySessionStore, session } from "@octo/fresh-session"; +import type { State } from "./main.ts"; + +// MemorySessionStore (data lives in memory while server is running) +const memorySessionStore = new MemorySessionStore(); + +/** + * Session middleware + * Manages sessions using MemorySessionStore + */ +export const memorySessionMiddleware = session( + memorySessionStore, + "your-secret-key-at-least-32-characters-long", // In production, get from environment variable + { + cookieName: "session", + cookieOptions: { + httpOnly: true, + secure: false, + sameSite: "Lax", + path: "/", + maxAge: 60 * 60 * 24, + }, + }, +); diff --git a/sample/session_mysql.ts b/sample/session_mysql.ts new file mode 100644 index 0000000..fdb3bd3 --- /dev/null +++ b/sample/session_mysql.ts @@ -0,0 +1,83 @@ +import { session, SqlSessionStore } from "@octo/fresh-session"; +import mysql from "mysql2/promise"; +import type { State } from "./main.ts"; + +const mysqlHost = Deno.env.get("MYSQL_HOST") ?? "127.0.0.1"; +const mysqlPort = Number(Deno.env.get("MYSQL_PORT") ?? "3306"); +const mysqlUser = Deno.env.get("MYSQL_USER") ?? "root"; +const mysqlPassword = Deno.env.get("MYSQL_PASSWORD") ?? ""; +const mysqlDatabase = Deno.env.get("MYSQL_DATABASE") ?? "fresh_session"; +const mysqlTable = Deno.env.get("MYSQL_TABLE") ?? "sessions"; + +type MysqlPool = { + query: (sql: string, params?: unknown[]) => Promise<[unknown, unknown]>; +}; + +const pool = mysql.createPool({ + host: mysqlHost, + port: mysqlPort, + user: mysqlUser, + password: mysqlPassword, + database: mysqlDatabase, + connectionLimit: 4, +}) as unknown as MysqlPool; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForMysql(): Promise { + const maxAttempts = 30; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await pool.query("SELECT 1"); + return; + } catch { + await delay(1000); + } + } + throw new Error("Failed to connect to MySQL after multiple attempts."); +} + +const createTableSql = ` + CREATE TABLE IF NOT EXISTS ${mysqlTable} ( + session_id VARCHAR(36) PRIMARY KEY, + data TEXT NOT NULL, + expires_at DATETIME NULL + ); +`; + +await waitForMysql(); +await pool.query(createTableSql); +await pool.query( + `CREATE INDEX IF NOT EXISTS idx_${mysqlTable}_expires_at ON ${mysqlTable}(expires_at);`, +).catch(() => {}); + +const sqlClient = { + execute: async (sql: string, params: unknown[] = []) => { + const [rows] = await pool.query(sql, params); + return { + rows: Array.isArray(rows) ? (rows as Record[]) : [], + }; + }, +}; + +const mysqlSessionStore = new SqlSessionStore({ + client: sqlClient, + tableName: mysqlTable, +}); + +export const mysqlSessionMiddleware = session( + mysqlSessionStore, + "your-secret-key-at-least-32-characters-long", + { + cookieName: "session", + cookieOptions: { + httpOnly: true, + secure: false, + sameSite: "Lax", + path: "/", + maxAge: 60 * 60 * 24, + }, + }, +); diff --git a/sample/session_postgres.ts b/sample/session_postgres.ts new file mode 100644 index 0000000..c41794e --- /dev/null +++ b/sample/session_postgres.ts @@ -0,0 +1,80 @@ +import { session, SqlSessionStore } from "@octo/fresh-session"; +import { Pool } from "pg"; +import type { State } from "./main.ts"; + +const pgHost = Deno.env.get("POSTGRES_HOST") ?? "127.0.0.1"; +const pgPort = Number(Deno.env.get("POSTGRES_PORT") ?? "5432"); +const pgUser = Deno.env.get("POSTGRES_USER") ?? "postgres"; +const pgPassword = Deno.env.get("POSTGRES_PASSWORD") ?? "postgres"; +const pgDatabase = Deno.env.get("POSTGRES_DATABASE") ?? "fresh_session"; +const pgTable = Deno.env.get("POSTGRES_TABLE") ?? "sessions"; + +const pool = new Pool({ + host: pgHost, + port: pgPort, + user: pgUser, + password: pgPassword, + database: pgDatabase, + max: 4, +}); + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForPostgres(): Promise { + const maxAttempts = 30; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await pool.query("SELECT 1"); + return; + } catch { + await delay(1000); + } + } + throw new Error("Failed to connect to PostgreSQL after multiple attempts."); +} + +const createTableSql = ` + CREATE TABLE IF NOT EXISTS ${pgTable} ( + session_id VARCHAR(36) PRIMARY KEY, + data TEXT NOT NULL, + expires_at TIMESTAMP NULL + ); +`; + +await waitForPostgres(); +await pool.query(createTableSql); +await pool.query( + `CREATE INDEX IF NOT EXISTS idx_${pgTable}_expires_at ON ${pgTable}(expires_at);`, +); + +const sqlClient = { + execute: async (sql: string, params: unknown[] = []) => { + const result = await pool.query(sql, params); + return { + rows: result.rows as Record[], + }; + }, +}; + +const postgresSessionStore = new SqlSessionStore({ + client: sqlClient, + tableName: pgTable, + dialect: "postgres", +}); + +export const postgresSessionMiddleware = session( + postgresSessionStore, + "your-secret-key-at-least-32-characters-long", + { + cookieName: "session", + cookieOptions: { + httpOnly: true, + secure: false, + sameSite: "Lax", + path: "/", + maxAge: 60 * 60 * 24, + }, + }, +); diff --git a/sample/session_redis.ts b/sample/session_redis.ts new file mode 100644 index 0000000..bc51d32 --- /dev/null +++ b/sample/session_redis.ts @@ -0,0 +1,46 @@ +import { + type RedisClient, + RedisSessionStore, + session, +} from "@octo/fresh-session"; +import { connect } from "@db/redis"; +import type { State } from "./main.ts"; + +const redisHost = Deno.env.get("REDIS_HOST") ?? "127.0.0.1"; +const redisPort = Number(Deno.env.get("REDIS_PORT") ?? "6379"); + +const redis = await connect({ + hostname: redisHost, + port: redisPort, +}); + +const redisClient: RedisClient = { + get: (key: string) => redis.get(key), + set: (key: string, value: string, options?: { ex?: number }) => + redis + .set(key, value, options?.ex ? { ex: options.ex } : undefined) + .then(() => {}), + del: (key: string) => redis.del(key).then(() => {}), +}; + +// RedisSessionStore (session data stored in Redis) +const redisSessionStore = new RedisSessionStore({ client: redisClient }); + +/** + * Session middleware (Redis store) + * Stores session data in Redis + */ +export const redisSessionMiddleware = session( + redisSessionStore, + "your-secret-key-at-least-32-characters-long", // In production, get from environment variable + { + cookieName: "session", + cookieOptions: { + httpOnly: true, + secure: false, + sameSite: "Lax", + path: "/", + maxAge: 60 * 60 * 24, + }, + }, +); diff --git a/scripts/with-resource.ts b/scripts/with-resource.ts new file mode 100644 index 0000000..fac2759 --- /dev/null +++ b/scripts/with-resource.ts @@ -0,0 +1,217 @@ +const REDIS_CONTAINER_NAME = "fresh-session-redis-test"; +const MYSQL_CONTAINER_NAME = "fresh-session-mysql-test"; +const POSTGRES_CONTAINER_NAME = "fresh-session-postgres-test"; +const REDIS_PORT = 6380; +const MYSQL_PORT = 3307; +const POSTGRES_PORT = 5433; + +const MYSQL_DATABASE = Deno.env.get("MYSQL_DATABASE") ?? "fresh_session"; +const MYSQL_USER = Deno.env.get("MYSQL_USER") ?? "root"; +const MYSQL_PASSWORD = Deno.env.get("MYSQL_PASSWORD") ?? "root"; +const POSTGRES_DATABASE = Deno.env.get("POSTGRES_DATABASE") ?? "fresh_session"; +const POSTGRES_USER = Deno.env.get("POSTGRES_USER") ?? "postgres"; +const POSTGRES_PASSWORD = Deno.env.get("POSTGRES_PASSWORD") ?? "postgres"; + +async function runCommand( + command: string, + args: string[], + options?: { + env?: Record; + inheritStdio?: boolean; + ignoreFailure?: boolean; + }, +): Promise { + const { env, inheritStdio, ignoreFailure } = options ?? {}; + const commandOptions: Deno.CommandOptions = { + args, + env, + stdin: inheritStdio ? "inherit" : "null", + stdout: inheritStdio ? "inherit" : "null", + stderr: inheritStdio ? "inherit" : "piped", + }; + + const result = await new Deno.Command(command, commandOptions).output(); + + if (!result.success && !ignoreFailure) { + if (!inheritStdio) { + const stderrText = new TextDecoder().decode(result.stderr); + if (stderrText.trim().length > 0) { + console.error(stderrText.trim()); + } + } + throw new Error(`Command failed: ${command} ${args.join(" ")}`); + } + + return result; +} + +async function removeContainer(name: string): Promise { + await runCommand("docker", ["rm", "-f", name], { + ignoreFailure: true, + }); +} + +async function startRedisContainer(): Promise { + await removeContainer(REDIS_CONTAINER_NAME); + console.info("[with-resource] Starting Redis container..."); + await runCommand( + "docker", + [ + "run", + "-d", + "--name", + REDIS_CONTAINER_NAME, + "-p", + `${REDIS_PORT}:6379`, + "redis:7-alpine", + ], + ); +} + +async function startMysqlContainer(): Promise { + await removeContainer(MYSQL_CONTAINER_NAME); + console.info("[with-resource] Starting MySQL container..."); + await runCommand( + "docker", + [ + "run", + "-d", + "--name", + MYSQL_CONTAINER_NAME, + "-p", + `${MYSQL_PORT}:3306`, + "-e", + `MYSQL_ROOT_PASSWORD=${MYSQL_PASSWORD}`, + "-e", + `MYSQL_DATABASE=${MYSQL_DATABASE}`, + "mysql:8.0", + ], + ); +} + +async function startPostgresContainer(): Promise { + await removeContainer(POSTGRES_CONTAINER_NAME); + console.info("[with-resource] Starting PostgreSQL container..."); + await runCommand( + "docker", + [ + "run", + "-d", + "--name", + POSTGRES_CONTAINER_NAME, + "-p", + `${POSTGRES_PORT}:5432`, + "-e", + `POSTGRES_PASSWORD=${POSTGRES_PASSWORD}`, + "-e", + `POSTGRES_USER=${POSTGRES_USER}`, + "-e", + `POSTGRES_DB=${POSTGRES_DATABASE}`, + "postgres:16-alpine", + ], + ); +} + +async function waitForMysqlReady(): Promise { + console.info("[with-resource] Waiting for MySQL to be ready..."); + const maxAttempts = 30; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await runCommand( + "docker", + [ + "exec", + MYSQL_CONTAINER_NAME, + "mysqladmin", + "ping", + "-uroot", + `-p${MYSQL_PASSWORD}`, + "--silent", + ], + { ignoreFailure: true }, + ); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + throw new Error("MySQL container did not become ready in time."); +} + +async function waitForPostgresReady(): Promise { + console.info("[with-resource] Waiting for PostgreSQL to be ready..."); + const maxAttempts = 30; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await runCommand( + "docker", + [ + "exec", + POSTGRES_CONTAINER_NAME, + "pg_isready", + "-U", + POSTGRES_USER, + ], + { ignoreFailure: true }, + ); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + throw new Error("PostgreSQL container did not become ready in time."); +} + +function buildCommand(args: string[]): { command: string; args: string[] } { + const sanitizedArgs = args[0] === "--" ? args.slice(1) : args; + if (sanitizedArgs.length === 0) { + throw new Error( + "Command args are required. Example: -- deno test -E --allow-net", + ); + } + + const [command, ...rest] = sanitizedArgs; + return { command, args: rest }; +} + +async function main(): Promise { + await startRedisContainer(); + await startMysqlContainer(); + await waitForMysqlReady(); + await startPostgresContainer(); + await waitForPostgresReady(); + + const { command, args } = buildCommand(Deno.args); + const env = { + REDIS_HOST: "127.0.0.1", + REDIS_PORT: `${REDIS_PORT}`, + MYSQL_HOST: "127.0.0.1", + MYSQL_PORT: `${MYSQL_PORT}`, + MYSQL_USER, + MYSQL_PASSWORD, + MYSQL_DATABASE, + POSTGRES_HOST: "127.0.0.1", + POSTGRES_PORT: `${POSTGRES_PORT}`, + POSTGRES_USER, + POSTGRES_PASSWORD, + POSTGRES_DATABASE, + }; + + let exitCode = 1; + try { + const status = await runCommand(command, args, { + env, + inheritStdio: true, + }); + exitCode = status.code; + } finally { + await removeContainer(REDIS_CONTAINER_NAME); + await removeContainer(MYSQL_CONTAINER_NAME); + await removeContainer(POSTGRES_CONTAINER_NAME); + } + + return exitCode; +} + +const exitCode = await main(); +Deno.exit(exitCode); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..e1856d0 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,57 @@ +export interface SessionConfig { + cookieName: string; + cookieOptions: { + path: string; + httpOnly: boolean; + secure: boolean; + sameSite: "Strict" | "Lax" | "None"; + maxAge: number; + domain: string; + }; + sessionExpires: number; // ms +} + +/** + * Partial session config (for input) + */ +export type PartialSessionConfig = { + cookieName?: string; + cookieOptions?: Partial; + sessionExpires?: number; +}; + +/** + * Default configuration values + */ +export const defaultSessionConfig: SessionConfig = { + cookieName: "fresh_session", + cookieOptions: { + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: 60 * 60 * 24, // 1 day + domain: "", + }, + sessionExpires: 1000 * 60 * 60 * 24, // 1 day +}; + +export function mergeSessionConfig( + inputSessionConfig?: PartialSessionConfig, +): SessionConfig { + // Merge input config with default config + if (!inputSessionConfig) { + return { ...defaultSessionConfig }; + } + + return { + cookieName: inputSessionConfig.cookieName ?? + defaultSessionConfig.cookieName, + cookieOptions: { + ...defaultSessionConfig.cookieOptions, + ...inputSessionConfig.cookieOptions, + }, + sessionExpires: inputSessionConfig.sessionExpires ?? + defaultSessionConfig.sessionExpires, + }; +} diff --git a/src/cookie.ts b/src/cookie.ts new file mode 100644 index 0000000..5944f64 --- /dev/null +++ b/src/cookie.ts @@ -0,0 +1,43 @@ +// Cookie operations +import { + type Cookie, + deleteCookie, + getCookies, + setCookie, +} from "@std/http/cookie"; + +/** + * Cookie utility functions + */ +export function setSessionCookie( + headers: Headers, + name: string, + value: string, + options: Partial = {}, +) { + setCookie(headers, { + name, + value, + path: options.path ?? "/", + httpOnly: options.httpOnly ?? true, + secure: options.secure ?? true, + sameSite: options.sameSite ?? "Lax", + maxAge: options.maxAge, + expires: options.expires, + domain: options.domain, + }); +} + +export function getSessionIdFromCookie( + reqHeaders: Headers, + name: string, +): string | undefined { + const cookies = getCookies(reqHeaders); + return cookies[name]; +} + +export function deleteSessionCookie(headers: Headers, name: string) { + deleteCookie(headers, name, { path: "/" }); +} + +export { getCookies }; diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..5fc44d1 --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,68 @@ +// Security features (encryption) +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** + * AES-GCM encryption + */ +export async function encrypt(data: string, key: CryptoKey): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + encoder.encode(data), + ); + // Return iv + encrypted as base64 + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.length); + return btoa(String.fromCharCode(...combined)); +} + +/** + * AES-GCM decryption + */ +export async function decrypt( + encoded: string, + key: CryptoKey, +): Promise { + const combined = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0)); + const iv = combined.slice(0, 12); + const data = combined.slice(12); + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + key, + data, + ); + return decoder.decode(decrypted); +} + +/** + * Generate encryption key (AES-GCM, 256bit) + */ +export async function generateKey(): Promise { + return await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"], + ); +} + +/** + * Import key from secret string + * @param secret String with at least 32 characters (required) + * @throws Error if secret is less than 32 characters + */ +export async function importKey(secret: string): Promise { + if (secret.length < 32) { + throw new Error("Secret must be at least 32 characters long"); + } + const keyData = encoder.encode(secret.slice(0, 32)); + return await crypto.subtle.importKey( + "raw", + keyData, + { name: "AES-GCM" }, + false, + ["encrypt", "decrypt"], + ); +} diff --git a/src/crypto_test.ts b/src/crypto_test.ts new file mode 100644 index 0000000..64abfa0 --- /dev/null +++ b/src/crypto_test.ts @@ -0,0 +1,213 @@ +import { assertEquals, assertNotEquals, assertRejects } from "@std/assert"; +import { decrypt, encrypt, generateKey, importKey } from "./crypto.ts"; + +Deno.test("encrypt and decrypt: basic string", async () => { + const key = await generateKey(); + const original = "Hello, World!"; + + const encrypted = await encrypt(original, key); + const decrypted = await decrypt(encrypted, key); + + assertEquals(decrypted, original); +}); + +Deno.test("encrypt and decrypt: empty string", async () => { + const key = await generateKey(); + const original = ""; + + const encrypted = await encrypt(original, key); + const decrypted = await decrypt(encrypted, key); + + assertEquals(decrypted, original); +}); + +Deno.test("encrypt and decrypt: Japanese characters", async () => { + const key = await generateKey(); + const original = "こんにちは世界!日本語テスト"; + + const encrypted = await encrypt(original, key); + const decrypted = await decrypt(encrypted, key); + + assertEquals(decrypted, original); +}); + +Deno.test("encrypt and decrypt: JSON data", async () => { + const key = await generateKey(); + const data = { + userId: "user123", + role: "admin", + settings: { theme: "dark" }, + }; + const original = JSON.stringify(data); + + const encrypted = await encrypt(original, key); + const decrypted = await decrypt(encrypted, key); + + assertEquals(decrypted, original); + assertEquals(JSON.parse(decrypted), data); +}); + +Deno.test("encrypt and decrypt: long string", async () => { + const key = await generateKey(); + const original = "a".repeat(10000); + + const encrypted = await encrypt(original, key); + const decrypted = await decrypt(encrypted, key); + + assertEquals(decrypted, original); +}); + +Deno.test("encrypt: produces different output each time (random IV)", async () => { + const key = await generateKey(); + const original = "Same input"; + + const encrypted1 = await encrypt(original, key); + const encrypted2 = await encrypt(original, key); + + // Same input produces different ciphertext (because IV is random) + assertNotEquals(encrypted1, encrypted2); + + // Both can be decrypted correctly + assertEquals(await decrypt(encrypted1, key), original); + assertEquals(await decrypt(encrypted2, key), original); +}); + +Deno.test("encrypt: output is base64 encoded", async () => { + const key = await generateKey(); + const encrypted = await encrypt("test", key); + + // Contains only base64 character set + const base64Regex = /^[A-Za-z0-9+/]+=*$/; + assertEquals(base64Regex.test(encrypted), true); +}); + +Deno.test("decrypt: fails with wrong key", async () => { + const key1 = await generateKey(); + const key2 = await generateKey(); + const original = "Secret message"; + + const encrypted = await encrypt(original, key1); + + await assertRejects( + async () => await decrypt(encrypted, key2), + Error, + ); +}); + +Deno.test("decrypt: fails with corrupted data", async () => { + const key = await generateKey(); + const original = "Secret message"; + + const encrypted = await encrypt(original, key); + const corrupted = encrypted.slice(0, -5) + "XXXXX"; + + await assertRejects( + async () => await decrypt(corrupted, key), + Error, + ); +}); + +Deno.test("decrypt: fails with invalid base64", async () => { + const key = await generateKey(); + + await assertRejects( + async () => await decrypt("not-valid-base64!!!", key), + Error, + ); +}); + +Deno.test("importKey: creates key from 32+ character secret", async () => { + const secret = "this-is-a-test-secret-key-32chars!"; + const key = await importKey(secret); + + const original = "Test message"; + const encrypted = await encrypt(original, key); + const decrypted = await decrypt(encrypted, key); + + assertEquals(decrypted, original); +}); + +Deno.test("importKey: same secret produces same key", async () => { + const secret = "this-is-a-test-secret-key-32chars!"; + const key1 = await importKey(secret); + const key2 = await importKey(secret); + + const original = "Test message"; + const encrypted = await encrypt(original, key1); + const decrypted = await decrypt(encrypted, key2); + + assertEquals(decrypted, original); +}); + +Deno.test("importKey: different secrets produce different keys", async () => { + const secret1 = "this-is-a-test-secret-key-32chars!"; + const secret2 = "another-test-secret-key-32-chars!"; + const key1 = await importKey(secret1); + const key2 = await importKey(secret2); + + const original = "Test message"; + const encrypted = await encrypt(original, key1); + + await assertRejects( + async () => await decrypt(encrypted, key2), + Error, + ); +}); + +Deno.test("importKey: throws error for short secret", async () => { + const shortSecret = "too-short"; + + await assertRejects( + async () => await importKey(shortSecret), + Error, + "Secret must be at least 32 characters long", + ); +}); + +Deno.test("importKey: uses only first 32 characters", async () => { + const secret1 = "this-is-a-test-secret-key-32chars!-extra"; + const secret2 = "this-is-a-test-secret-key-32chars!-different"; + const key1 = await importKey(secret1); + const key2 = await importKey(secret2); + + // Same key because first 32 characters are the same + const original = "Test message"; + const encrypted = await encrypt(original, key1); + const decrypted = await decrypt(encrypted, key2); + + assertEquals(decrypted, original); +}); + +Deno.test("generateKey: creates unique keys", async () => { + const key1 = await generateKey(); + const key2 = await generateKey(); + + const original = "Test message"; + const encrypted = await encrypt(original, key1); + + // Cannot decrypt with different key + await assertRejects( + async () => await decrypt(encrypted, key2), + Error, + ); +}); + +Deno.test("encrypt and decrypt: special characters", async () => { + const key = await generateKey(); + const original = "Special: !@#$%^&*()_+-=[]{}|;':\",./<>?`~"; + + const encrypted = await encrypt(original, key); + const decrypted = await decrypt(encrypted, key); + + assertEquals(decrypted, original); +}); + +Deno.test("encrypt and decrypt: emoji", async () => { + const key = await generateKey(); + const original = "🎉🔐💻🚀 Emoji test! 日本語+emoji: 🇯🇵"; + + const encrypted = await encrypt(original, key); + const decrypted = await decrypt(encrypted, key); + + assertEquals(decrypted, original); +}); diff --git a/src/deps.ts b/src/deps.ts deleted file mode 100644 index 64610af..0000000 --- a/src/deps.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type { - MiddlewareHandler, - MiddlewareHandlerContext, - Plugin, -} from "https://deno.land/x/fresh@1.5.4/server.ts"; -export { - type Cookie, - deleteCookie, - getCookies, - setCookie, -} from "https://deno.land/std@0.207.0/http/mod.ts"; -export { - defaults as ironDefaults, - seal, - unseal, -} from "https://deno.land/x/iron@v1.0.0/mod.ts"; diff --git a/src/mod.ts b/src/mod.ts deleted file mode 100644 index 001fa58..0000000 --- a/src/mod.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from "./stores/cookie.ts"; -export * from "./stores/redis.ts"; -export * from "./stores/kv.ts"; -export * from "./stores/interface.ts"; -export * from "./session.ts"; -export * from "./plugins/cookie_session_plugin.ts"; -export * from "./plugins/redis_session_plugin.ts"; -export * from "./plugins/kv_session_plugin.ts"; diff --git a/src/plugins/cookie_session_plugin.ts b/src/plugins/cookie_session_plugin.ts deleted file mode 100644 index 33c7d8d..0000000 --- a/src/plugins/cookie_session_plugin.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { - MiddlewareHandler, - MiddlewareHandlerContext, - Plugin, -} from "../deps.ts"; -import { cookieSession } from "../stores/cookie.ts"; -import { CookieOptions } from "../stores/cookie_option.ts"; -import { sessionModule } from "../stores/interface.ts"; - -export function getCookieSessionHandler( - session: sessionModule, - excludePath: string[], -): MiddlewareHandler { - return function (req: Request, ctx: MiddlewareHandlerContext) { - if (excludePath.includes(new URL(req.url).pathname)) { - return ctx.next(); - } - return session(req, ctx); - }; -} - -export function getCookieSessionPlugin( - path = "/", - excludePath = [], - cookieOptions?: CookieOptions, -): Plugin { - const session = cookieSession(cookieOptions); - const handler = getCookieSessionHandler(session, excludePath); - - return { - name: "cookieSessionPlugin", - middlewares: [ - { - middleware: { - handler: handler, - }, - path: path, - }, - ], - }; -} diff --git a/src/plugins/kv_session_plugin.ts b/src/plugins/kv_session_plugin.ts deleted file mode 100644 index 2b24310..0000000 --- a/src/plugins/kv_session_plugin.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { - MiddlewareHandler, - MiddlewareHandlerContext, - Plugin, -} from "../deps.ts"; -import { kvSession } from "../stores/kv.ts"; -import { CookieOptions } from "../stores/cookie_option.ts"; -import { sessionModule } from "../stores/interface.ts"; - -export function getKvSessionHandler( - session: sessionModule, - excludePath: string[], -): MiddlewareHandler { - return function (req: Request, ctx: MiddlewareHandlerContext) { - if (excludePath.includes(new URL(req.url).pathname)) { - return ctx.next(); - } - return session(req, ctx); - }; -} - -export function getKvSessionPlugin( - storePath: string | null, - path = "/", - excludePath = [], - cookieOptions?: CookieOptions, -): Plugin { - const session = kvSession(storePath, cookieOptions); - const handler = getKvSessionHandler(session, excludePath); - - return { - name: "cookieSessionPlugin", - middlewares: [ - { - middleware: { - handler: handler, - }, - path: path, - }, - ], - }; -} diff --git a/src/plugins/redis_session_plugin.ts b/src/plugins/redis_session_plugin.ts deleted file mode 100644 index 671732f..0000000 --- a/src/plugins/redis_session_plugin.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { - MiddlewareHandler, - MiddlewareHandlerContext, - Plugin, -} from "../deps.ts"; -import { redisSession, Store } from "../stores/redis.ts"; -import { CookieOptions } from "../stores/cookie_option.ts"; -import { sessionModule } from "../stores/interface.ts"; - -export function getRedisSessionHandler( - session: sessionModule, - excludePath: string[], -): MiddlewareHandler { - return function (req: Request, ctx: MiddlewareHandlerContext) { - if (excludePath.includes(new URL(req.url).pathname)) { - return ctx.next(); - } - return session(req, ctx); - }; -} - -export function getRedisSessionPlugin( - store: Store, - path = "/", - excludePath = [], - cookieOptions?: CookieOptions, -): Plugin { - const session = redisSession(store, cookieOptions); - const handler = getRedisSessionHandler(session, excludePath); - - return { - name: "cookieSessionPlugin", - middlewares: [ - { - middleware: { - handler: handler, - }, - path: path, - }, - ], - }; -} diff --git a/src/session.ts b/src/session.ts index 394d00e..65a40e8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,67 +1,317 @@ -export class Session { - #data = new Map(); - #flash = new Map(); - #doDelete = false; - #doKeyRotate = false; - - constructor(data = {}, flash = {}) { - this.#data = new Map(Object.entries(data)); - this.#flash = new Map(Object.entries(flash)); +// Session management main logic +import { + mergeSessionConfig, + type PartialSessionConfig, + type SessionConfig, +} from "./config.ts"; +import type { ISessionStore, SessionData } from "./types.ts"; +import type { Middleware } from "@fresh/core"; +import { + deleteSessionCookie, + getSessionIdFromCookie, + setSessionCookie, +} from "./cookie.ts"; +import { decrypt, encrypt, importKey } from "./crypto.ts"; + +/** + * Session ID generation and management logic + */ +export class SessionManager { + #sessionId: string | undefined = undefined; + #sessionData: SessionData | undefined = undefined; + #doSessionDestroy: boolean = false; + #doSessionRotate: boolean = false; + #cryptoKey: CryptoKey | undefined = undefined; + #isNew: boolean = false; + // Flash data from previous request (available for reading) + #flashData: Record = {}; + // Flash data to be stored for next request + #nextFlashData: Record = {}; + // Key used to store flash data in session + static readonly #FLASH_KEY = "__flash__"; + + constructor( + private store: ISessionStore, + private secret: string, + private config: SessionConfig, + ) {} + + /** + * Get encryption key (lazy initialization) + */ + private async getCryptoKey(): Promise { + if (!this.#cryptoKey) { + this.#cryptoKey = await importKey(this.secret); + } + + return this.#cryptoKey; } - get data() { - return Object.fromEntries(this.#data); + /** + * Decrypt cookie value + */ + private async decryptCookieValue( + encryptedValue: string | undefined, + ): Promise { + if (!encryptedValue) { + return undefined; + } + try { + const key = await this.getCryptoKey(); + return await decrypt(encryptedValue, key); + } catch { + // Treat as new session if decryption fails + return undefined; + } } - get flashedData() { - return Object.fromEntries(this.#flash); + /** + * Encrypt cookie value + */ + private async encryptCookieValue(value: string): Promise { + const key = await this.getCryptoKey(); + return await encrypt(value, key); } - get doDelete() { - return this.#doDelete; + getValue( + key: string, + ): undefined | number | string | boolean | Date | Record { + if ( + this.#sessionData && typeof this.#sessionData === "object" && + !(this.#sessionData instanceof Date) + ) { + return (this.#sessionData as Record)[key] as + | undefined + | number + | string + | boolean + | Date + | Record; + } + return undefined; } - get doKeyRotate() { - return this.#doKeyRotate; + setValue(key: string, value: SessionData): void { + if ( + !this.#sessionData || typeof this.#sessionData !== "object" || + this.#sessionData instanceof Date + ) { + this.#sessionData = {}; + } + (this.#sessionData as Record)[key] = value; } - set(key: string, value: any) { - this.#data.set(key, value); + /** + * Get flash data (available only once, from previous request) + */ + getFlash( + key: string, + ): undefined | number | string | boolean | Date | Record { + return this.#flashData[key] as + | undefined + | number + | string + | boolean + | Date + | Record; + } - return this; + /** + * Set flash data (will be available only on next request) + */ + setFlash(key: string, value: SessionData): void { + this.#nextFlashData[key] = value; } - get(key: string) { - return this.#data.get(key); + /** + * Check if flash data exists for key + */ + hasFlash(key: string): boolean { + return key in this.#flashData; } - has(key: string) { - return this.#data.has(key); + /** + * Request session destruction + */ + requestDestroySession(): void { + this.#doSessionDestroy = true; } - clear() { - this.#data.clear(); - return this; + /** + * Request session ID rotation + */ + requestRotateSessionId(): void { + this.#doSessionRotate = true; + } + isNew(): boolean { + return this.#isNew; } - flash(key: string, value?: any) { - if (value === undefined) { - const flashedValue = this.#flash.get(key); + /** + * Before request processing: Restore session from cookie + */ + async before(request: Request): Promise { + const encryptedCookieValue = getSessionIdFromCookie( + request.headers, + this.config.cookieName, + ); + const cookieValue = await this.decryptCookieValue(encryptedCookieValue); + const { sessionId, data, isNew } = await this.store.load(cookieValue); - this.#flash.delete(key); + this.#sessionId = sessionId; + this.#sessionData = data; + this.#isNew = isNew; - return flashedValue; + // Extract flash data from session (available for this request only) + if ( + this.#sessionData && + typeof this.#sessionData === "object" && + !(this.#sessionData instanceof Date) + ) { + const sessionObj = this.#sessionData as Record; + if (sessionObj[SessionManager.#FLASH_KEY]) { + this.#flashData = sessionObj[SessionManager.#FLASH_KEY] as Record< + string, + SessionData + >; + // Remove flash data from session (it will be consumed) + delete sessionObj[SessionManager.#FLASH_KEY]; + } } - this.#flash.set(key, value); - - return this; } - destroy() { - this.#doDelete = true; + /** + * After response processing: Save session and set cookie + */ + async after(response: Response): Promise { + if (!this.#sessionId) { + return; + } + + // Session destruction + if (this.#doSessionDestroy) { + await this.store.destroy(this.#sessionId); + deleteSessionCookie(response.headers, this.config.cookieName); + return; + } + + // Session ID rotation + if (this.#doSessionRotate) { + await this.store.destroy(this.#sessionId); + const { sessionId } = await this.store.load(undefined); // Get new ID + this.#sessionId = sessionId; + } + + // Store flash data for next request if any + if (Object.keys(this.#nextFlashData).length > 0) { + if ( + !this.#sessionData || typeof this.#sessionData !== "object" || + this.#sessionData instanceof Date + ) { + this.#sessionData = {}; + } + (this.#sessionData as Record)[ + SessionManager.#FLASH_KEY + ] = this.#nextFlashData; + } + + // Save session + const expiresAt = new Date(Date.now() + this.config.sessionExpires); + const cookieValue = await this.store.save( + this.#sessionId, + this.#sessionData ?? {}, + expiresAt, + ); + const encryptedCookieValue = await this.encryptCookieValue(cookieValue); + setSessionCookie( + response.headers, + this.config.cookieName, + encryptedCookieValue, + this.config.cookieOptions, + ); } - keyRotate() { - this.#doKeyRotate = true; + appMethods(): { + get: ( + key: string, + ) => undefined | number | string | boolean | Date | Record; + set: (key: string, value: SessionData) => void; + flash: { + get: ( + key: string, + ) => + | undefined + | number + | string + | boolean + | Date + | Record; + set: (key: string, value: SessionData) => void; + has: (key: string) => boolean; + }; + sessionId: () => string | undefined; + destroy: () => void; + rotate: () => void; + isNew: () => boolean; + } { + return { + get: (key: string) => this.getValue(key), + set: (key: string, value: SessionData) => this.setValue(key, value), + flash: { + get: (key: string) => this.getFlash(key), + set: (key: string, value: SessionData) => this.setFlash(key, value), + has: (key: string) => this.hasFlash(key), + }, + sessionId: () => this.#sessionId, + destroy: () => this.requestDestroySession(), + rotate: () => this.requestRotateSessionId(), + isNew: () => this.isNew(), + }; } } + +export interface SessionState { + session: { + get: ( + key: string, + ) => undefined | number | string | boolean | Date | Record; + set: (key: string, value: SessionData) => void; + flash: { + get: ( + key: string, + ) => + | undefined + | number + | string + | boolean + | Date + | Record; + set: (key: string, value: SessionData) => void; + has: (key: string) => boolean; + }; + sessionId: () => string | undefined; + destroy: () => void; + rotate: () => void; + isNew: () => boolean; + }; +} + +export function session( + store: ISessionStore, + secret: string, + config?: PartialSessionConfig, +): Middleware { + const sessionConfig = mergeSessionConfig(config); + return async (ctx) => { + // Create new SessionManager instance for each request + const sessionManager = new SessionManager(store, secret, sessionConfig); + await sessionManager.before(ctx.req); + + ctx.state.session = sessionManager.appMethods(); + + const res = await ctx.next(); + await sessionManager.after(res); + + return res; + }; +} diff --git a/src/storage/cookie.ts b/src/storage/cookie.ts new file mode 100644 index 0000000..96daad8 --- /dev/null +++ b/src/storage/cookie.ts @@ -0,0 +1,81 @@ +// Cookie storage implementation +// Session data is stored in the cookie itself (encryption is done by session.ts) +import type { ISessionStore, LoadResult, SessionData } from "../types.ts"; + +/** + * Cookie-based session store + * Stores session data in the cookie itself + * No server-side storage needed, but be aware of cookie size limit (4KB) + * Encryption/decryption is handled by SessionManager + */ +export class CookieSessionStore implements ISessionStore { + /** + * Restore session from cookie value + * cookieValue is a decrypted JSON string + */ + load(cookieValue: string | undefined): Promise { + if (!cookieValue) { + return Promise.resolve({ + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }); + } + + try { + const parsed = JSON.parse(cookieValue) as { + sessionId: string; + data: SessionData; + expiresAt?: string; + }; + + // Expiry check + if (parsed.expiresAt && new Date(parsed.expiresAt) < new Date()) { + return Promise.resolve({ + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }); + } + + return Promise.resolve({ + sessionId: parsed.sessionId, + data: parsed.data, + isNew: false, + }); + } catch { + // Return new session on parse failure + return Promise.resolve({ + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }); + } + } + + /** + * Save session and return value to set in cookie (JSON string) + * Encryption is handled by SessionManager + */ + save( + sessionId: string, + data: SessionData, + expiresAt?: Date, + ): Promise { + const payload = { + sessionId, + data, + expiresAt: expiresAt?.toISOString(), + }; + return Promise.resolve(JSON.stringify(payload)); + } + + /** + * Destroy session + * For CookieStore, nothing needs to be done on server side + * Cookie deletion is handled by SessionManager + */ + destroy(_sessionId: string): Promise { + return Promise.resolve(); + } +} diff --git a/src/storage/cookie_test.ts b/src/storage/cookie_test.ts new file mode 100644 index 0000000..cad8afd --- /dev/null +++ b/src/storage/cookie_test.ts @@ -0,0 +1,121 @@ +import { assertEquals, assertExists } from "@std/assert"; +import { CookieSessionStore } from "./cookie.ts"; + +Deno.test("CookieSessionStore: load with undefined cookie creates new session", async () => { + const store = new CookieSessionStore(); + + const result = await store.load(undefined); + + assertExists(result.sessionId); + assertEquals(result.data, {}); + assertEquals(result.isNew, true); +}); + +Deno.test("CookieSessionStore: save and load session data", async () => { + const store = new CookieSessionStore(); + + // Create new session + const { sessionId } = await store.load(undefined); + const data = { userId: "user123", role: "admin" }; + + // Save data (get JSON string) + const cookieValue = await store.save(sessionId, data); + + // Cookie value is JSON string + const parsed = JSON.parse(cookieValue); + assertEquals(parsed.sessionId, sessionId); + assertEquals(parsed.data, data); + + // Load data + const result = await store.load(cookieValue); + assertEquals(result.sessionId, sessionId); + assertEquals(result.data, data); + assertEquals(result.isNew, false); +}); + +Deno.test("CookieSessionStore: invalid cookie value creates new session", async () => { + const store = new CookieSessionStore(); + + // Invalid cookie value (JSON parse failure) + const result = await store.load("invalid-cookie-value"); + + assertExists(result.sessionId); + assertEquals(result.data, {}); + assertEquals(result.isNew, true); +}); + +Deno.test("CookieSessionStore: expired session returns new session", async () => { + const store = new CookieSessionStore(); + + const { sessionId } = await store.load(undefined); + const data = { temp: "data" }; + const pastDate = new Date(Date.now() - 10000); // 10 seconds ago + + const cookieValue = await store.save(sessionId, data, pastDate); + const result = await store.load(cookieValue); + + // Treated as new session because expired + assertEquals(result.isNew, true); + assertEquals(result.data, {}); +}); + +Deno.test("CookieSessionStore: non-expired session returns data", async () => { + const store = new CookieSessionStore(); + + const { sessionId } = await store.load(undefined); + const data = { active: true }; + const futureDate = new Date(Date.now() + 60000); // 1 minute later + + const cookieValue = await store.save(sessionId, data, futureDate); + const result = await store.load(cookieValue); + + assertEquals(result.sessionId, sessionId); + assertEquals(result.data, data); + assertEquals(result.isNew, false); +}); + +Deno.test("CookieSessionStore: update existing session", async () => { + const store = new CookieSessionStore(); + + const { sessionId } = await store.load(undefined); + + await store.save(sessionId, { count: 1 }); + const cookieValue2 = await store.save(sessionId, { count: 2 }); + + const result = await store.load(cookieValue2); + assertEquals(result.data, { count: 2 }); + assertEquals(result.isNew, false); +}); + +Deno.test("CookieSessionStore: destroy does nothing (cookie deletion handled by SessionManager)", async () => { + const store = new CookieSessionStore(); + + const { sessionId } = await store.load(undefined); + + // destroy does nothing (just verify no error occurs) + await store.destroy(sessionId); +}); + +Deno.test("CookieSessionStore: complex data types", async () => { + const store = new CookieSessionStore(); + + const { sessionId } = await store.load(undefined); + const complexData = { + user: { + id: 123, + name: "Test User", + roles: ["admin", "user"], + }, + settings: { + theme: "dark", + notifications: true, + }, + lastLogin: "2024-01-01T00:00:00.000Z", + }; + + const cookieValue = await store.save(sessionId, complexData); + const result = await store.load(cookieValue); + + assertEquals(result.data, complexData); + assertEquals(result.isNew, false); +}); diff --git a/src/storage/kv.ts b/src/storage/kv.ts new file mode 100644 index 0000000..1a9db99 --- /dev/null +++ b/src/storage/kv.ts @@ -0,0 +1,146 @@ +/// +// Deno KV storage implementation +import type { ISessionStore, LoadResult, SessionData } from "../types.ts"; + +/** + * Deno KV session store options + */ +export interface KvSessionStoreOptions { + /** + * KV instance (if omitted, obtained via Deno.openKv()) + */ + kv?: Deno.Kv; + /** + * KV key prefix + * @default ["sessions"] + */ + keyPrefix?: Deno.KvKey; +} + +/** + * Deno KV-based session store + * Enables persistent session management + */ +export class KvSessionStore implements ISessionStore { + #kv: Deno.Kv | undefined; + #kvPromise: Promise | undefined; + #keyPrefix: Deno.KvKey; + + constructor(options: KvSessionStoreOptions = {}) { + this.#kv = options.kv; + this.#keyPrefix = options.keyPrefix ?? ["sessions"]; + } + + // Note: cleanup() is not implemented for KvSessionStore + // Deno KV handles expiration automatically via expireIn option + + /** + * Get KV instance (lazy initialization) + */ + private async getKv(): Promise { + if (this.#kv) { + return this.#kv; + } + if (!this.#kvPromise) { + this.#kvPromise = Deno.openKv(); + } + this.#kv = await this.#kvPromise; + return this.#kv; + } + + /** + * Generate KV key from session ID + */ + private getKey(sessionId: string): Deno.KvKey { + return [...this.#keyPrefix, sessionId]; + } + + /** + * Restore session from cookie value (session ID) + */ + async load(cookieValue: string | undefined): Promise { + if (!cookieValue) { + return { + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }; + } + + const kv = await this.getKv(); + const entry = await kv.get<{ data: SessionData; expiresAt?: string }>( + this.getKey(cookieValue), + ); + + if (!entry.value) { + return { + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }; + } + + // Expiry check + if (entry.value.expiresAt && new Date(entry.value.expiresAt) < new Date()) { + // Delete if expired + await kv.delete(this.getKey(cookieValue)); + return { + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }; + } + + return { + sessionId: cookieValue, + data: entry.value.data, + isNew: false, + }; + } + + /** + * Save session and return value to set in cookie (session ID) + */ + async save( + sessionId: string, + data: SessionData, + expiresAt?: Date, + ): Promise { + const kv = await this.getKv(); + const value = { + data, + expiresAt: expiresAt?.toISOString(), + }; + + // Use expireIn option to set auto-expiry at KV level + const options: { expireIn?: number } = {}; + if (expiresAt) { + const expireIn = expiresAt.getTime() - Date.now(); + if (expireIn > 0) { + options.expireIn = expireIn; + } + } + + await kv.set(this.getKey(sessionId), value, options); + return sessionId; + } + + /** + * Destroy session + */ + async destroy(sessionId: string): Promise { + const kv = await this.getKv(); + await kv.delete(this.getKey(sessionId)); + } + + /** + * Close KV connection + */ + async close(): Promise { + if (this.#kv) { + await this.#kv.close(); + this.#kv = undefined; + this.#kvPromise = undefined; + } + } +} diff --git a/src/storage/kv_test.ts b/src/storage/kv_test.ts new file mode 100644 index 0000000..788795f --- /dev/null +++ b/src/storage/kv_test.ts @@ -0,0 +1,207 @@ +import { assertEquals, assertExists } from "@std/assert"; +import { KvSessionStore } from "./kv.ts"; + +// Test KV instance (in-memory) +async function createTestKv(): Promise { + return await Deno.openKv(":memory:"); +} + +Deno.test("KvSessionStore: load with undefined cookie creates new session", async () => { + const kv = await createTestKv(); + const store = new KvSessionStore({ kv }); + + try { + const result = await store.load(undefined); + + assertExists(result.sessionId); + assertEquals(result.data, {}); + assertEquals(result.isNew, true); + } finally { + kv.close(); + } +}); + +Deno.test("KvSessionStore: load with non-existent sessionId creates new session", async () => { + const kv = await createTestKv(); + const store = new KvSessionStore({ kv }); + + try { + const result = await store.load("non-existent-session"); + + assertExists(result.sessionId); + assertEquals(result.data, {}); + assertEquals(result.isNew, true); + } finally { + kv.close(); + } +}); + +Deno.test("KvSessionStore: save and load session data", async () => { + const kv = await createTestKv(); + const store = new KvSessionStore({ kv }); + + try { + // Create new session + const { sessionId } = await store.load(undefined); + const data = { userId: "user123", role: "admin" }; + + // Save data + const cookieValue = await store.save(sessionId, data); + assertEquals(cookieValue, sessionId); // KvStore returns sessionId as-is + + // Load data + const result = await store.load(sessionId); + assertEquals(result.sessionId, sessionId); + assertEquals(result.data, data); + assertEquals(result.isNew, false); + } finally { + kv.close(); + } +}); + +Deno.test("KvSessionStore: destroy removes session", async () => { + const kv = await createTestKv(); + const store = new KvSessionStore({ kv }); + + try { + // Create and save session + const { sessionId } = await store.load(undefined); + await store.save(sessionId, { foo: "bar" }); + + // Destroy + await store.destroy(sessionId); + + // After destruction, treated as new session + const result = await store.load(sessionId); + assertEquals(result.isNew, true); + assertEquals(result.data, {}); + } finally { + kv.close(); + } +}); + +Deno.test("KvSessionStore: expired session returns new session", async () => { + const kv = await createTestKv(); + const store = new KvSessionStore({ kv }); + + try { + const sessionId = "expired-session"; + const data = { temp: "data" }; + const pastDate = new Date(Date.now() - 10000); // 10 seconds ago + + await store.save(sessionId, data, pastDate); + const result = await store.load(sessionId); + + // Treated as new session because expired + assertEquals(result.isNew, true); + assertEquals(result.data, {}); + } finally { + kv.close(); + } +}); + +Deno.test("KvSessionStore: non-expired session returns data", async () => { + const kv = await createTestKv(); + const store = new KvSessionStore({ kv }); + + try { + const sessionId = "valid-session"; + const data = { active: true }; + const futureDate = new Date(Date.now() + 60000); // 1 minute later + + await store.save(sessionId, data, futureDate); + const result = await store.load(sessionId); + + assertEquals(result.sessionId, sessionId); + assertEquals(result.data, data); + assertEquals(result.isNew, false); + } finally { + kv.close(); + } +}); + +Deno.test("KvSessionStore: update existing session", async () => { + const kv = await createTestKv(); + const store = new KvSessionStore({ kv }); + + try { + const sessionId = "update-session"; + + await store.save(sessionId, { count: 1 }); + await store.save(sessionId, { count: 2 }); + + const result = await store.load(sessionId); + assertEquals(result.data, { count: 2 }); + assertEquals(result.isNew, false); + } finally { + kv.close(); + } +}); + +Deno.test("KvSessionStore: session with no expiry persists", async () => { + const kv = await createTestKv(); + const store = new KvSessionStore({ kv }); + + try { + const sessionId = "persistent-session"; + const data = { persistent: true }; + + await store.save(sessionId, data); + const result = await store.load(sessionId); + + assertEquals(result.data, data); + assertEquals(result.isNew, false); + } finally { + kv.close(); + } +}); + +Deno.test("KvSessionStore: custom key prefix", async () => { + const kv = await createTestKv(); + const store = new KvSessionStore({ kv, keyPrefix: ["custom", "prefix"] }); + + try { + const sessionId = "test-session"; + const data = { custom: "prefix" }; + + await store.save(sessionId, data); + + // Verify saved with custom prefix + const entry = await kv.get(["custom", "prefix", sessionId]); + assertExists(entry.value); + + const result = await store.load(sessionId); + assertEquals(result.data, data); + } finally { + kv.close(); + } +}); + +Deno.test("KvSessionStore: complex data types", async () => { + const kv = await createTestKv(); + const store = new KvSessionStore({ kv }); + + try { + const { sessionId } = await store.load(undefined); + const complexData = { + user: { + id: 123, + name: "Test User", + roles: ["admin", "user"], + }, + settings: { + theme: "dark", + notifications: true, + }, + lastLogin: "2024-01-01T00:00:00.000Z", + }; + + await store.save(sessionId, complexData); + const result = await store.load(sessionId); + + assertEquals(result.data, complexData); + assertEquals(result.isNew, false); + } finally { + kv.close(); + } +}); diff --git a/src/storage/memory.ts b/src/storage/memory.ts new file mode 100644 index 0000000..77dc863 --- /dev/null +++ b/src/storage/memory.ts @@ -0,0 +1,79 @@ +// Memory storage implementation +import type { ISessionStore, LoadResult, SessionData } from "../types.ts"; + +/** + * Simple in-memory session store + */ +export class MemorySessionStore implements ISessionStore { + private store = new Map(); + + /** + * Restore session from cookie value (session ID) + */ + load(cookieValue: string | undefined): Promise { + if (!cookieValue) { + return Promise.resolve({ + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }); + } + // If cookieValue exists and is in the store + if (!this.store.has(cookieValue)) { + return Promise.resolve({ + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }); + } + + const entry = this.store.get(cookieValue)!; + if (entry.expiresAt && entry.expiresAt < new Date()) { + // Delete if expired + this.store.delete(cookieValue); + return Promise.resolve({ + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }); + } + + return Promise.resolve({ + sessionId: cookieValue, + data: entry.data, + isNew: false, + }); + } + + /** + * Save session and return value to set in cookie (session ID) + */ + save( + sessionId: string, + data: SessionData, + expiresAt?: Date, + ): Promise { + this.store.set(sessionId, { data, expiresAt }); + return Promise.resolve(sessionId); + } + + /** + * Destroy session + */ + destroy(sessionId: string): Promise { + this.store.delete(sessionId); + return Promise.resolve(); + } + + /** + * Cleanup expired sessions + */ + cleanup(): void { + const now = new Date(); + for (const [sid, entry] of this.store.entries()) { + if (entry.expiresAt && entry.expiresAt < now) { + this.store.delete(sid); + } + } + } +} diff --git a/src/storage/memory_test.ts b/src/storage/memory_test.ts new file mode 100644 index 0000000..f08ab7b --- /dev/null +++ b/src/storage/memory_test.ts @@ -0,0 +1,138 @@ +import { assertEquals, assertExists } from "@std/assert"; +import { MemorySessionStore } from "./memory.ts"; + +Deno.test("MemorySessionStore: load with undefined cookie creates new session", async () => { + const store = new MemorySessionStore(); + + const result = await store.load(undefined); + + assertExists(result.sessionId); + assertEquals(result.data, {}); + assertEquals(result.isNew, true); +}); + +Deno.test("MemorySessionStore: load with non-existent sessionId creates new session", async () => { + const store = new MemorySessionStore(); + + const result = await store.load("non-existent-session"); + + assertExists(result.sessionId); + assertEquals(result.data, {}); + assertEquals(result.isNew, true); +}); + +Deno.test("MemorySessionStore: save and load session data", async () => { + const store = new MemorySessionStore(); + + // Create new session + const { sessionId } = await store.load(undefined); + const data = { userId: "user123", role: "admin" }; + + // Save data + const cookieValue = await store.save(sessionId, data); + assertEquals(cookieValue, sessionId); // MemoryStore returns sessionId as-is + + // Load data + const result = await store.load(sessionId); + assertEquals(result.sessionId, sessionId); + assertEquals(result.data, data); + assertEquals(result.isNew, false); +}); + +Deno.test("MemorySessionStore: destroy removes session", async () => { + const store = new MemorySessionStore(); + + // Create and save session + const { sessionId } = await store.load(undefined); + await store.save(sessionId, { foo: "bar" }); + + // Destroy + await store.destroy(sessionId); + + // After destruction, treated as new session + const result = await store.load(sessionId); + assertEquals(result.isNew, true); + assertEquals(result.data, {}); +}); + +Deno.test("MemorySessionStore: expired session returns new session", async () => { + const store = new MemorySessionStore(); + const sessionId = "expired-session"; + const data = { temp: "data" }; + const pastDate = new Date(Date.now() - 10000); // 10 seconds ago + + await store.save(sessionId, data, pastDate); + const result = await store.load(sessionId); + + // Treated as new session because expired + assertEquals(result.isNew, true); + assertEquals(result.data, {}); +}); + +Deno.test("MemorySessionStore: non-expired session returns data", async () => { + const store = new MemorySessionStore(); + const sessionId = "valid-session"; + const data = { active: true }; + const futureDate = new Date(Date.now() + 60000); // 1 minute later + + await store.save(sessionId, data, futureDate); + const result = await store.load(sessionId); + + assertEquals(result.sessionId, sessionId); + assertEquals(result.data, data); + assertEquals(result.isNew, false); +}); + +Deno.test("MemorySessionStore: cleanup removes expired sessions", async () => { + const store = new MemorySessionStore(); + + // Expired sessions + await store.save("expired-1", { a: 1 }, new Date(Date.now() - 10000)); + await store.save("expired-2", { b: 2 }, new Date(Date.now() - 5000)); + + // Valid sessions + await store.save("valid-1", { c: 3 }, new Date(Date.now() + 60000)); + await store.save("no-expiry", { d: 4 }); // No expiry + + store.cleanup(); + + // Expired sessions are deleted (treated as new) + const expired1 = await store.load("expired-1"); + assertEquals(expired1.isNew, true); + + const expired2 = await store.load("expired-2"); + assertEquals(expired2.isNew, true); + + // Valid sessions remain + const valid1 = await store.load("valid-1"); + assertEquals(valid1.data, { c: 3 }); + assertEquals(valid1.isNew, false); + + const noExpiry = await store.load("no-expiry"); + assertEquals(noExpiry.data, { d: 4 }); + assertEquals(noExpiry.isNew, false); +}); + +Deno.test("MemorySessionStore: update existing session", async () => { + const store = new MemorySessionStore(); + const sessionId = "update-session"; + + await store.save(sessionId, { count: 1 }); + await store.save(sessionId, { count: 2 }); + + const result = await store.load(sessionId); + assertEquals(result.data, { count: 2 }); + assertEquals(result.isNew, false); +}); + +Deno.test("MemorySessionStore: session with no expiry persists", async () => { + const store = new MemorySessionStore(); + const sessionId = "persistent-session"; + const data = { persistent: true }; + + await store.save(sessionId, data); + const result = await store.load(sessionId); + + assertEquals(result.data, data); + assertEquals(result.isNew, false); +}); diff --git a/src/storage/redis.ts b/src/storage/redis.ts new file mode 100644 index 0000000..f987bfd --- /dev/null +++ b/src/storage/redis.ts @@ -0,0 +1,137 @@ +// Redis storage implementation +import type { ISessionStore, LoadResult, SessionData } from "../types.ts"; + +/** + * Redis connection interface + * Abstraction for compatibility with various Redis clients + */ +export interface RedisClient { + get(key: string): Promise; + set(key: string, value: string, options?: { ex?: number }): Promise; + del(key: string): Promise; +} + +/** + * Redis session store options + */ +export interface RedisSessionStoreOptions { + /** + * Redis client instance + */ + client: RedisClient; + /** + * Key prefix + * @default "session:" + */ + keyPrefix?: string; +} + +/** + * Redis-based session store + * Enables persistent session management in distributed environments + */ +export class RedisSessionStore implements ISessionStore { + #client: RedisClient; + #keyPrefix: string; + + constructor(options: RedisSessionStoreOptions) { + this.#client = options.client; + this.#keyPrefix = options.keyPrefix ?? "session:"; + } + + /** + * Generate Redis key from session ID + */ + private getKey(sessionId: string): string { + return `${this.#keyPrefix}${sessionId}`; + } + + /** + * Restore session from cookie value (session ID) + */ + async load(cookieValue: string | undefined): Promise { + if (!cookieValue) { + return { + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }; + } + + try { + const value = await this.#client.get(this.getKey(cookieValue)); + + if (!value) { + return { + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }; + } + + const parsed = JSON.parse(value) as { + data: SessionData; + expiresAt?: string; + }; + + // Expiry check (check at app level in addition to Redis TTL) + if (parsed.expiresAt && new Date(parsed.expiresAt) < new Date()) { + await this.#client.del(this.getKey(cookieValue)); + return { + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }; + } + + return { + sessionId: cookieValue, + data: parsed.data, + isNew: false, + }; + } catch { + // Return new session on parse failure + return { + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }; + } + } + + /** + * Save session and return value to set in cookie (session ID) + */ + async save( + sessionId: string, + data: SessionData, + expiresAt?: Date, + ): Promise { + const value = { + data, + expiresAt: expiresAt?.toISOString(), + }; + + const options: { ex?: number } = {}; + if (expiresAt) { + const ttl = Math.floor((expiresAt.getTime() - Date.now()) / 1000); + if (ttl > 0) { + options.ex = ttl; + } + } + + await this.#client.set( + this.getKey(sessionId), + JSON.stringify(value), + options, + ); + return sessionId; + } + + /** + * Destroy session + */ + async destroy(sessionId: string): Promise { + await this.#client.del(this.getKey(sessionId)); + } +} diff --git a/src/storage/redis_test.ts b/src/storage/redis_test.ts new file mode 100644 index 0000000..cddce11 --- /dev/null +++ b/src/storage/redis_test.ts @@ -0,0 +1,289 @@ +import { assertEquals, assertExists } from "@std/assert"; +import { connect } from "@db/redis"; +import { type RedisClient, RedisSessionStore } from "./redis.ts"; + +type ConnectedRedisClient = RedisClient & { + flushall: () => Promise; + close: () => Promise; +}; + +export async function createRedisClient(): Promise { + const redisHost = Deno.env.get("REDIS_HOST") ?? "127.0.0.1"; + const redisPort = Number(Deno.env.get("REDIS_PORT") ?? "6379"); + const redis = await connect({ hostname: redisHost, port: redisPort }); + + return { + get: (key: string) => redis.get(key), + set: (key: string, value: string, options?: { ex?: number }) => + redis + .set(key, value, options?.ex ? { ex: options.ex } : undefined) + .then(() => {}), + del: (key: string) => redis.del(key).then(() => {}), + flushall: () => redis.flushdb().then(() => {}), + close: async () => { + await redis.close(); + }, + }; +} + +Deno.test({ + name: "RedisSessionStore: load with undefined cookie creates new session", + fn: async () => { + const client = await createRedisClient(); + const store = new RedisSessionStore({ client }); + + try { + const result = await store.load(undefined); + + assertExists(result.sessionId); + assertEquals(result.data, {}); + assertEquals(result.isNew, true); + } finally { + await client.flushall(); + await client.close(); + } + }, +}); + +Deno.test({ + name: + "RedisSessionStore: load with non-existent sessionId creates new session", + fn: async () => { + const client = await createRedisClient(); + const store = new RedisSessionStore({ client }); + + try { + const result = await store.load("non-existent-session"); + + assertExists(result.sessionId); + assertEquals(result.data, {}); + assertEquals(result.isNew, true); + } finally { + await client.flushall(); + await client.close(); + } + }, +}); + +Deno.test({ + name: "RedisSessionStore: save and load session data", + fn: async () => { + const client = await createRedisClient(); + const store = new RedisSessionStore({ client }); + + try { + // Create new session + const { sessionId } = await store.load(undefined); + const data = { userId: "user123", role: "admin" }; + + // Save data + const cookieValue = await store.save(sessionId, data); + assertEquals(cookieValue, sessionId); // RedisStore returns sessionId as-is + + // Load data + const result = await store.load(sessionId); + assertEquals(result.sessionId, sessionId); + assertEquals(result.data, data); + assertEquals(result.isNew, false); + } finally { + await client.flushall(); + await client.close(); + } + }, +}); + +Deno.test({ + name: "RedisSessionStore: destroy removes session", + fn: async () => { + const client = await createRedisClient(); + const store = new RedisSessionStore({ client }); + + try { + // Create and save session + const { sessionId } = await store.load(undefined); + await store.save(sessionId, { foo: "bar" }); + + // Destroy + await store.destroy(sessionId); + + // After destruction, treated as new session + const result = await store.load(sessionId); + assertEquals(result.isNew, true); + assertEquals(result.data, {}); + } finally { + await client.flushall(); + await client.close(); + } + }, +}); + +Deno.test({ + name: "RedisSessionStore: expired session returns new session", + fn: async () => { + const client = await createRedisClient(); + const store = new RedisSessionStore({ client }); + + try { + const sessionId = "expired-session"; + const data = { temp: "data" }; + const pastDate = new Date(Date.now() - 10000); // 10 seconds ago + + await store.save(sessionId, data, pastDate); + const result = await store.load(sessionId); + + // Treated as new session because expired + assertEquals(result.isNew, true); + assertEquals(result.data, {}); + } finally { + await client.flushall(); + await client.close(); + } + }, +}); + +Deno.test({ + name: "RedisSessionStore: non-expired session returns data", + fn: async () => { + const client = await createRedisClient(); + const store = new RedisSessionStore({ client }); + + try { + const sessionId = "valid-session"; + const data = { active: true }; + const futureDate = new Date(Date.now() + 60000); // 1 minute later + + await store.save(sessionId, data, futureDate); + const result = await store.load(sessionId); + + assertEquals(result.sessionId, sessionId); + assertEquals(result.data, data); + assertEquals(result.isNew, false); + } finally { + await client.flushall(); + await client.close(); + } + }, +}); + +Deno.test({ + name: "RedisSessionStore: update existing session", + fn: async () => { + const client = await createRedisClient(); + const store = new RedisSessionStore({ client }); + + try { + const sessionId = "update-session"; + + await store.save(sessionId, { count: 1 }); + await store.save(sessionId, { count: 2 }); + + const result = await store.load(sessionId); + assertEquals(result.data, { count: 2 }); + assertEquals(result.isNew, false); + } finally { + await client.flushall(); + await client.close(); + } + }, +}); + +Deno.test({ + name: "RedisSessionStore: session with no expiry persists", + fn: async () => { + const client = await createRedisClient(); + const store = new RedisSessionStore({ client }); + + try { + const sessionId = "persistent-session"; + const data = { persistent: true }; + + await store.save(sessionId, data); + const result = await store.load(sessionId); + + assertEquals(result.data, data); + assertEquals(result.isNew, false); + } finally { + await client.flushall(); + await client.close(); + } + }, +}); + +Deno.test({ + name: "RedisSessionStore: custom key prefix", + fn: async () => { + const client = await createRedisClient(); + const store = new RedisSessionStore({ client, keyPrefix: "custom:" }); + + try { + const sessionId = "test-session"; + const data = { custom: "prefix" }; + + await store.save(sessionId, data); + + // Verify saved with custom prefix + const storedValue = await client.get("custom:test-session"); + assertExists(storedValue); + + const result = await store.load(sessionId); + assertEquals(result.data, data); + } finally { + await client.flushall(); + await client.close(); + } + }, +}); + +Deno.test({ + name: "RedisSessionStore: complex data types", + fn: async () => { + const client = await createRedisClient(); + const store = new RedisSessionStore({ client }); + + try { + const { sessionId } = await store.load(undefined); + const complexData = { + user: { + id: 123, + name: "Test User", + roles: ["admin", "user"], + }, + settings: { + theme: "dark", + notifications: true, + }, + lastLogin: "2024-01-01T00:00:00.000Z", + }; + + await store.save(sessionId, complexData); + const result = await store.load(sessionId); + + assertEquals(result.data, complexData); + assertEquals(result.isNew, false); + } finally { + await client.flushall(); + await client.close(); + } + }, +}); + +Deno.test({ + name: "RedisSessionStore: invalid JSON in Redis returns new session", + fn: async () => { + const client = await createRedisClient(); + const store = new RedisSessionStore({ client }); + + try { + // Set invalid JSON directly + await client.set("session:invalid-json", "not valid json"); + + const result = await store.load("invalid-json"); + + assertEquals(result.isNew, true); + assertEquals(result.data, {}); + } finally { + await client.flushall(); + await client.close(); + } + }, +}); diff --git a/src/storage/sql.ts b/src/storage/sql.ts new file mode 100644 index 0000000..e188e1c --- /dev/null +++ b/src/storage/sql.ts @@ -0,0 +1,201 @@ +// SQL storage implementation +import type { ISessionStore, LoadResult, SessionData } from "../types.ts"; + +/** + * SQL connection interface + * Abstraction for compatibility with various SQL clients + */ +export interface SqlClient { + execute( + sql: string, + params?: unknown[], + ): Promise<{ rows?: Record[] }>; +} + +/** + * SQL session store options + */ +export interface SqlSessionStoreOptions { + /** + * SQL client instance + */ + client: SqlClient; + /** + * SQL dialect + * @default "mysql" + */ + dialect?: "mysql" | "postgres"; + /** + * Table name + * @default "sessions" + */ + tableName?: string; +} + +/** + * SQL-based session store + * Enables persistent session management with RDBMS + * + * Required table structure: + * ```sql + * CREATE TABLE sessions ( + * session_id VARCHAR(36) PRIMARY KEY, + * data TEXT NOT NULL, + * expires_at DATETIME NULL + * ); + * CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); + * ``` + */ +export class SqlSessionStore implements ISessionStore { + #client: SqlClient; + #tableName: string; + #dialect: "mysql" | "postgres"; + + constructor(options: SqlSessionStoreOptions) { + this.#client = options.client; + this.#tableName = options.tableName ?? "sessions"; + this.#dialect = options.dialect ?? "mysql"; + } + + /** + * Restore session from cookie value (session ID) + */ + async load(cookieValue: string | undefined): Promise { + if (!cookieValue) { + return { + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }; + } + + try { + const selectPlaceholder = this.#dialect === "postgres" ? "$1" : "?"; + const result = await this.#client.execute( + `SELECT data, expires_at FROM ${this.#tableName} WHERE session_id = ${selectPlaceholder}`, + [cookieValue], + ); + + if (!result.rows || result.rows.length === 0) { + return { + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }; + } + + const row = result.rows[0]; + + // Expiry check + if (row.expires_at) { + const rawExpires = row.expires_at instanceof Date + ? row.expires_at + : typeof row.expires_at === "string" + ? row.expires_at + : row.expires_at instanceof Uint8Array + ? new TextDecoder().decode(row.expires_at) + : String(row.expires_at); + + const expiresAt = rawExpires instanceof Date + ? new Date(Date.UTC( + rawExpires.getFullYear(), + rawExpires.getMonth(), + rawExpires.getDate(), + rawExpires.getHours(), + rawExpires.getMinutes(), + rawExpires.getSeconds(), + rawExpires.getMilliseconds(), + )) + : (() => { + const normalized = rawExpires.replace(" ", "T"); + const hasZone = /Z|[+-]\d{2}:?\d{2}$/.test(normalized); + const isoValue = hasZone ? normalized : `${normalized}Z`; + return new Date(isoValue); + })(); + if (expiresAt < new Date()) { + // Delete if expired + await this.destroy(cookieValue); + return { + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }; + } + } + + const rawData = row.data; + const dataText = typeof rawData === "string" + ? rawData + : rawData instanceof Uint8Array + ? new TextDecoder().decode(rawData) + : String(rawData); + const data = JSON.parse(dataText) as SessionData; + + return { + sessionId: cookieValue, + data, + isNew: false, + }; + } catch { + // Return new session on error + return { + sessionId: crypto.randomUUID(), + data: {}, + isNew: true, + }; + } + } + + /** + * Save session and return value to set in cookie (session ID) + */ + async save( + sessionId: string, + data: SessionData, + expiresAt?: Date, + ): Promise { + const dataJson = JSON.stringify(data); + const expiresAtStr = + expiresAt?.toISOString().slice(0, 19).replace("T", " ") ?? null; + + if (this.#dialect === "postgres") { + await this.#client.execute( + `INSERT INTO ${this.#tableName} (session_id, data, expires_at) + VALUES ($1, $2, $3) + ON CONFLICT (session_id) + DO UPDATE SET data = EXCLUDED.data, expires_at = EXCLUDED.expires_at`, + [sessionId, dataJson, expiresAtStr], + ); + } else { + // UPSERT (INSERT ... ON DUPLICATE KEY UPDATE) + await this.#client.execute( + `INSERT INTO ${this.#tableName} (session_id, data, expires_at) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE data = VALUES(data), expires_at = VALUES(expires_at)`, + [sessionId, dataJson, expiresAtStr], + ); + } + + return sessionId; + } + + /** + * Destroy session + */ + async destroy(sessionId: string): Promise { + const deletePlaceholder = this.#dialect === "postgres" ? "$1" : "?"; + await this.#client.execute( + `DELETE FROM ${this.#tableName} WHERE session_id = ${deletePlaceholder}`, + [sessionId], + ); + } + + /** + * Cleanup expired sessions + */ + async cleanup(): Promise { + await this.#client.execute( + `DELETE FROM ${this.#tableName} WHERE expires_at IS NOT NULL AND expires_at < NOW()`, + ); + } +} diff --git a/src/storage/sql_test.ts b/src/storage/sql_test.ts new file mode 100644 index 0000000..9b5ca25 --- /dev/null +++ b/src/storage/sql_test.ts @@ -0,0 +1,288 @@ +import { assertEquals, assertExists } from "@std/assert"; +import { type SqlClient, SqlSessionStore } from "./sql.ts"; +import mysql from "mysql2/promise"; + +const MYSQL_HOST = Deno.env.get("MYSQL_HOST") ?? "127.0.0.1"; +const MYSQL_PORT = Number(Deno.env.get("MYSQL_PORT") ?? "3307"); +const MYSQL_USER = Deno.env.get("MYSQL_USER") ?? "root"; +const MYSQL_PASSWORD = Deno.env.get("MYSQL_PASSWORD") ?? "root"; +const MYSQL_DATABASE = Deno.env.get("MYSQL_DATABASE") ?? "fresh_session"; +const MYSQL_TABLE = Deno.env.get("MYSQL_TABLE") ?? "sessions"; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function uniqueTableName(prefix: string): string { + const suffix = crypto.randomUUID().replace(/-/g, "").slice(0, 8); + return `${prefix}_${suffix}`; +} + +function sqlTest( + name: string, + fn: () => Promise, +): void { + Deno.test({ + name, + sanitizeResources: false, + sanitizeOps: false, + fn, + }); +} + +type MysqlExecutor = { + query: (sql: string, params?: unknown[]) => Promise<[unknown, unknown]>; +}; + +type MysqlPool = MysqlExecutor & { end: () => Promise }; + +async function ensureTable( + executor: MysqlExecutor, + tableName: string, +): Promise { + await executor.query( + ` + CREATE TABLE IF NOT EXISTS ${tableName} ( + session_id VARCHAR(36) PRIMARY KEY, + data TEXT NOT NULL, + expires_at DATETIME NULL + ); + `, + ); + + try { + await executor.query( + `CREATE INDEX IF NOT EXISTS idx_${tableName}_expires_at ON ${tableName}(expires_at);`, + ); + } catch { + // Ignore if the index already exists or IF NOT EXISTS is unsupported + } +} + +async function clearTable( + executor: MysqlExecutor, + tableName: string, +): Promise { + await executor.query(`DELETE FROM ${tableName}`); +} + +async function withStore( + tableName: string, + fn: (store: SqlSessionStore) => Promise, +): Promise { + let pool: MysqlPool | undefined; + const maxAttempts = 30; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const candidate = mysql.createPool({ + host: MYSQL_HOST, + port: MYSQL_PORT, + user: MYSQL_USER, + password: MYSQL_PASSWORD, + database: MYSQL_DATABASE, + }) as unknown as MysqlPool; + + try { + await candidate.query("SELECT 1"); + pool = candidate; + break; + } catch { + await candidate.end().catch(() => {}); + await delay(1000); + } + } + + if (!pool) { + throw new Error("Failed to connect to MySQL after multiple attempts."); + } + + const client: SqlClient = { + execute: async (sql: string, params: unknown[] = []) => { + const [rows] = await pool.query(sql, params); + return { + rows: Array.isArray(rows) ? (rows as Record[]) : [], + }; + }, + }; + + await ensureTable(pool, tableName); + await clearTable(pool, tableName); + + const store = new SqlSessionStore({ client, tableName }); + + try { + return await fn(store); + } finally { + await pool.end(); + } +} + +sqlTest( + "SqlSessionStore: load with undefined cookie creates new session", + async () => { + await withStore(uniqueTableName(MYSQL_TABLE), async (store) => { + const result = await store.load(undefined); + + assertExists(result.sessionId); + assertEquals(result.data, {}); + assertEquals(result.isNew, true); + }); + }, +); + +sqlTest( + "SqlSessionStore: load with non-existent sessionId creates new session", + async () => { + await withStore(uniqueTableName(MYSQL_TABLE), async (store) => { + const result = await store.load("non-existent-session"); + + assertExists(result.sessionId); + assertEquals(result.data, {}); + assertEquals(result.isNew, true); + }); + }, +); + +sqlTest("SqlSessionStore: save and load session data", async () => { + await withStore(uniqueTableName(MYSQL_TABLE), async (store) => { + const { sessionId } = await store.load(undefined); + const data = { userId: "user123", role: "admin" }; + + const cookieValue = await store.save(sessionId, data); + assertEquals(cookieValue, sessionId); + + const result = await store.load(sessionId); + assertEquals(result.sessionId, sessionId); + assertEquals(result.data, data); + assertEquals(result.isNew, false); + }); +}); + +sqlTest("SqlSessionStore: destroy removes session", async () => { + await withStore(uniqueTableName(MYSQL_TABLE), async (store) => { + const { sessionId } = await store.load(undefined); + await store.save(sessionId, { foo: "bar" }); + + await store.destroy(sessionId); + + const result = await store.load(sessionId); + assertEquals(result.isNew, true); + assertEquals(result.data, {}); + }); +}); + +sqlTest("SqlSessionStore: expired session returns new session", async () => { + await withStore(uniqueTableName(MYSQL_TABLE), async (store) => { + const sessionId = "expired-session"; + const data = { temp: "data" }; + const pastDate = new Date(Date.now() - 10000); + + await store.save(sessionId, data, pastDate); + const result = await store.load(sessionId); + + assertEquals(result.isNew, true); + assertEquals(result.data, {}); + }); +}); + +sqlTest("SqlSessionStore: non-expired session returns data", async () => { + await withStore(uniqueTableName(MYSQL_TABLE), async (store) => { + const sessionId = "valid-session"; + const data = { active: true }; + const futureDate = new Date(Date.now() + 60000); + + await store.save(sessionId, data, futureDate); + const result = await store.load(sessionId); + + assertEquals(result.sessionId, sessionId); + assertEquals(result.data, data); + assertEquals(result.isNew, false); + }); +}); + +sqlTest("SqlSessionStore: update existing session", async () => { + await withStore(uniqueTableName(MYSQL_TABLE), async (store) => { + const sessionId = "update-session"; + + await store.save(sessionId, { count: 1 }); + await store.save(sessionId, { count: 2 }); + + const result = await store.load(sessionId); + assertEquals(result.data, { count: 2 }); + assertEquals(result.isNew, false); + }); +}); + +sqlTest("SqlSessionStore: session with no expiry persists", async () => { + await withStore(uniqueTableName(MYSQL_TABLE), async (store) => { + const sessionId = "persistent-session"; + const data = { persistent: true }; + + await store.save(sessionId, data); + const result = await store.load(sessionId); + + assertEquals(result.data, data); + assertEquals(result.isNew, false); + }); +}); + +sqlTest("SqlSessionStore: custom table name", async () => { + await withStore(uniqueTableName("custom_sessions"), async (store) => { + const sessionId = "test-session"; + const data = { custom: "table" }; + + await store.save(sessionId, data); + const result = await store.load(sessionId); + + assertEquals(result.data, data); + }); +}); + +sqlTest("SqlSessionStore: complex data types", async () => { + await withStore(uniqueTableName(MYSQL_TABLE), async (store) => { + const { sessionId } = await store.load(undefined); + const complexData = { + user: { + id: 123, + name: "Test User", + roles: ["admin", "user"], + }, + settings: { + theme: "dark", + notifications: true, + }, + lastLogin: "2024-01-01T00:00:00.000Z", + }; + + await store.save(sessionId, complexData); + const result = await store.load(sessionId); + + assertEquals(result.data, complexData); + assertEquals(result.isNew, false); + }); +}); + +sqlTest("SqlSessionStore: cleanup removes expired sessions", async () => { + await withStore(uniqueTableName(MYSQL_TABLE), async (store) => { + await store.save("expired-1", { a: 1 }, new Date(Date.now() - 10000)); + await store.save("expired-2", { b: 2 }, new Date(Date.now() - 5000)); + + await store.save("valid-1", { c: 3 }, new Date(Date.now() + 60000)); + await store.save("no-expiry", { d: 4 }); + + await store.cleanup(); + + const expired1 = await store.load("expired-1"); + assertEquals(expired1.isNew, true); + + const expired2 = await store.load("expired-2"); + assertEquals(expired2.isNew, true); + + const valid1 = await store.load("valid-1"); + assertEquals(valid1.data, { c: 3 }); + assertEquals(valid1.isNew, false); + + const noExpiry = await store.load("no-expiry"); + assertEquals(noExpiry.data, { d: 4 }); + assertEquals(noExpiry.isNew, false); + }); +}); diff --git a/src/stores/cookie.ts b/src/stores/cookie.ts deleted file mode 100644 index f51e249..0000000 --- a/src/stores/cookie.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - getCookies, - ironDefaults, - MiddlewareHandlerContext, - seal, - setCookie, - unseal, -} from "../deps.ts"; -import { type CookieOptions } from "./cookie_option.ts"; -import { Session } from "../session.ts"; -import type { WithSession } from "./interface.ts"; - -export function createCookieSessionStorage(cookieOptions?: CookieOptions) { - let cookieOptionsParam = cookieOptions; - if (!cookieOptionsParam) { - cookieOptionsParam = {}; - } - - return CookieSessionStorage.init(cookieOptionsParam); -} - -export class CookieSessionStorage { - #cookieOptions: CookieOptions; - - constructor(cookieOptions: CookieOptions) { - this.#cookieOptions = cookieOptions; - } - - static init(cookieOptions: CookieOptions) { - return new this(cookieOptions); - } - - create() { - return new Session(); - } - - exists(sessionId: string) { - return unseal( - globalThis.crypto, - sessionId, - Deno.env.get("APP_KEY") as string, - ironDefaults, - ) - .then(() => true) - .catch((e) => { - console.warn("Invalid session, creating new session..."); - return false; - }); - } - - async get(sessionId: string) { - const decryptedData = await unseal( - globalThis.crypto, - sessionId, - Deno.env.get("APP_KEY") as string, - ironDefaults, - ); - - const { _flash = {}, ...data } = decryptedData; - return new Session(data as object, _flash); - } - - async persist(response: Response, session: Session) { - if (session.doKeyRotate) { - this.keyRotate(); - } - - const encryptedData = await seal( - globalThis.crypto, - { ...session.data, _flash: session.flashedData }, - Deno.env.get("APP_KEY") as string, - ironDefaults, - ); - - setCookie(response.headers, { - name: "sessionId", - value: encryptedData, - path: "/", - ...this.#cookieOptions, - }); - - return response; - } - /** - * Does not work in cookie sessions. - */ - keyRotate() { - console.warn( - "%c*****************************************************\n* '.keyRotate' is not supported for cookie sessions *\n*****************************************************", - "color: yellow;", - ); - } -} - -export function cookieSession(cookieOptions?: CookieOptions) { - return async function ( - req: Request, - ctx: MiddlewareHandlerContext, - ): Promise { - const { sessionId } = getCookies(req.headers); - const cookieSessionStorage = await createCookieSessionStorage( - cookieOptions, - ); - - if (sessionId && (await cookieSessionStorage.exists(sessionId))) { - ctx.state.session = await cookieSessionStorage.get(sessionId); - } - - if (!ctx.state.session) { - ctx.state.session = cookieSessionStorage.create(); - } - - const response = await ctx.next(); - - return cookieSessionStorage.persist(response, ctx.state.session); - }; -} diff --git a/src/stores/cookie_option.ts b/src/stores/cookie_option.ts deleted file mode 100644 index 31fab5e..0000000 --- a/src/stores/cookie_option.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { type Cookie } from "../deps.ts"; - -export type CookieOptions = Omit; -export type CookieWithRedisOptions = CookieOptions & { keyPrefix?: string }; diff --git a/src/stores/interface.ts b/src/stores/interface.ts deleted file mode 100644 index 341f475..0000000 --- a/src/stores/interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { MiddlewareHandlerContext } from "../deps.ts"; -import { Session } from "../session.ts"; -export type WithSession = { - session: Session; -}; - -export type sessionModule = ( - req: Request, - ctx: MiddlewareHandlerContext, -) => Promise; diff --git a/src/stores/kv.ts b/src/stores/kv.ts deleted file mode 100644 index 50d71a3..0000000 --- a/src/stores/kv.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { - deleteCookie, - getCookies, - MiddlewareHandlerContext, - setCookie, -} from "../deps.ts"; -import { type CookieOptions, CookieWithRedisOptions } from "./cookie_option.ts"; -import { Session } from "../session.ts"; -import type { WithSession } from "./interface.ts"; - -export function createKvSessionStorage( - sessionId: string, - store: Deno.Kv, - keyPrefix: string, - cookieOptions?: CookieOptions, -) { - let cookieOptionsParam = cookieOptions; - if (!cookieOptionsParam) { - cookieOptionsParam = {}; - } - - return KvSessionStorage.init( - sessionId, - store, - keyPrefix, - cookieOptionsParam, - ); -} - -export class KvSessionStorage { - #sessionKey: string; - #keyPrefix: string; - #store: Deno.Kv; - #cookieOptions: CookieOptions; - constructor( - key: string, - store: Deno.Kv, - keyPrefix: string, - cookieOptions: CookieOptions, - ) { - this.#sessionKey = key; - this.#store = store; - this.#keyPrefix = keyPrefix; - this.#cookieOptions = cookieOptions; - } - - static init( - sessionKey: string | undefined, - store: Deno.Kv, - keyPrefix: string, - cookieOptions: CookieOptions, - ) { - let key = !sessionKey ? crypto.randomUUID() : sessionKey; - - return new this(key, store, keyPrefix, cookieOptions); - } - - get key() { - return `${this.#keyPrefix}${this.#sessionKey}`; - } - - create() { - return new Session(); - } - - async exists(): Promise { - return !(await this.#store.get(["fresh-session", this.key]).value); - } - - async get() { - const { _flash = {}, data } = { - ...(await this.#store.get(["fresh-session", this.key])).value, - }; - - return new Session(data as object, _flash); - } - - async persist(response: Response, session: Session) { - if (session.doKeyRotate) { - this.keyRotate(); - } - - if (session.doDelete) { - await this.#store.delete(["fresh-session", this.key]); - - deleteCookie(response.headers, "sessionId"); - } else { - let redisOptions: { ex?: number } = {}; - - if (this.#cookieOptions?.maxAge) { - redisOptions.ex = this.#cookieOptions.maxAge; - } - if (this.#cookieOptions?.expires) { - redisOptions.ex = Math.round( - ((this.#cookieOptions?.expires).getTime() - new Date().getTime()) / - 1000, - ); - } - - await this.#store.set( - ["fresh-session", this.key], - { data: session.data, _flash: session.flashedData }, - redisOptions, - ); - - setCookie(response.headers, { - name: "sessionId", - value: this.#sessionKey, - path: "/", - ...this.#cookieOptions, - }); - } - - return response; - } - keyRotate() { - this.#sessionKey = crypto.randomUUID(); - } -} - -function hasKeyPrefix( - cookieWithRedisOptions: any, -): cookieWithRedisOptions is { keyPrefix: string } { - if (!cookieWithRedisOptions) return false; - if (typeof cookieWithRedisOptions !== "object") return false; - if (!cookieWithRedisOptions.keyPrefix) return false; - if (typeof cookieWithRedisOptions.keyPrefix !== "string") return false; - return true; -} - -export function kvSession( - storePath: string | null, - cookieWithRedisOptions?: CookieWithRedisOptions, -) { - let setupKeyPrefix = "session_"; - let setupCookieOptions = cookieWithRedisOptions; - - if (hasKeyPrefix(cookieWithRedisOptions)) { - const { keyPrefix, ...cookieOptions } = cookieWithRedisOptions; - setupKeyPrefix = keyPrefix; - setupCookieOptions = cookieOptions; - } - - return async function ( - req: Request, - ctx: MiddlewareHandlerContext, - ) { - const { sessionId } = getCookies(req.headers); - - const kvStore = await Deno.openKv(storePath); - const kvSessionStorage = await createKvSessionStorage( - sessionId, - kvStore, - setupKeyPrefix, - setupCookieOptions, - ); - - if (sessionId && (await kvSessionStorage.exists())) { - ctx.state.session = await kvSessionStorage.get(); - } - - if (!ctx.state.session) { - ctx.state.session = kvSessionStorage.create(); - } - const response = await ctx.next(); - - const persistedResponse = await kvSessionStorage.persist( - response, - ctx.state.session, - ); - - await kvStore.close(); - - return persistedResponse; - }; -} diff --git a/src/stores/redis.ts b/src/stores/redis.ts deleted file mode 100644 index 9c959a6..0000000 --- a/src/stores/redis.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { - deleteCookie, - getCookies, - MiddlewareHandlerContext, - setCookie, -} from "../deps.ts"; -import { type CookieOptions, CookieWithRedisOptions } from "./cookie_option.ts"; -import { Session } from "../session.ts"; -import type { WithSession } from "./interface.ts"; - -export interface Store { - set: Function; - get: Function; - del: Function; -} - -export function createRedisSessionStorage( - sessionId: string, - store: Store, - keyPrefix: string, - cookieOptions?: CookieOptions, -) { - let cookieOptionsParam = cookieOptions; - if (!cookieOptionsParam) { - cookieOptionsParam = {}; - } - - return RedisSessionStorage.init( - sessionId, - store, - keyPrefix, - cookieOptionsParam, - ); -} - -export class RedisSessionStorage { - #sessionKey: string; - #keyPrefix: string; - #store: Store; - #cookieOptions: CookieOptions; - constructor( - key: string, - store: Store, - keyPrefix: string, - cookieOptions: CookieOptions, - ) { - this.#sessionKey = key; - this.#store = store; - this.#keyPrefix = keyPrefix; - this.#cookieOptions = cookieOptions; - } - - static init( - sessionKey: string | undefined, - store: Store, - keyPrefix: string, - cookieOptions: CookieOptions, - ) { - let key = !sessionKey ? crypto.randomUUID() : sessionKey; - - return new this(key, store, keyPrefix, cookieOptions); - } - - get key() { - return `${this.#keyPrefix}${this.#sessionKey}`; - } - - create() { - return new Session(); - } - - async exists(): Promise { - return !!(await this.#store.get(this.key)); - } - - async get() { - const { _flash = {}, data } = JSON.parse(await this.#store.get(this.key)); - return new Session(data as object, _flash); - } - - async persist(response: Response, session: Session) { - if (session.doKeyRotate) { - this.keyRotate(); - } - - if (session.doDelete) { - await this.#store.del(this.key); - - deleteCookie(response.headers, "sessionId"); - } else { - let redisOptions: { ex?: number } = {}; - - if (this.#cookieOptions?.maxAge) { - redisOptions.ex = this.#cookieOptions.maxAge; - } - if (this.#cookieOptions?.expires) { - redisOptions.ex = Math.round( - ((this.#cookieOptions?.expires).getTime() - new Date().getTime()) / - 1000, - ); - } - - await this.#store.set( - this.key, - JSON.stringify({ data: session.data, _flash: session.flashedData }), - redisOptions, - ); - - setCookie(response.headers, { - name: "sessionId", - value: this.#sessionKey, - path: "/", - ...this.#cookieOptions, - }); - } - - return response; - } - keyRotate() { - this.#sessionKey = crypto.randomUUID(); - } -} - -function hasKeyPrefix( - cookieWithRedisOptions: any, -): cookieWithRedisOptions is { keyPrefix: string } { - if (!cookieWithRedisOptions) return false; - if (typeof cookieWithRedisOptions !== "object") return false; - if (!cookieWithRedisOptions.keyPrefix) return false; - if (typeof cookieWithRedisOptions.keyPrefix !== "string") return false; - return true; -} - -export function redisSession( - store: Store, - cookieWithRedisOptions?: CookieWithRedisOptions, -) { - const redisStore = store; - - let setupKeyPrefix = "session_"; - let setupCookieOptions = cookieWithRedisOptions; - - if (hasKeyPrefix(cookieWithRedisOptions)) { - const { keyPrefix, ...cookieOptions } = cookieWithRedisOptions; - setupKeyPrefix = keyPrefix; - setupCookieOptions = cookieOptions; - } - - return async function ( - req: Request, - ctx: MiddlewareHandlerContext, - ) { - const { sessionId } = getCookies(req.headers); - const redisSessionStorage = await createRedisSessionStorage( - sessionId, - redisStore, - setupKeyPrefix, - setupCookieOptions, - ); - - if (sessionId && (await redisSessionStorage.exists())) { - ctx.state.session = await redisSessionStorage.get(); - } - - if (!ctx.state.session) { - ctx.state.session = redisSessionStorage.create(); - } - const response = await ctx.next(); - - return redisSessionStorage.persist(response, ctx.state.session); - }; -} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..fc2df05 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,47 @@ +// Session management plugin common type definitions + +// Session data can be any object, string, number, boolean, Date, or null +export type SessionData = + | Record + | string + | number + | boolean + | Date + | null; + +/** + * Result from loading session from storage + */ +export interface LoadResult { + sessionId: string; + data: SessionData; + isNew: boolean; +} + +export interface ISessionStore { + /** + * Restore session from cookie value + * @param cookieValue Value from cookie (session ID or encrypted data) + * @returns Session ID, data, and whether it's a new session + */ + load(cookieValue: string | undefined): Promise; + + /** + * Save session and return value to set in cookie + * @param sessionId Session ID + * @param data Session data + * @param expiresAt Optional expiration date + * @returns Value to set in cookie (memory: sessionId, cookie: encrypted data) + */ + save(sessionId: string, data: SessionData, expiresAt?: Date): Promise; + + /** + * Destroy session + */ + destroy(sessionId: string): Promise; + + /** + * Cleanup expired sessions (optional) + */ + cleanup?(): void | Promise; +} diff --git a/tests/1_dashboard_test.js b/tests/1_dashboard_test.js deleted file mode 100644 index bf74a27..0000000 --- a/tests/1_dashboard_test.js +++ /dev/null @@ -1,52 +0,0 @@ -import { BASE_URL, fixtureTestWrapper } from "./wrapper.js"; -import { assert, assertEquals } from "$std/assert/mod.ts"; -import { Status } from "$std/http/http_status.ts"; -import { wrapFetch } from "cookiejar"; - -const fetch = wrapFetch(); - -Deno.test( - "The Dashboard should show a new login", - { - sanitizeResources: false, - sanitizeOps: false, - }, - fixtureTestWrapper(async (t) => { - const EMAIL = "example@example.com"; - - await t.step("The dashboard shows nothing", async () => { - const response = await fetch(`${BASE_URL}/dashboard`); - assertEquals(response.status, Status.OK); - const text = await response.text(); - assert(!text.includes("
Flashed message: test
")); - }); - - await t.step("Post index page with 'email' form data.", async () => { - const body = new FormData(); - body.append("email", EMAIL); - const response = await fetch(`${BASE_URL}`, { - method: "POST", - body, - }); - const text = await response.text(); - assert( - text.includes( - `
  • email: ${EMAIL}
  • `, - ), - ); - assertEquals(response.status, Status.OK); - }); - - await t.step("The dashboard shows the login", async () => { - const response = await fetch(`${BASE_URL}/dashboard`); - const text = await response.text(); - console.log(text); - assert( - text.includes( - `You are logged in as ${EMAIL}`, - ), - ); - assertEquals(response.status, Status.OK); - }); - }), -); diff --git a/tests/2_route_test.js b/tests/2_route_test.js deleted file mode 100644 index ff16500..0000000 --- a/tests/2_route_test.js +++ /dev/null @@ -1,68 +0,0 @@ -import { BASE_URL, fixtureTestWrapper } from "./wrapper.js"; -import { assert, assertEquals } from "$std/assert/mod.ts"; -import { Status } from "$std/http/http_status.ts"; -import { wrapFetch } from "cookiejar"; - -const fetch = wrapFetch(); - -Deno.test( - "Route Testing", - { - sanitizeResources: false, - sanitizeOps: false, - }, - fixtureTestWrapper(async (t) => { - await t.step("The index page should work", async () => { - const response = await fetch(`${BASE_URL}`); - assertEquals(response.status, Status.OK); - const text = await response.text(); - assert(!text.includes("
    Flashed message: test
    ")); - }); - - await t.step("Post index page with 'email' form data.", async () => { - const form_data = new FormData(); - form_data.append("email", "taylor@example.com"); - const response = await fetch(`${BASE_URL}`, { - method: "POST", - body: form_data, - credentials: "include", - }); - const text = await response.text(); - assert( - text.includes( - "
    Flashed message: Successfully "logged in"
    ", - ), - ); - assertEquals(response.status, Status.OK); - }); - - await t.step("The dashboard should work", async () => { - const response = await fetch(`${BASE_URL}/dashboard`); - assertEquals(response.status, Status.OK); - }); - - await t.step("The other route should work", async () => { - const response = await fetch(`${BASE_URL}/other-route`, { - method: "POST", - }); - const text = await response.text(); - // console.log(text); - assert( - text.includes( - "
    Flashed message: test
    ", - ), - ); - assert( - text.includes( - "
    Flashed message: [{"msg":"test 2"}]
    ", - ), - ); - assertEquals(response.status, Status.OK); - }); - - await t.step("The 404 page should 404", async () => { - const response = await fetch(`${BASE_URL}/404`); - assertEquals(response.status, Status.NotFound); - }); - }), -); diff --git a/tests/3_kvStore_test.js b/tests/3_kvStore_test.js deleted file mode 100644 index 8f9ed8f..0000000 --- a/tests/3_kvStore_test.js +++ /dev/null @@ -1,84 +0,0 @@ -import { BASE_URL, exampleKVStoreTestWrapper } from "./wrapper.js"; -import { assert, assertEquals } from "$std/assert/mod.ts"; -import { Status } from "$std/http/http_status.ts"; -import { wrapFetch } from "cookiejar"; - -const fetch = wrapFetch(); - -Deno.test( - "Test KV Store Example", - { - sanitizeResources: false, - sanitizeOps: false, - }, - exampleKVStoreTestWrapper(async (t) => { - await t.step("The index page should work", async () => { - const response = await fetch(`${BASE_URL}`); - assertEquals(response.status, Status.OK); - const text = await response.text(); - assert(text.includes("
    Flash Message:
    ")); - // console.log(text); - }); - - const SESSION_TEXT = "This is some _Session Text_"; - await t.step( - "Post index page with 'new_session_text_value' form data.", - async () => { - const form_data = new FormData(); - form_data.append("new_session_text_value", SESSION_TEXT); - const response = await fetch(`${BASE_URL}`, { - method: "POST", - body: form_data, - }); - const text = await response.text(); - // console.log(text); - assert( - text.includes("
    Flash Message: Session value update!
    "), - ); - assert(text.includes(`
    Now Session Value: ${SESSION_TEXT}
    `)); - assertEquals(response.status, Status.OK); - }, - ); - - await t.step("Visit again to verify session value", async () => { - const response = await fetch(`${BASE_URL}`); - const text = await response.text(); - assert( - text.includes("
    Flash Message:
    "), - ); - assert(text.includes(`
    Now Session Value: ${SESSION_TEXT}
    `)); - assertEquals(response.status, Status.OK); - }); - - await t.step("Delete the session value", async () => { - const body = new FormData(); - body.append("method", "DELETE"); - const response = await fetch(`${BASE_URL}`, { - method: "POST", - body, - }); - const text = await response.text(); - assert( - text.includes("
    Flash Message: Delete value!
    "), - ); - assert(text.includes(`
    Now Session Value:
    `)); - assertEquals(response.status, Status.OK); - }); - - await t.step("Visit again to verify session value is gone", async () => { - const response = await fetch(`${BASE_URL}`); - const text = await response.text(); - assert( - text.includes( - "
    Flash Message:
    Now Session Value:
    ", - ), - ); - assertEquals(response.status, Status.OK); - }); - - await t.step("The 404 page should 404", async () => { - const response = await fetch(`${BASE_URL}/404`); - assertEquals(response.status, Status.NotFound); - }); - }), -); diff --git a/tests/fixture/deno.json b/tests/fixture/deno.json deleted file mode 100644 index 00adb91..0000000 --- a/tests/fixture/deno.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "tasks": { - "start": "deno run -A --watch=static/,routes/ dev.ts" - }, - "compilerOptions": { - "jsx": "react-jsx", - "jsxImportSource": "preact" - }, - "imports": { - "$fresh/": "https://deno.land/x/fresh@1.3.1/", - "preact": "https://esm.sh/preact@10.15.1", - "preact/": "https://esm.sh/preact@10.15.1/", - "preact-render-to-string": "https://esm.sh/preact-render-to-string@6.1.0", - "fresh-session": "../../mod.ts" - }, - "lock": false -} diff --git a/tests/fixture/dev.ts b/tests/fixture/dev.ts deleted file mode 100755 index b932927..0000000 --- a/tests/fixture/dev.ts +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env -S deno run -A --watch=static/,routes/ - -Deno.env.set("APP_KEY", "password-at-least-32-characters-long"); - -import dev from "$fresh/dev.ts"; - -await dev(import.meta.url, "./main.ts"); diff --git a/tests/fixture/fresh.gen.ts b/tests/fixture/fresh.gen.ts deleted file mode 100644 index 28c5351..0000000 --- a/tests/fixture/fresh.gen.ts +++ /dev/null @@ -1,21 +0,0 @@ -// DO NOT EDIT. This file is generated by fresh. -// This file SHOULD be checked into source version control. -// This file is automatically updated during development when running `dev.ts`. - -import * as $0 from "./routes/_middleware.ts"; -import * as $1 from "./routes/dashboard/index.tsx"; -import * as $2 from "./routes/index.tsx"; -import * as $3 from "./routes/other-route.tsx"; - -const manifest = { - routes: { - "./routes/_middleware.ts": $0, - "./routes/dashboard/index.tsx": $1, - "./routes/index.tsx": $2, - "./routes/other-route.tsx": $3, - }, - islands: {}, - baseUrl: import.meta.url, -}; - -export default manifest; diff --git a/tests/fixture/main.ts b/tests/fixture/main.ts deleted file mode 100644 index bae9199..0000000 --- a/tests/fixture/main.ts +++ /dev/null @@ -1,9 +0,0 @@ -/// -/// -/// -/// -/// - -import { start } from "$fresh/server.ts"; -import manifest from "./fresh.gen.ts"; -await start(manifest); diff --git a/tests/fixture/routes/_middleware.ts b/tests/fixture/routes/_middleware.ts deleted file mode 100644 index 7bb876e..0000000 --- a/tests/fixture/routes/_middleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { cookieSession } from "fresh-session"; - -export const handler = cookieSession(); diff --git a/tests/fixture/routes/dashboard/index.tsx b/tests/fixture/routes/dashboard/index.tsx deleted file mode 100644 index 88ec000..0000000 --- a/tests/fixture/routes/dashboard/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Handlers, PageProps } from "$fresh/server.ts"; -import { WithSession } from "fresh-session"; - -export type Data = { session: Record }; - -export const handler: Handlers< - Data, - WithSession // indicate with Typescript that the session is in the `ctx.state` -> = { - GET(_req, ctx) { - // The session is accessible via the `ctx.state` - const { session } = ctx.state; - - // You can pass the session data to the page - return ctx.render({ session: session.data }); - }, -}; - -export default function Dashboard({ data }: PageProps) { - return
    You are logged in as {data.session.email}
    ; -} diff --git a/tests/fixture/routes/index.tsx b/tests/fixture/routes/index.tsx deleted file mode 100644 index ad3de9c..0000000 --- a/tests/fixture/routes/index.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Handlers, PageProps } from "$fresh/server.ts"; -import type { WithSession } from "fresh-session"; - -export type Data = { - session: Record; - flashedMessage?: string; - msg?: string; - errors?: any[]; -}; - -export const handler: Handlers< - Data, - WithSession -> = { - GET(_req, ctx) { - const { session } = ctx.state; - - const flashedMessage = ctx.state.session.flash("success"); - const msg = ctx.state.session.get("msg"); - const errors = ctx.state.session.flash("errors"); - - return ctx.render({ session: session.data, flashedMessage, msg, errors }); - }, - - async POST(req, ctx) { - const formData = await req.formData(); - - // ctx.state.session.data = { - // email: formData.get("email"), - // }; - ctx.state.session.set("email", formData.get("email")); - ctx.state.session.flash("success", 'Successfully "logged in"'); - - return new Response(null, { - status: 303, - headers: { - "Location": "/", - }, - }); - }, -}; - -export default function Home({ data }: PageProps) { - return ( -
    - {!!data.flashedMessage && ( -
    - Flashed message: {data.flashedMessage} -
    - )} - - {!!data.msg && ( -
    - Flashed message: {data.msg} -
    - )} - - {!!data.errors && ( -
    - Flashed message: {JSON.stringify(data.errors)} -
    - )} - -
    -

    Your session data

    - -
      - {Object.entries(data.session).map(([key, value]) => { - return
    • {key}: {value}
    • ; - })} -
    -
    - -
    -
    - - - -
    -
    - -
    -
    - -
    -
    -
    - ); -} diff --git a/tests/fixture/routes/other-route.tsx b/tests/fixture/routes/other-route.tsx deleted file mode 100644 index f48c3dd..0000000 --- a/tests/fixture/routes/other-route.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Handlers } from "$fresh/server.ts"; -import { WithSession } from "fresh-session"; - -export const handler: Handlers = { - POST(_req, ctx) { - ctx.state.session.set("msg", "test"); - ctx.state.session.flash("errors", [{ msg: "test 2" }]); - - return new Response(null, { status: 303, headers: { "Location": "/" } }); - }, -}; diff --git a/tests/fixture/static/favicon.ico b/tests/fixture/static/favicon.ico deleted file mode 100644 index 1cfaaa2..0000000 Binary files a/tests/fixture/static/favicon.ico and /dev/null differ diff --git a/tests/fixture/static/logo.svg b/tests/fixture/static/logo.svg deleted file mode 100644 index ef2fbe4..0000000 --- a/tests/fixture/static/logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/tests/wrapper.js b/tests/wrapper.js deleted file mode 100644 index 04e54fd..0000000 --- a/tests/wrapper.js +++ /dev/null @@ -1,31 +0,0 @@ -import { delay } from "$std/async/delay.ts"; -import { startFreshServer } from "$fresh/tests/test_utils.ts"; - -Deno.env.set("APP_KEY", "password-at-least-32-characters-long"); - -export const BASE_URL = "http://localhost:8000"; - -const myTestWrapper = (args) => (theTests) => async (t) => { - const { serverProcess, lines } = await startFreshServer({ - args, - }); - await theTests(t); - // Stop the Server - await lines.cancel(); - serverProcess.kill("SIGTERM"); - // await for the server to close - await delay(100); -}; - -export const fixtureTestWrapper = myTestWrapper([ - "run", - "-A", - "./tests/fixture/main.ts", -]); - -export const exampleKVStoreTestWrapper = myTestWrapper([ - "run", - "-A", - "--unstable", - "./example/use_kv_store/main.ts", -]);