From e230f4a33c84086fa61e89383265183e8f949be6 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Fri, 7 Nov 2025 03:39:43 +0100 Subject: [PATCH 01/28] chore: initialize validation package --- packages/validation/CHANGELOG.md | 3 + packages/validation/README.md | 76 +++++++++++++++++++ packages/validation/package.json | 80 ++++++++++++++++++++ packages/validation/tests/validation.spec.ts | 0 packages/validation/tsconfig.json | 8 ++ 5 files changed, 167 insertions(+) create mode 100644 packages/validation/CHANGELOG.md create mode 100644 packages/validation/README.md create mode 100644 packages/validation/package.json create mode 100644 packages/validation/tests/validation.spec.ts create mode 100644 packages/validation/tsconfig.json diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md new file mode 100644 index 00000000..6361e43e --- /dev/null +++ b/packages/validation/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to this project will be documented in this file. diff --git a/packages/validation/README.md b/packages/validation/README.md new file mode 100644 index 00000000..d487a257 --- /dev/null +++ b/packages/validation/README.md @@ -0,0 +1,76 @@ +
+ + H3ravel Logo + +

H3ravel Validation

+ +[![Framework][ix]][lx] +[![Validation Package Version][i1]][l1] +[![Downloads][d1]][l1] +[![Tests][tei]][tel] +[![License][lini]][linl] + +
+ +# About H3ravel/validation + +Lightweight validation library providing expressive rule-based validation for requests, data objects, and custom logic for [H3ravel](https://h3ravel.toneflix.net) applications. + +## Installation + +```bash +npm install @h3ravel/validation +``` + +## Features + +- Rule-based validation — Supports common rules like required, min, max, email, url, numeric, boolean, in, regex, etc. +- Nested data validation — Dot notation for nested objects (user.email, items.\*.price). +- Custom error messages — Per-rule and per-field message overrides. +- Batch validation — Validate multiple datasets or groups in one call. +- Conditional validation — Rules that only apply when other fields meet conditions (required_if, sometimes, exclude_unless). +- Implicit rules — Rules that run even when attributes are missing (e.g., accepted, required). + +- Custom rules — Define user-provided validation rules as functions or classes. +- Async rules — Support for async validation (e.g., checking uniqueness in a database). +- Attribute sanitization — Optional transformation (e.g., trimming, normalizing case) before validation. +- Dynamic rule sets — Rules can be generated at runtime (e.g., based on user roles). +- Dependent rules — Rules that reference other fields dynamically. + +- Localized error messages — Built-in support for localization and i18n message templates. +- Structured errors — Validation errors returned as structured objects or flat key–message pairs. +- Fail-fast mode — Option to stop at the first failure or collect all errors. +- Human-readable summaries — Helper for formatting readable validation reports. + +- Rule testing utilities — Built-in helpers for testing custom rules. +- TypeScript-first design — Full type inference for rules, messages, and validated data. +- Chainable API — Optional fluent syntax for building validators. +- Rule presets — Common validation profiles for forms, authentication, or file uploads. + +## Usage + +## Contributing + +Thank you for considering contributing to the H3ravel framework! The [Contribution Guide](https://h3ravel.toneflix.net/contributing) can be found in the H3ravel documentation and will provide you with all the information you need to get started. + +## Code of Conduct + +In order to ensure that the H3ravel community is welcoming to all, please review and abide by the [Code of Conduct](#). + +## Security Vulnerabilities + +If you discover a security vulnerability within H3ravel, please send an e-mail to Legacy via hamzas.legacy@toneflix.ng. All security vulnerabilities will be promptly addressed. + +## License + +The H3ravel framework is open-sourced software licensed under the [MIT license](LICENSE). + +[ix]: https://img.shields.io/npm/v/%40h3ravel%2Fcore?style=flat-square&label=Framework&color=%230970ce +[lx]: https://www.npmjs.com/package/@h3ravel/core +[i1]: https://img.shields.io/npm/v/%40h3ravel%2Fvalidation?style=flat-square&label=@h3ravel/validation&color=%230970ce +[l1]: https://www.npmjs.com/package/@h3ravel/validation +[d1]: https://img.shields.io/npm/dt/%40h3ravel%2Fvalidation?style=flat-square&label=Downloads&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40h3ravel%2Fvalidation +[linl]: https://github.com/h3ravel/framework/blob/main/LICENSE +[lini]: https://img.shields.io/github/license/h3ravel/framework +[tel]: https://github.com/h3ravel/framework/actions/workflows/test.yml +[tei]: https://github.com/h3ravel/framework/actions/workflows/test.yml/badge.svg diff --git a/packages/validation/package.json b/packages/validation/package.json new file mode 100644 index 00000000..2f494955 --- /dev/null +++ b/packages/validation/package.json @@ -0,0 +1,80 @@ +{ + "name": "@h3ravel/validation", + "version": "1.0.15", + "description": "Lightweight validation library providing expressive rule-based validation for requests, data objects, and custom logic for H3ravel applications.", + "h3ravel": { + "providers": [ + "ValidationServiceProvider" + ] + }, + "type": "module", + "main": "./dist/index.cjs", + "types": "./dist/index.d.ts", + "module": "./dist/index.js", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./*": "./*" + } + }, + "homepage": "https://h3ravel.toneflix.net", + "repository": { + "type": "git", + "url": "git+https://github.com/h3ravel/framework.git", + "directory": "packages/validation" + }, + "keywords": [ + "h3ravel", + "modern", + "web", + "H3", + "framework", + "nodejs", + "typescript", + "laravel", + "validation", + "validator", + "requests", + "api", + "builder" + ], + "scripts": { + "barrel": "barrelsby --directory src --delete --singleQuotes", + "build": "tsdown --config-loader unconfig", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "lint": "eslint . --ext .ts", + "test": "jest --passWithNoTests", + "version-patch": "pnpm version patch" + }, + "dependencies": { + "@h3ravel/support": "workspace:^", + "@h3ravel/shared": "workspace:^" + }, + "peerDependencies": { + "@h3ravel/core": "workspace:^", + "@h3ravel/config": "workspace:^" + }, + "devDependencies": { + "typescript": "^5.4.0" + }, + "peerDependenciesMeta": { + "@h3ravel/config": { + "optional": true + } + } +} \ No newline at end of file diff --git a/packages/validation/tests/validation.spec.ts b/packages/validation/tests/validation.spec.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/validation/tsconfig.json b/packages/validation/tsconfig.json new file mode 100644 index 00000000..79239e58 --- /dev/null +++ b/packages/validation/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} From b9edfdcf5081cf71ad037bd79b3184bd086339d1 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Fri, 7 Nov 2025 19:29:23 +0100 Subject: [PATCH 02/28] feat: implement validation library --- packages/support/src/GlobalBootstrap.ts | 2 + packages/support/src/Helpers/Arr.ts | 10 + packages/validation/[rules].txt | 115 ++++++++ packages/validation/package.json | 11 +- .../src/Contracts/ValidationRuleName.ts | 77 +++++ .../src/Contracts/ValidatorContracts.ts | 56 ++++ .../Providers/ValidationServiceProvider.ts | 20 ++ packages/validation/src/Rule.ts | 16 + .../validation/src/ValidationException.ts | 101 +++++++ packages/validation/src/Validator.ts | 221 ++++++++++++++ .../validation/src/utilities/MessageBag.ts | 273 ++++++++++++++++++ packages/validation/tests/validation.spec.ts | 0 .../validation/tests/validator.make.spec.ts | 79 +++++ packages/validation/tests/validator.spec.ts | 171 +++++++++++ pnpm-lock.yaml | 176 +++-------- 15 files changed, 1186 insertions(+), 142 deletions(-) create mode 100644 packages/validation/[rules].txt create mode 100644 packages/validation/src/Contracts/ValidationRuleName.ts create mode 100644 packages/validation/src/Contracts/ValidatorContracts.ts create mode 100644 packages/validation/src/Providers/ValidationServiceProvider.ts create mode 100644 packages/validation/src/Rule.ts create mode 100644 packages/validation/src/ValidationException.ts create mode 100644 packages/validation/src/Validator.ts create mode 100644 packages/validation/src/utilities/MessageBag.ts delete mode 100644 packages/validation/tests/validation.spec.ts create mode 100644 packages/validation/tests/validator.make.spec.ts create mode 100644 packages/validation/tests/validator.spec.ts diff --git a/packages/support/src/GlobalBootstrap.ts b/packages/support/src/GlobalBootstrap.ts index 02c67e0f..6f7b3ee5 100644 --- a/packages/support/src/GlobalBootstrap.ts +++ b/packages/support/src/GlobalBootstrap.ts @@ -115,6 +115,7 @@ export function loadHelpers (target: any = globalThis): void { last: Arr.last, prepend: Arr.prepend, flatten: Arr.flatten, + unique: Arr.unique, // Object helpers dot: SimpleObj.dot, @@ -131,6 +132,7 @@ export function loadHelpers (target: any = globalThis): void { data_set: SimpleObj.data_set, data_fill: SimpleObj.data_fill, data_forget: SimpleObj.data_forget, + isPlainObject: SimpleObj.isPlainObject, // Crypto helpers uuid: Crypto.uuid, diff --git a/packages/support/src/Helpers/Arr.ts b/packages/support/src/Helpers/Arr.ts index cd6cad1a..225f6214 100644 --- a/packages/support/src/Helpers/Arr.ts +++ b/packages/support/src/Helpers/Arr.ts @@ -878,4 +878,14 @@ export class Arr { if (size <= 0 || !Number.isFinite(size)) return [] return Array.from({ length: size }, (_, i) => startAt + i) } + + /** + * Filters an array and returns only unique values + * + * @param items + * @returns + */ + static unique (items: T[]) { + return items.filter((value, index, self) => self.indexOf(value) === index) + } } diff --git a/packages/validation/[rules].txt b/packages/validation/[rules].txt new file mode 100644 index 00000000..83355f68 --- /dev/null +++ b/packages/validation/[rules].txt @@ -0,0 +1,115 @@ +[simple-body-validator] + +accepted +accepted_if:anotherfield,value,... +after:date +after_or_equal:date +alpha +alpha_dash +alpha_num +array +array_unique +bail +before:date +before_or_equal:date +between:min,max +boolean +confirmed +date +date_equals:date +declined +declined_if:anotherfield,value,... +different:field +digits:value +digits_between:min,max +email +ends_with:foo,bar +gt:field +gte:field +gte:field +in:foo,bar,... +integer +json +lt:field +lte:field +max:value +min:value +not_in:foo,bar,... +not_regex +nullable +numeric +object +present +regex +required +required_if:anotherfield,value,... +required_unless:anotherfield,value,... +required_with:foo,bar,... +required_with_all:foo,bar,... +required_without:foo,bar,... +required_without_all:foo,bar,... +same:field +size:value +sometimes +starts_with:foo,bar... +string +url + +[robust-validator] + +accepted +after:date +after_or_equal:date +alpha, alpha_dash +alpha_num +array +before:date +before_or_equal:date +between:min,max +boolean +confirmed +date:format +digits:value +digits_between:min,max +email +hex +includes:foo,bar,... +integer +max:value +min:value +not_includes:foo,bar,... +numeric +required +size:value +string +url + +[mix] + +accepted_if:anotherfield,value,… +bail +declined +declined_if:anotherfield,value,… +different:field +date_equals:date +ends_with:foo,bar +gt:field +gte:field +in:foo,bar,… +json +lt:field +lte:field +not_in:foo,bar,… +not_regex +nullable +object +present +regex +required_if:anotherfield,value,… +required_unless:anotherfield,value,… +required_with:foo,bar,… +required_with_all:foo,bar,… +required_without:foo,bar,… +required_without_all:foo,bar,… +same:field +starts_with:foo,bar,… \ No newline at end of file diff --git a/packages/validation/package.json b/packages/validation/package.json index 2f494955..ea414edf 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -62,17 +62,22 @@ "version-patch": "pnpm version patch" }, "dependencies": { + "@h3ravel/shared": "workspace:^", "@h3ravel/support": "workspace:^", - "@h3ravel/shared": "workspace:^" + "robust-validator": "^3.0.0", + "simple-body-validator": "^1.3.9" }, "peerDependencies": { - "@h3ravel/core": "workspace:^", - "@h3ravel/config": "workspace:^" + "@h3ravel/config": "workspace:^", + "@h3ravel/core": "workspace:^" }, "devDependencies": { "typescript": "^5.4.0" }, "peerDependenciesMeta": { + "@h3ravel/core": { + "optional": true + }, "@h3ravel/config": { "optional": true } diff --git a/packages/validation/src/Contracts/ValidationRuleName.ts b/packages/validation/src/Contracts/ValidationRuleName.ts new file mode 100644 index 00000000..18878a31 --- /dev/null +++ b/packages/validation/src/Contracts/ValidationRuleName.ts @@ -0,0 +1,77 @@ +import type In from 'simple-body-validator/lib/cjs/rules/in' +import type NotIn from 'simple-body-validator/lib/cjs/rules/notIn' +import type Regex from 'simple-body-validator/lib/cjs/rules/regex' +import type RequiredIf from 'simple-body-validator/lib/cjs/rules/requiredIf' +import { Rule } from 'simple-body-validator' + +export type ParamableRuleName = + | 'accepted_if' + | 'after' + | 'after_or_equal' + | 'before' + | 'before_or_equal' + | 'between' + | 'date_equals' + | 'declined_if' + | 'digits_between' + | 'different' + | 'ends_with' + | 'gt' + | 'gte' + | 'in' + | 'lt' + | 'lte' + | 'max' + | 'min' + | 'not_in' + | 'required_if' + | 'required_unless' + | 'required_with' + | 'required_with_all' + | 'required_without' + | 'required_without_all' + | 'same' + | 'size' + | 'starts_with' + +export type PlainRuleName = + | 'accepted' + | 'alpha' + | 'alpha_dash' + | 'alpha_num' + | 'array' + | 'array_unique' + | 'bail' + | 'boolean' + | 'confirmed' + | 'date' + | 'declined' + | 'digits' + | 'email' + | 'integer' + | 'json' + | 'not_regex' + | 'nullable' + | 'numeric' + | 'object' + | 'present' + | 'regex' + | 'required' + | 'sometimes' + | 'string' + | 'url' + | 'uuid' + +export type ValidationRuleName = ParamableRuleName | PlainRuleName + +type MethodRules = Regex | In | NotIn | RequiredIf + +/** + * Single rule value (supports autocomplete + arbitrary strings + Rule instances) + */ +type RuleName = ValidationRuleName | `${ParamableRuleName}:${string}` | Rule | MethodRules + +export type RuleSet = + | RuleName + | RuleName[] + | `${ValidationRuleName}${string & `|${string}`}` \ No newline at end of file diff --git a/packages/validation/src/Contracts/ValidatorContracts.ts b/packages/validation/src/Contracts/ValidatorContracts.ts new file mode 100644 index 00000000..46592a8e --- /dev/null +++ b/packages/validation/src/Contracts/ValidatorContracts.ts @@ -0,0 +1,56 @@ +import { RuleSet, ValidationRuleName } from './ValidationRuleName' + +/** + * Parse rule names from rule string or string[] definitions + */ +export type ExtractRules = + R extends string + ? R extends `${infer Head}|${infer Tail}` + ? Head extends `${infer Rule}:${string}` + ? Rule | ExtractRules + : Head | ExtractRules + : R extends `${infer Rule}:${string}` + ? Rule + : R + : R extends string[] + ? ExtractRules + : never + +/** + * Flatten data structure into dot-notation keys + * including wildcards (*) for arrays. + */ +export type DotPaths = { + [K in keyof T & string]: + T[K] extends (infer A)[] + ? | `${Prefix}${K}` + | `${Prefix}${K}.*` + | (A extends Record + ? `${Prefix}${K}.*.${DotPaths}` + : never) + : T[K] extends Record + ? | `${Prefix}${K}` + | `${Prefix}${K}.${DotPaths}` + : `${Prefix}${K}` +}[keyof T & string] + +/** +* Builds message keys only for rules used on that field +*/ +export type FieldMessages = + | `${Field}` + | `${Field}.${ExtractRules & ValidationRuleName}` + +/** +* Build all valid message keys for a given rules object +*/ +export type MessagesForRules> = { + [K in keyof Rules & string]: FieldMessages +}[keyof Rules & string] + +/** + * Make rules align with keys in the data object + */ +export type RulesForData> = Partial< + Record, RuleSet> +> \ No newline at end of file diff --git a/packages/validation/src/Providers/ValidationServiceProvider.ts b/packages/validation/src/Providers/ValidationServiceProvider.ts new file mode 100644 index 00000000..8ffff10b --- /dev/null +++ b/packages/validation/src/Providers/ValidationServiceProvider.ts @@ -0,0 +1,20 @@ +import { ServiceProvider } from '@h3ravel/core' + +/** + * Service provider for Validation utilities + */ +export class UrlServiceProvider extends ServiceProvider { + public static priority = 895 + + /** + * Register URL services in the container + */ + register (): void { + } + + /** + * Boot URL services + */ + boot (): void { + } +} diff --git a/packages/validation/src/Rule.ts b/packages/validation/src/Rule.ts new file mode 100644 index 00000000..5723d947 --- /dev/null +++ b/packages/validation/src/Rule.ts @@ -0,0 +1,16 @@ +export class Rule { + /** + * Checks if a database record exists + */ + public static exists (value: any) { + return { + rule () { + return false + }, + name: 'exists', + messages: { + en: 'The record doesn\'t exists on database: {0}', + }, + } + } +} \ No newline at end of file diff --git a/packages/validation/src/ValidationException.ts b/packages/validation/src/ValidationException.ts new file mode 100644 index 00000000..0c017648 --- /dev/null +++ b/packages/validation/src/ValidationException.ts @@ -0,0 +1,101 @@ +import { MessageBag } from './utilities/MessageBag' +import { Str } from '@h3ravel/support' +import { Validator } from './Validator' + +export class ValidationException extends Error { + public validator: Validator + public response?: any + public status: number = 422 + public errorBag: string = 'default' + public redirectTo?: string + + constructor(validator: Validator, response: any = null, errorBag = 'default') { + super(ValidationException.summarize(validator)) + + this.name = 'ValidationException' + this.validator = validator + this.response = response + this.errorBag = errorBag + + Object.setPrototypeOf(this, ValidationException.prototype) + } + + /** + * Create a new validation exception from a plain array of messages. + */ + public static withMessages ( + messages: Record + ): ValidationException { + const validator = new Validator({}, {}) + const bag = new MessageBag() + + for (const [key, value] of Object.entries(messages)) { + const list = Array.isArray(value) ? value : [value] + for (const message of list) { + bag.add(key, message) + } + } + + (validator as any)._errors = bag + + return new ValidationException(validator) + } + + /** + * Create a readable summary message from the validation errors. + */ + protected static summarize (validator: Validator): string { + const messages = validator.errors().all() + + if (!messages.length || typeof messages[0] !== 'string') { + return 'The given data was invalid.' + } + + let message = messages.shift()! + const count = messages.length + + if (count > 0) { + message += ` (and ${count} more ${Str.plural('error', count)})` + } + + return message + } + + /** + * Get all of the validation error messages. + */ + public errors (): Record { + return this.validator.errors().getMessages() + } + + /** + * Set the HTTP status code to be used for the response. + */ + public setStatus (status: number): this { + this.status = status + return this + } + + /** + * Set the error bag on the exception. + */ + public setErrorBag (errorBag: string): this { + this.errorBag = errorBag + return this + } + + /** + * Set the URL to redirect to on a validation error. + */ + public setRedirectTo (url: string): this { + this.redirectTo = url + return this + } + + /** + * Get the underlying response instance. + */ + public getResponse (): any { + return this.response + } +} \ No newline at end of file diff --git a/packages/validation/src/Validator.ts b/packages/validation/src/Validator.ts new file mode 100644 index 00000000..138e744a --- /dev/null +++ b/packages/validation/src/Validator.ts @@ -0,0 +1,221 @@ +import { DotPaths, MessagesForRules, RulesForData } from './Contracts/ValidatorContracts' +import { Validator as SimpleBodyValidator, make } from 'simple-body-validator' + +import { MessageBag } from './utilities/MessageBag' +import { RuleSet } from './Contracts/ValidationRuleName' +import { ValidationException } from './ValidationException' + +export class Validator< + D extends Record, + R extends RulesForData +> { + #messages: Partial, string>> + + private data: D + private rules: R + private _errors: MessageBag + private passing: boolean = false + private executed: boolean = false + private instance?: SimpleBodyValidator + private errorBagName = 'default' + private shouldStopOnFirstFailure = false + + constructor( + data: D, + rules: R, + messages: Partial, string>> = {} + ) { + this.data = data + this.rules = rules + this.#messages = messages + this._errors = new MessageBag() + } + + /** + * Validate the data and return the instance + */ + static make< + D extends Record, + R extends RulesForData + > ( + data: D, + rules: R, + messages: Partial, string>> = {} + ) { + return new Validator(data, rules, messages) + } + + /** + * Run the validator and store results. + */ + public async passes (): Promise { + if (this.executed) return this._errors.isEmpty() + + return (await this.execute()).passing + } + + /** + * Opposite of passes() + */ + public async fails (): Promise { + return !(await this.passes()) + } + + /** + * Throw if validation fails, else return executed data + */ + public async validate (): Promise> { + const ok = await this.passes() + + if (!ok) { + throw new ValidationException(this, JSON.stringify(this._errors.toArray())) + } + + return this.validatedData() + } + + /** + * Run the validator's rules against its data. + * @param bagName + * @returns + */ + async validateWithBag (bagName: string) { + this.errorBagName = bagName + return this.validate() + } + + /** + * Stop validation on first failure. + */ + stopOnFirstFailure () { + this.shouldStopOnFirstFailure = true + return this + } + + + /** + * Get the data that passed validation. + */ + public validatedData (): Record { + const validKeys = Object.keys(this.rules) + const clean: Record = {} + for (const key of validKeys) { + if (this.data[key] !== undefined) clean[key] = this.data[key] + } + return clean + } + + + /** + * Return all validated input. + */ + validated (): Partial { + return Object.fromEntries( + Object.entries(this.data).filter(([key]) => key in this.rules) + ) as Partial + } + + /** + * Return a portion of validated input + */ + safe () { + const validated = this.validated() + + return { + only: (keys: string[]) => + Object.fromEntries(Object.entries(validated).filter(([key]) => keys.includes(key))) as Partial, + except: (keys: string[]) => + Object.fromEntries(Object.entries(validated).filter(([key]) => !keys.includes(key))) as Partial, + } + } + + /** + * Get the message container for the validator. + */ + public async messages () { + if (!this.#messages) { + await this.passes() + } + + return this.#messages + } + + + /** + * Get all errors. + */ + public errors (): MessageBag { + return this._errors + } + + public errorBag () { + return this.errorBagName + } + + /** + * Reset validator with new data. + */ + public setData (data: D): this { + this.data = data + this.executed = false + return this + } + + /** + * Set validation rules. + */ + public setRules (rules: R): this { + this.rules = rules + this.executed = false + return this + } + + /** + * Add a single rule to existing rules. + */ + public addRule (key: DotPaths, rule: RuleSet): this { + this.rules[key as never] = rule as never + return this + } + + /** + * Merge additional rules. + */ + public mergeRules (rules: Record): this { + this.rules = { ...this.rules, ...rules } + return this + } + + /** + * Get current data. + */ + public getData (): Record { + return this.data + } + + /** + * Get current rules. + */ + public getRules (): R { + return this.rules + } + + private async execute () { + const instance = make() + .setData(this.data) + .setRules(this.rules as never) + .setCustomMessages(this.#messages) + .stopOnFirstFailure(this.shouldStopOnFirstFailure) + + this.passing = await instance.validateAsync() + + this.executed = true + this.instance = instance + + if (!this.passing) { + this._errors = new MessageBag(instance.errors().all()) + } + + return this + } +} \ No newline at end of file diff --git a/packages/validation/src/utilities/MessageBag.ts b/packages/validation/src/utilities/MessageBag.ts new file mode 100644 index 00000000..b120fdd0 --- /dev/null +++ b/packages/validation/src/utilities/MessageBag.ts @@ -0,0 +1,273 @@ +export interface MessageProvider { + getMessageBag (): MessageBag; +} + +export interface MessageBagContract { + add (key: string, message: string): this; + has (key: string | string[]): boolean; + all (format?: string): string[]; + first (key?: string | null, format?: string | null): string; + getMessages (): Record; +} + +export class MessageBag implements MessageBagContract, MessageProvider { + /** + * All of the registered messages. + */ + protected messages: Record = {} + + /** + * Default format for message output. + */ + protected format = ':message' + + /** + * Create a new message bag instance. + */ + constructor(messages: Record = {}) { + for (const [key, value] of Object.entries(messages)) { + const arr = Array.isArray(value) ? value : [value] + this.messages[key] = Array.from(new Set(arr)) + } + } + + /** + * Get all message keys. + */ + keys (): string[] { + return Object.keys(this.messages) + } + + /** + * Add a message. + */ + add (key: string, message: string): this { + if (this.isUnique(key, message)) { + if (!this.messages[key]) this.messages[key] = [] + this.messages[key].push(message) + } + return this + } + + /** + * Add a message conditionally. + */ + addIf (condition: boolean, key: string, message: string): this { + return condition ? this.add(key, message) : this + } + + /** + * Check uniqueness of key/message pair. + */ + protected isUnique (key: string, message: string): boolean { + return !this.messages[key] || !this.messages[key].includes(message) + } + + /** + * Merge another message source into this one. + */ + merge (messages: Record | MessageProvider): this { + const incoming = + (messages as MessageProvider).getMessageBag?.()?.getMessages?.() ?? + (messages as Record) + + for (const [key, list] of Object.entries(incoming)) { + if (!this.messages[key]) this.messages[key] = [] + this.messages[key].push(...list) + this.messages[key] = Array.from(new Set(this.messages[key])) + } + return this + } + + /** + * Determine if messages exist for all given keys. + */ + has (key: string | string[] | null): boolean { + if (this.isEmpty()) return false + if (key == null) return this.any() + + const keys = Array.isArray(key) ? key : [key] + return keys.every(k => this.first(k) !== '') + } + + /** + * Determine if messages exist for any given key. + */ + hasAny (keys: string | string[] = []): boolean { + if (this.isEmpty()) return false + const list = Array.isArray(keys) ? keys : [keys] + return list.some(k => this.has(k)) + } + + /** + * Determine if messages don't exist for given keys. + */ + missing (key: string | string[]): boolean { + const keys = Array.isArray(key) ? key : [key] + return !this.hasAny(keys) + } + + /** + * Get the first message for a given key. + */ + first (key: string | null = null, format: string | null = null): string { + const messages = key == null ? this.all(format) : this.get(key, format) + const firstMessage = Array.isArray(messages) ? messages[0] ?? '' : '' + return Array.isArray(firstMessage) ? firstMessage[0] ?? '' : firstMessage + } + + /** + * Get all messages for a given key. + */ + get (key: string, format: string | null = null): string[] | Record { + if (this.messages[key]) { + return this.transform(this.messages[key], this.checkFormat(format), key) + } + + if (key.includes('*')) { + return this.getMessagesForWildcardKey(key, format) + } + + return [] + } + + /** + * Wildcard key match. + */ + protected getMessagesForWildcardKey (key: string, format: string | null) { + const regex = new RegExp('^' + key.replace(/\*/g, '.*') + '$') + const result: Record = {} + for (const [messageKey, messages] of Object.entries(this.messages)) { + if (regex.test(messageKey)) { + result[messageKey] = this.transform(messages, this.checkFormat(format), messageKey) + } + } + return result + } + + /** + * Get all messages. + */ + all (format: string | null = null): string[] { + const fmt = this.checkFormat(format) + const all: string[] = [] + for (const [key, messages] of Object.entries(this.messages)) { + all.push(...this.transform(messages, fmt, key)) + } + return all + } + + /** + * Get unique messages. + */ + unique (format: string | null = null): string[] { + return Array.from(new Set(this.all(format))) + } + + /** + * Remove messages for a key. + */ + forget (key: string): this { + delete this.messages[key] + return this + } + + /** + * Format an array of messages. + */ + protected transform (messages: string[], format: string, messageKey: string): string[] { + if (format === ':message') return messages + return messages.map(m => format.replace(':message', m).replace(':key', messageKey)) + } + + /** + * Get proper format string. + */ + protected checkFormat (format?: string | null): string { + return format || this.format + } + + /** + * Get raw messages. + */ + messagesRaw (): Record { + return this.messages + } + + /** + * Alias for messagesRaw(). + */ + getMessages (): Record { + return this.messagesRaw() + } + + /** + * Return message bag instance. + */ + getMessageBag (): MessageBag { + return this + } + + /** + * Get format string. + */ + getFormat (): string { + return this.format + } + + /** + * Set default message format. + */ + setFormat (format = ':message'): this { + this.format = format + return this + } + + /** + * Empty checks. + */ + isEmpty (): boolean { + return !this.any() + } + + isNotEmpty (): boolean { + return this.any() + } + + any (): boolean { + return this.count() > 0 + } + + /** + * Count total messages. + */ + count (): number { + return Object.values(this.messages).reduce((sum, list) => sum + list.length, 0) + } + + /** + * Array & JSON conversions. + */ + toArray (): Record { + return this.getMessages() + } + + jsonSerialize (): any { + return this.toArray() + } + + toJson (options = 0): string { + return JSON.stringify(this.jsonSerialize(), null, options ? 2 : undefined) + } + + toPrettyJson (): string { + return JSON.stringify(this.jsonSerialize(), null, 2) + } + + /** + * String representation. + */ + toString (): string { + return this.toJson() + } +} \ No newline at end of file diff --git a/packages/validation/tests/validation.spec.ts b/packages/validation/tests/validation.spec.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/validation/tests/validator.make.spec.ts b/packages/validation/tests/validator.make.spec.ts new file mode 100644 index 00000000..98039df2 --- /dev/null +++ b/packages/validation/tests/validator.make.spec.ts @@ -0,0 +1,79 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { register, setTranslationObject } from 'simple-body-validator' + +import { ValidationException } from '../src/ValidationException' +import { Validator } from '../src/Validator' + +describe('Validator.make', () => { + beforeAll(() => { + + setTranslationObject({ + en: { + uuid: 'The :attribute must be a valid UUID.', + } + }) + + register('uuid', (value) => /^[0-9a-f-]{36}$/i.test(value)) + }) + + it('passes simple required rule', async () => { + const v = Validator.make({ name: 'John' }, { name: 'required' }) + expect(await v.passes()).toBe(true) + }) + + it('fails when required field missing', async () => { + const v = Validator.make({}, { name: 'required' }) + expect(await v.fails()).toBe(true) + }) + + it('supports multiple rules', async () => { + const v = Validator.make({ email: 'foo@bar.com' }, { email: 'email' }) + expect(await v.passes()).toBe(true) + }) + + it('fails email validation', async () => { + const v = Validator.make({ email: 'invalid' }, { email: 'email' }) + expect(await v.fails()).toBe(true) + }) + + it('stops on first failure when bail used', async () => { + const v = Validator.make({ name: '' }, { name: 'bail|required|min:3' }) + expect(await v.fails()).toBe(true) + }) + + it('validates sometimes rule', async () => { + const v = Validator.make({}, { name: 'sometimes|required' }) + expect(await v.passes()).toBe(true) + }) + + it('handles nullable rule', async () => { + const v = Validator.make({ age: null }, { age: 'nullable|integer' }) + expect(await v.passes()).toBe(true) + }) + + it('validates custom uuid rule', async () => { + const v = Validator.make({ id: '123e4567-e89b-12d3-a456-426614174000' }, { id: ['uuid'] }) + expect(await v.passes()).toBe(true) + }) + + it('fails invalid uuid', async () => { + const v = Validator.make({ id: 'not-a-uuid' }, { id: 'uuid' }) + expect(await v.fails()).toBe(true) + }) + + it('validates different rule', async () => { + const v = Validator.make({ password: '123', confirm: '456' }, { confirm: 'different:password' }) + expect(await v.passes()).toBe(true) + }) + + it('fails different rule when same', async () => { + const v = Validator.make({ password: '123', confirm: '123' }, { confirm: 'different:password' }) + expect(await v.fails()).toBe(true) + }) + + it('reports all error messages', async () => { + const v = Validator.make({ email: '' }, { email: 'required|email' }) + await expect(v.validate()).rejects.toThrowError(ValidationException) + expect(Object.keys(v.errors().all()).length).toBeGreaterThan(0) + }) +}) \ No newline at end of file diff --git a/packages/validation/tests/validator.spec.ts b/packages/validation/tests/validator.spec.ts new file mode 100644 index 00000000..ca383c18 --- /dev/null +++ b/packages/validation/tests/validator.spec.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest' + +import { ValidationException } from '../src/ValidationException' +import { Validator } from '../src/Validator' + +describe('Validator', () => { + describe('basic rules', () => { + + it('throws ValidationException for invalid email', async () => { + const validator = new Validator( + { email: 'invalid-email' }, + { email: 'required|email' } + ) + + await expect(validator.validate()).rejects.toThrowError(ValidationException) + }) + + it('passes validation for valid data', async () => { + const validator = new Validator( + { email: 'valid@example.com', name: 'John' }, + { email: 'required|email', name: 'required|min:2|max:10' } + ) + + const result = await validator.passes() + expect(result).toBe(true) + expect(validator.errors().isEmpty()).toBe(true) + }) + + it('fails validation when required field is missing', async () => { + const validator = new Validator({}, { name: 'required' }) + + await expect(validator.validate()).rejects.toThrowError(ValidationException) + }) + + it('applies multiple rules correctly', async () => { + const validator = new Validator( + { name: 'A' }, + { name: 'required|min:2|max:5' } + ) + + await expect(validator.validate()).rejects.toThrowError(ValidationException) + }) + }) + + describe('advanced rules', () => { + + it('validates numeric and min/max values', async () => { + const validator = new Validator( + { age: 15 }, + { age: 'required|numeric|min:18|max:60' } + ) + + await expect(validator.validate()).rejects.toThrowError(ValidationException) + }) + + it('passes when numeric is in valid range', async () => { + const validator = new Validator( + { age: 25 }, + { age: 'required|numeric|min:18|max:60' } + ) + + const result = await validator.passes() + expect(result).toBe(true) + }) + + it('validates boolean values', async () => { + const validator = new Validator( + { active: 'yes' }, + { active: 'boolean' }, + ) + + await expect(validator.validate()).rejects.toThrowError(ValidationException) + }) + }) + + describe('arrays and nested data', () => { + + it('validates wildcard array elements', async () => { + const validator = new Validator( + { users: [{ email: 'bad' }, { email: 'good@example.com' }] }, + { 'users.*.email': 'required|email', }, + { 'users.*.email.required': 'Hello' } + ) + + await expect(validator.validate()).rejects.toThrowError(ValidationException) + }) + + it('validates nested object fields', async () => { + const validator = new Validator( + { user: { name: { first: '', last: 'Doe' } } }, + { 'user.name.first': 'required|min:2', 'user.name.last': 'required|min:2' } + ) + + await expect(validator.validate()).rejects.toThrowError(ValidationException) + }) + + it('passes with valid nested fields', async () => { + const validator = new Validator( + { user: { name: { first: 'John', last: 'Doe' } } }, + { 'user.name.first': 'required|min:2', 'user.name.last': 'required|min:2' } + ) + + const result = await validator.passes() + expect(result).toBe(true) + }) + }) + + describe('custom messages', () => { + it('uses custom error messages', async () => { + const validator = new Validator( + { email: '' }, + { email: 'required|email' }, + { 'email.required': 'Email is required!' } + ) + + await expect(validator.validate()).rejects.toThrowError(ValidationException) + try { + await validator.validate() + } catch (error: any) { + expect(error.errors().email[0]).toBe('Email is required!') + } + }) + }) + + describe('optional and nullable', () => { + it('passes when optional field is missing', async () => { + const validator = new Validator({}, { nickname: 'nullable|min:2' }) + const result = await validator.passes() + expect(result).toBe(true) + }) + + it('fails when nullable field is provided but invalid', async () => { + const validator = new Validator({ nickname: 'A' }, { nickname: 'nullable|min:2' }) + await expect(validator.validate()).rejects.toThrowError(ValidationException) + }) + }) + + describe('error structure and message', () => { + it('provides a structured errors object', async () => { + const validator = new Validator({ email: '' }, { email: 'required|email' }) + try { + await validator.validate() + } catch (error: any) { + expect(error).toBeInstanceOf(ValidationException) + expect(typeof error.errors()).toBe('object') + expect(error.errors().email).toBeInstanceOf(Array) + expect(error.errors().email.length).toBeGreaterThan(0) + } + }) + + it('throws with proper message', async () => { + const validator = new Validator({ name: '' }, { name: 'required' }) + await expect(validator.validate()).rejects.toThrow('The name field is required.') + }) + }) + + describe('integration-like behavior', () => { + it('can be reused with different data', async () => { + const validator = new Validator( + { email: 'invalid' }, + { email: 'required|email' } + ) + + await expect(validator.validate()).rejects.toThrowError(ValidationException) + + validator.setData({ email: 'valid@example.com' }) + const result = await validator.passes() + expect(result).toBe(true) + }) + }) +}) \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e24fa1b1..55a04c06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,145 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -catalogs: - default: - '@changesets/cli': - specifier: ^2.29.7 - version: 2.29.7 - '@eslint/js': - specifier: ^9.39.1 - version: 9.39.1 - '@rollup/plugin-run': - specifier: ^3.1.0 - version: 3.1.0 - '@swc/core': - specifier: ^1.15.0 - version: 1.14.0 - '@types/luxon': - specifier: ^3.7.1 - version: 3.7.1 - '@types/nodemailer': - specifier: ^6.4.17 - version: 6.4.17 - '@types/semver': - specifier: ^7.7.1 - version: 7.7.1 - '@typescript-eslint/eslint-plugin': - specifier: ^8.46.3 - version: 8.46.3 - '@typescript-eslint/parser': - specifier: ^8.46.3 - version: 8.46.3 - '@vitest/coverage-v8': - specifier: ^3.2.4 - version: 3.2.4 - argon2: - specifier: ^0.44.0 - version: 0.44.0 - barrelize: - specifier: 1.6.6 - version: 1.6.6 - barrelsby: - specifier: ^2.8.1 - version: 2.8.1 - bcryptjs: - specifier: ^3.0.2 - version: 3.0.2 - cross-env: - specifier: ^10.1.0 - version: 10.1.0 - dayjs: - specifier: ^1.11.18 - version: 1.11.18 - detect-port: - specifier: ^2.1.0 - version: 2.1.0 - dotenv: - specifier: ^17.2.3 - version: 17.2.3 - dotenv-expand: - specifier: ^12.0.3 - version: 12.0.3 - edge.js: - specifier: ^6.3.0 - version: 6.3.0 - escalade: - specifier: ^3.2.0 - version: 3.2.0 - eslint: - specifier: ^9.39.1 - version: 9.39.1 - execa: - specifier: ^9.6.0 - version: 9.6.0 - fast-glob: - specifier: ^3.3.3 - version: 3.3.3 - husky: - specifier: ^9.1.7 - version: 9.1.7 - knex: - specifier: ^3.1.0 - version: 3.1.0 - luxon: - specifier: ^3.7.2 - version: 3.7.2 - path: - specifier: ^0.12.7 - version: 0.12.7 - preferred-pm: - specifier: ^4.1.1 - version: 4.1.1 - reflect-metadata: - specifier: ^0.2.2 - version: 0.2.2 - resolve-from: - specifier: ^5.0.0 - version: 5.0.0 - rimraf: - specifier: ^6.1.0 - version: 6.1.0 - semver: - specifier: ^7.7.2 - version: 7.7.3 - source-map-support: - specifier: ^0.5.21 - version: 0.5.21 - sqlite3: - specifier: 5.1.7 - version: 5.1.7 - ts-node: - specifier: ^10.9.2 - version: 10.9.2 - tsconfig-paths: - specifier: ^4.2.0 - version: 4.2.0 - tslib: - specifier: ^2.8.1 - version: 2.8.1 - tsx: - specifier: ^4.20.6 - version: 4.20.5 - typescript-eslint: - specifier: ^8.46.3 - version: 8.46.3 - utility-types: - specifier: ^3.11.0 - version: 3.11.0 - vite-tsconfig-paths: - specifier: ^5.1.4 - version: 5.1.4 - prod: - '@h3ravel/arquebus': - specifier: ^0.6.16 - version: 0.6.16 - '@h3ravel/musket': - specifier: ^0.3.11 - version: 0.3.11 - h3: - specifier: 2.0.1-rc.5 - version: 2.0.1-rc.5 - importers: .: @@ -687,6 +548,25 @@ importers: specifier: ^5.4.0 version: 5.9.2 + packages/validation: + dependencies: + '@h3ravel/shared': + specifier: workspace:^ + version: link:../shared + '@h3ravel/support': + specifier: workspace:^ + version: link:../support + robust-validator: + specifier: ^3.0.0 + version: 3.0.0 + simple-body-validator: + specifier: ^1.3.9 + version: 1.3.9 + devDependencies: + typescript: + specifier: ^5.4.0 + version: 5.9.3 + packages/view: dependencies: '@h3ravel/core': @@ -3305,6 +3185,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dayjs@1.11.18: resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} @@ -4928,6 +4811,10 @@ packages: engines: {node: 20 || >=22} hasBin: true + robust-validator@3.0.0: + resolution: {integrity: sha512-/HBCtMSuvCk+01wUNpnBq9BPOOOlMuCfPJnv4obuTS9hmD8M0FSX/z8ppkkYK3qoYcQPHJGPAoSOL2WtSxFf4A==} + engines: {node: '>=18.0.0'} + rolldown-plugin-dts@0.17.3: resolution: {integrity: sha512-8mGnNUVNrqEdTnrlcaDxs4sAZg0No6njO+FuhQd4L56nUbJO1tHxOoKDH3mmMJg7f/BhEj/1KjU5W9kZ9zM/kQ==} engines: {node: '>=20.18.0'} @@ -5032,6 +4919,9 @@ packages: resolution: {integrity: sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==} engines: {node: '>=6'} + simple-body-validator@1.3.9: + resolution: {integrity: sha512-zruc+5Y+L16lHTX+z/aSp8payiGGk1GM7UC9rs8j+lKOwxikfm+/RACsrjh461FCyyOcwAbu7s0UnKRKy9nUyQ==} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -9088,6 +8978,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + date-fns@4.1.0: {} + dayjs@1.11.18: {} dayjs@1.11.19: {} @@ -10766,6 +10658,10 @@ snapshots: glob: 11.0.3 package-json-from-dist: 1.0.1 + robust-validator@3.0.0: + dependencies: + date-fns: 4.1.0 + rolldown-plugin-dts@0.17.3(rolldown@1.0.0-beta.45)(typescript@5.9.3): dependencies: '@babel/generator': 7.28.5 @@ -10946,6 +10842,8 @@ snapshots: figures: 2.0.0 pkg-conf: 2.1.0 + simple-body-validator@1.3.9: {} + simple-concat@1.0.1: {} simple-get@4.0.1: From 696292a6eac29a4278550aff6e11425db7d35a4c Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Sat, 8 Nov 2025 04:51:13 +0100 Subject: [PATCH 03/28] feat: add path control for h3ravel() entry point. --- .barrelize | 8 + db.sqlite | 0 package.json | 1 + .../core/src/Contracts/H3ravelContract.ts | 4 + packages/core/src/H3ravel.ts | 7 + packages/support/src/Helpers/Time.ts | 8 +- packages/validation/package.json | 7 +- packages/validation/src/BaseRule.ts | 6 + .../validation/src/Contracts/RuleBuilder.ts | 9 + .../src/Contracts/ValidationRuleName.ts | 6 + .../Providers/ValidationServiceProvider.ts | 2 +- packages/validation/src/Rule.ts | 16 - .../validation/src/Rules/ExtendedRules.ts | 108 ++++ packages/validation/src/Validator.ts | 34 +- packages/validation/src/index.ts | 9 + packages/validation/tests/config/database.ts | 160 +++++ packages/validation/tests/config/db.sqlite3 | 0 packages/validation/tests/validator.spec.ts | 148 +++-- pnpm-lock.yaml | 605 +++++++----------- pnpm-workspace.yaml | 3 +- 20 files changed, 689 insertions(+), 452 deletions(-) create mode 100644 db.sqlite create mode 100644 packages/validation/src/BaseRule.ts create mode 100644 packages/validation/src/Contracts/RuleBuilder.ts delete mode 100644 packages/validation/src/Rule.ts create mode 100644 packages/validation/src/Rules/ExtendedRules.ts create mode 100644 packages/validation/src/index.ts create mode 100644 packages/validation/tests/config/database.ts create mode 100644 packages/validation/tests/config/db.sqlite3 diff --git a/.barrelize b/.barrelize index 11675fc5..0eda06b1 100644 --- a/.barrelize +++ b/.barrelize @@ -125,6 +125,14 @@ ] } }, + { + "name": "index.ts", + "root": "packages/validation/src", + "exclude": [ + "**/*.test.ts", + "**/*.d.ts" + ] + }, { "name": "index.ts", "root": "packages/support/src", diff --git a/db.sqlite b/db.sqlite new file mode 100644 index 00000000..e69de29b diff --git a/package.json b/package.json index f5fa2ec8..774a8c69 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "fetchdts": "^0.1.7", "husky": "catalog:", "knex": "catalog:", + "mysql2": "catalog:", "madge": "^8.0.0", "nodemailer": "^7.0.10", "path": "catalog:", diff --git a/packages/core/src/Contracts/H3ravelContract.ts b/packages/core/src/Contracts/H3ravelContract.ts index 811ba267..c411cffe 100644 --- a/packages/core/src/Contracts/H3ravelContract.ts +++ b/packages/core/src/Contracts/H3ravelContract.ts @@ -24,4 +24,8 @@ export interface EntryConfig { * @default [] */ filteredProviders?: string[] + /** + * Overide the defined system path + */ + customPaths?: Partial> } \ No newline at end of file diff --git a/packages/core/src/H3ravel.ts b/packages/core/src/H3ravel.ts index 8fd80e1a..0b1c98a7 100644 --- a/packages/core/src/H3ravel.ts +++ b/packages/core/src/H3ravel.ts @@ -35,6 +35,13 @@ export const h3ravel = async ( // Initialize the Application class const app = new Application(basePath) + // Overide defined paths + if (config.customPaths) { + for (const [name, path] of Object.entries(config.customPaths)) { + app.setPath(name as never, path) + } + } + // Start up the app // @ts-expect-error Provider signature does not match since param is optional, but it should work await app.quickStartup(providers, config.filteredProviders, config.autoload) diff --git a/packages/support/src/Helpers/Time.ts b/packages/support/src/Helpers/Time.ts index 3e0904e4..0b64898c 100644 --- a/packages/support/src/Helpers/Time.ts +++ b/packages/support/src/Helpers/Time.ts @@ -1,4 +1,4 @@ -import dayjs, { ConfigType, Dayjs, OpUnitType } from 'dayjs' +import dayjs, { ConfigType, Dayjs, OpUnitType, OptionType } from 'dayjs' import advancedFormat from 'dayjs/plugin/advancedFormat.js' import customParseFormat from 'dayjs/plugin/customParseFormat.js' @@ -36,10 +36,12 @@ const TimeClass = class { } as { new(date?: dayjs.ConfigType): Dayjs } & typeof export class DateTime extends TimeClass { private instance: Dayjs - constructor(config?: ConfigType) { + constructor(config?: ConfigType) + constructor(config?: ConfigType, format?: OptionType, locale?: boolean) + constructor(config?: ConfigType, format?: OptionType, locale?: string | boolean, strict?: boolean) { super(config) - this.instance = dayjs(config) + this.instance = dayjs(config, format, locale as never, strict) return new Proxy(this, { get: (target, prop, receiver) => { if (prop in target) return Reflect.get(target, prop, receiver) diff --git a/packages/validation/package.json b/packages/validation/package.json index ea414edf..5f4abd6f 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -64,12 +64,12 @@ "dependencies": { "@h3ravel/shared": "workspace:^", "@h3ravel/support": "workspace:^", - "robust-validator": "^3.0.0", "simple-body-validator": "^1.3.9" }, "peerDependencies": { + "@h3ravel/core": "workspace:^", "@h3ravel/config": "workspace:^", - "@h3ravel/core": "workspace:^" + "@h3ravel/database": "workspace:^" }, "devDependencies": { "typescript": "^5.4.0" @@ -80,6 +80,9 @@ }, "@h3ravel/config": { "optional": true + }, + "@h3ravel/database": { + "optional": true } } } \ No newline at end of file diff --git a/packages/validation/src/BaseRule.ts b/packages/validation/src/BaseRule.ts new file mode 100644 index 00000000..2d98a4f0 --- /dev/null +++ b/packages/validation/src/BaseRule.ts @@ -0,0 +1,6 @@ +import { Rule } from 'simple-body-validator' +import type { RuleCallable } from './Contracts/RuleBuilder' + +export class BaseRule extends Rule { + rules: RuleCallable[] = [] +} \ No newline at end of file diff --git a/packages/validation/src/Contracts/RuleBuilder.ts b/packages/validation/src/Contracts/RuleBuilder.ts new file mode 100644 index 00000000..72304685 --- /dev/null +++ b/packages/validation/src/Contracts/RuleBuilder.ts @@ -0,0 +1,9 @@ +import type { BaseRule } from '../BaseRule' + +export interface RuleCallable { + name: string; + validator: (value: any, parameters?: string[], attribute?: string) => boolean | Promise; + message?: string +} + +export type CustomRules = BaseRule | RuleCallable \ No newline at end of file diff --git a/packages/validation/src/Contracts/ValidationRuleName.ts b/packages/validation/src/Contracts/ValidationRuleName.ts index 18878a31..d9649847 100644 --- a/packages/validation/src/Contracts/ValidationRuleName.ts +++ b/packages/validation/src/Contracts/ValidationRuleName.ts @@ -12,18 +12,22 @@ export type ParamableRuleName = | 'before_or_equal' | 'between' | 'date_equals' + | 'datetime' | 'declined_if' | 'digits_between' | 'different' + | 'exists' | 'ends_with' | 'gt' | 'gte' | 'in' + | 'includes' | 'lt' | 'lte' | 'max' | 'min' | 'not_in' + | 'not_includes' | 'required_if' | 'required_unless' | 'required_with' @@ -33,6 +37,7 @@ export type ParamableRuleName = | 'same' | 'size' | 'starts_with' + | 'unique' export type PlainRuleName = | 'accepted' @@ -60,6 +65,7 @@ export type PlainRuleName = | 'sometimes' | 'string' | 'url' + | 'hex' | 'uuid' export type ValidationRuleName = ParamableRuleName | PlainRuleName diff --git a/packages/validation/src/Providers/ValidationServiceProvider.ts b/packages/validation/src/Providers/ValidationServiceProvider.ts index 8ffff10b..8c8994d1 100644 --- a/packages/validation/src/Providers/ValidationServiceProvider.ts +++ b/packages/validation/src/Providers/ValidationServiceProvider.ts @@ -3,7 +3,7 @@ import { ServiceProvider } from '@h3ravel/core' /** * Service provider for Validation utilities */ -export class UrlServiceProvider extends ServiceProvider { +export class ValidationServiceProvider extends ServiceProvider { public static priority = 895 /** diff --git a/packages/validation/src/Rule.ts b/packages/validation/src/Rule.ts deleted file mode 100644 index 5723d947..00000000 --- a/packages/validation/src/Rule.ts +++ /dev/null @@ -1,16 +0,0 @@ -export class Rule { - /** - * Checks if a database record exists - */ - public static exists (value: any) { - return { - rule () { - return false - }, - name: 'exists', - messages: { - en: 'The record doesn\'t exists on database: {0}', - }, - } - } -} \ No newline at end of file diff --git a/packages/validation/src/Rules/ExtendedRules.ts b/packages/validation/src/Rules/ExtendedRules.ts new file mode 100644 index 00000000..a09fc9bb --- /dev/null +++ b/packages/validation/src/Rules/ExtendedRules.ts @@ -0,0 +1,108 @@ +import { BaseRule } from '../BaseRule' +import { DateTime } from '@h3ravel/support' +import type { RuleCallable } from '../Contracts/RuleBuilder' + +export class ExtendedRules extends BaseRule { + rules: RuleCallable[] = [ + { + + name: 'hex', + validator: (value: any) => { + if (typeof value !== 'string') return false + return /^[0-9a-fA-F]+$/.test(value.replace('#', '')) + }, + message: 'The :attribute must be a valid hexadecimal string.' + }, + { + name: 'includes', + validator: (value: any, parameters: string[] = []) => { + if (value == null) return false + + if (Array.isArray(value)) { + return parameters.some(param => value.includes(param)) + } + + if (typeof value === 'string') { + return parameters.some(param => value.includes(param)) + } + + return false + }, + message: 'The :attribute must include one of the following values: :values.' + }, + { + name: 'not_includes', + validator: (value: any, parameters: string[] = []) => { + if (value == null) return true + + if (Array.isArray(value)) { + return parameters.every(param => !value.includes(param)) + } + + if (typeof value === 'string') { + return parameters.every(param => !value.includes(param)) + } + + return true + }, + message: 'The :attribute must not include any of the following values: :values.' + }, + { + name: 'datetime', + validator: (value: any, parameters: string[] = [], attr) => { + console.log(this.data, attr) + if (typeof value !== 'string') return false + const [format] = parameters + + if (!format) { + return !isNaN(Date.parse(value)) + } + + try { + return new DateTime(value, format, true).isValid() + } catch { + return !isNaN(Date.parse(value)) + } + }, + message: 'The :attribute must be a valid date matching the format :format.' + }, + { + name: 'exists', + validator: async (value: any, parameters: string[] = []) => { + const [tab, column, ignore] = parameters + try { + const { DB } = await import(('@h3ravel/database')) + const [conn, table] = tab.split('.') + const query = DB.instance(table && conn ? conn : 'default').table(table && conn ? table : conn) + if (ignore) { + query.whereNot(column, ignore) + } + + return await query.where(column, value).exists() + } catch { + return false + } + }, + message: 'The :attribute does not exist.' + }, + { + name: 'unique', + validator: async (value: any, parameters: string[] = []) => { + const [tab, column, ignore] = parameters + try { + const { DB } = await import(('@h3ravel/database')) + const [conn, table] = tab.split('.') + const query = DB.instance(table && conn ? conn : 'default').table(table && conn ? table : conn) + if (ignore) { + query.whereNot(column, ignore) + } + + return !(await query.where(column, value).exists()) + } catch { + return false + } + }, + message: 'The :attribute does not exist.' + }, + ] +} \ No newline at end of file diff --git a/packages/validation/src/Validator.ts b/packages/validation/src/Validator.ts index 138e744a..ea51c46c 100644 --- a/packages/validation/src/Validator.ts +++ b/packages/validation/src/Validator.ts @@ -1,10 +1,17 @@ import { DotPaths, MessagesForRules, RulesForData } from './Contracts/ValidatorContracts' -import { Validator as SimpleBodyValidator, make } from 'simple-body-validator' +import { Validator as SimpleBodyValidator, make, register, setTranslationObject } from 'simple-body-validator' +import { BaseRule } from './BaseRule' +import { CustomRules } from './Contracts/RuleBuilder' +import { ExtendedRules } from './Rules/ExtendedRules' import { MessageBag } from './utilities/MessageBag' import { RuleSet } from './Contracts/ValidationRuleName' import { ValidationException } from './ValidationException' +register('telephone', function (value) { + return /^\d{3}-\d{3}-\d{4}$/.test(value) +}) + export class Validator< D extends Record, R extends RulesForData @@ -18,6 +25,9 @@ export class Validator< private executed: boolean = false private instance?: SimpleBodyValidator private errorBagName = 'default' + private registeredCustomRules: CustomRules[] = [ + new ExtendedRules() + ] private shouldStopOnFirstFailure = false constructor( @@ -29,6 +39,7 @@ export class Validator< this.rules = rules this.#messages = messages this._errors = new MessageBag() + this.registerCustomRules() } /** @@ -200,6 +211,27 @@ export class Validator< return this.rules } + /** + * Stop validation on first failure. + */ + private registerCustomRules () { + for (const reged of this.registeredCustomRules) { + if (reged instanceof BaseRule) { + for (const rule of reged.rules) { + register(rule.name, rule.validator) + if (rule.message) { + setTranslationObject({ + en: { + [rule.name]: rule.message, + } + }) + } + } + } + } + return this + } + private async execute () { const instance = make() .setData(this.data) diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts new file mode 100644 index 00000000..4544ae3b --- /dev/null +++ b/packages/validation/src/index.ts @@ -0,0 +1,9 @@ +export * from './BaseRule' +export * from './Contracts/RuleBuilder' +export * from './Contracts/ValidationRuleName' +export * from './Contracts/ValidatorContracts' +export * from './Providers/ValidationServiceProvider' +export * from './Rules/ExtendedRules' +export * from './utilities/MessageBag' +export * from './ValidationException' +export * from './Validator' diff --git a/packages/validation/tests/config/database.ts b/packages/validation/tests/config/database.ts new file mode 100644 index 00000000..2b96dd32 --- /dev/null +++ b/packages/validation/tests/config/database.ts @@ -0,0 +1,160 @@ +export default () => { + return { + /* + |-------------------------------------------------------------------------- + | Default Database Connection Name + |-------------------------------------------------------------------------- + | + | Here you may specify which of the database connections below you wish + | to use as your default connection for database operations. This is + | the connection which will be utilized unless another connection + | is explicitly specified when you execute a query / statement. + | + */ + + default: 'driver', + + aws_db_host: env('AWS_DB_HOST'), + rds_secret_name: env('AWS_RDS_SECRET_NAME'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by H3ravel. You're free to add / remove connections. + | + */ + + connections: { + + sqlite: { + driver: 'sqlite3', //better-sqlite3 + // database: ':memory:', + database: base_path('config/db.sqlite3'), + prefix: '', + foreign_key_constraints: env('DB_FOREIGN_KEYS', true), + flags: [], + debug: false, + expirationChecker: () => false, + useNullAsDefault: true, + options: { + nativeBinding: undefined, + readonly: false + } + }, + + mysql: { + driver: 'mysql2', //mysql + url: env('DB_URL'), + host: env('DB_HOST', '127.0.0.1'), + port: env('DB_PORT', '3306'), + database: env('DB_DATABASE', 'H3ravel'), + username: env('DB_USERNAME', 'root'), + password: env('DB_PASSWORD', 'password'), + unix_socket: env('DB_SOCKET', ''), + charset: env('DB_CHARSET', 'utf8mb4'), + collation: env('DB_COLLATION', 'utf8mb4_unicode_ci'), + prefix: '', + prefix_indexes: true, + strict: true, + engine: null, + options: [ + ], + }, + + mariadb: { + driver: 'mariasql', + url: env('DB_URL'), + host: env('DB_HOST', '127.0.0.1'), + port: env('DB_PORT', '3306'), + database: env('DB_DATABASE', 'H3ravel'), + username: env('DB_USERNAME', 'root'), + password: env('DB_PASSWORD', 'password'), + unix_socket: env('DB_SOCKET', ''), + charset: env('DB_CHARSET', 'utf8mb4'), + collation: env('DB_COLLATION', 'utf8mb4_unicode_ci'), + prefix: '', + prefix_indexes: true, + strict: true, + engine: null, + options: [ + ], + }, + + pgsql: { + driver: 'pg', + url: env('DB_URL'), + host: env('DB_HOST', '127.0.0.1'), + port: env('DB_PORT', '5432'), + database: env('DB_DATABASE', 'H3ravel'), + username: env('DB_USERNAME', 'root'), + password: env('DB_PASSWORD', ''), + charset: env('DB_CHARSET', 'utf8'), + prefix: '', + prefix_indexes: true, + search_path: 'public', + sslmode: 'prefer', + }, + + }, + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + migrations: { + table: 'migrations', + update_date_on_publish: true, + }, + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + redis: { + + client: env('REDIS_CLIENT', 'phpredis'), + + options: { + cluster: env('REDIS_CLUSTER', 'redis'), + prefix: env('REDIS_PREFIX', str(env('APP_NAME', 'h3ravel')).slug('_') + '_database_'), + }, + + default: { + url: env('REDIS_URL'), + host: env('REDIS_HOST', '127.0.0.1'), + username: env('REDIS_USERNAME'), + password: env('REDIS_PASSWORD'), + port: env('REDIS_PORT', '6379'), + database: env('REDIS_DB', '0'), + }, + + cache: { + url: env('REDIS_URL'), + host: env('REDIS_HOST', '127.0.0.1'), + username: env('REDIS_USERNAME'), + password: env('REDIS_PASSWORD'), + port: env('REDIS_PORT', '6379'), + database: env('REDIS_CACHE_DB', '1'), + }, + + }, + } +} diff --git a/packages/validation/tests/config/db.sqlite3 b/packages/validation/tests/config/db.sqlite3 new file mode 100644 index 00000000..e69de29b diff --git a/packages/validation/tests/validator.spec.ts b/packages/validation/tests/validator.spec.ts index ca383c18..b06d8aa9 100644 --- a/packages/validation/tests/validator.spec.ts +++ b/packages/validation/tests/validator.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' import { ValidationException } from '../src/ValidationException' import { Validator } from '../src/Validator' @@ -7,115 +7,115 @@ describe('Validator', () => { describe('basic rules', () => { it('throws ValidationException for invalid email', async () => { - const validator = new Validator( + const v = new Validator( { email: 'invalid-email' }, { email: 'required|email' } ) - await expect(validator.validate()).rejects.toThrowError(ValidationException) + await expect(v.validate()).rejects.toThrowError(ValidationException) }) it('passes validation for valid data', async () => { - const validator = new Validator( + const v = new Validator( { email: 'valid@example.com', name: 'John' }, { email: 'required|email', name: 'required|min:2|max:10' } ) - const result = await validator.passes() + const result = await v.passes() expect(result).toBe(true) - expect(validator.errors().isEmpty()).toBe(true) + expect(v.errors().isEmpty()).toBe(true) }) it('fails validation when required field is missing', async () => { - const validator = new Validator({}, { name: 'required' }) + const v = new Validator({}, { name: 'required' }) - await expect(validator.validate()).rejects.toThrowError(ValidationException) + await expect(v.validate()).rejects.toThrowError(ValidationException) }) it('applies multiple rules correctly', async () => { - const validator = new Validator( + const v = new Validator( { name: 'A' }, { name: 'required|min:2|max:5' } ) - await expect(validator.validate()).rejects.toThrowError(ValidationException) + await expect(v.validate()).rejects.toThrowError(ValidationException) }) }) describe('advanced rules', () => { it('validates numeric and min/max values', async () => { - const validator = new Validator( + const v = new Validator( { age: 15 }, { age: 'required|numeric|min:18|max:60' } ) - await expect(validator.validate()).rejects.toThrowError(ValidationException) + await expect(v.validate()).rejects.toThrowError(ValidationException) }) it('passes when numeric is in valid range', async () => { - const validator = new Validator( + const v = new Validator( { age: 25 }, { age: 'required|numeric|min:18|max:60' } ) - const result = await validator.passes() + const result = await v.passes() expect(result).toBe(true) }) it('validates boolean values', async () => { - const validator = new Validator( + const v = new Validator( { active: 'yes' }, { active: 'boolean' }, ) - await expect(validator.validate()).rejects.toThrowError(ValidationException) + await expect(v.validate()).rejects.toThrowError(ValidationException) }) }) describe('arrays and nested data', () => { it('validates wildcard array elements', async () => { - const validator = new Validator( + const v = new Validator( { users: [{ email: 'bad' }, { email: 'good@example.com' }] }, { 'users.*.email': 'required|email', }, { 'users.*.email.required': 'Hello' } ) - await expect(validator.validate()).rejects.toThrowError(ValidationException) + await expect(v.validate()).rejects.toThrowError(ValidationException) }) it('validates nested object fields', async () => { - const validator = new Validator( + const v = new Validator( { user: { name: { first: '', last: 'Doe' } } }, { 'user.name.first': 'required|min:2', 'user.name.last': 'required|min:2' } ) - await expect(validator.validate()).rejects.toThrowError(ValidationException) + await expect(v.validate()).rejects.toThrowError(ValidationException) }) it('passes with valid nested fields', async () => { - const validator = new Validator( + const v = new Validator( { user: { name: { first: 'John', last: 'Doe' } } }, { 'user.name.first': 'required|min:2', 'user.name.last': 'required|min:2' } ) - const result = await validator.passes() + const result = await v.passes() expect(result).toBe(true) }) }) describe('custom messages', () => { it('uses custom error messages', async () => { - const validator = new Validator( + const v = new Validator( { email: '' }, { email: 'required|email' }, { 'email.required': 'Email is required!' } ) - await expect(validator.validate()).rejects.toThrowError(ValidationException) + await expect(v.validate()).rejects.toThrowError(ValidationException) try { - await validator.validate() + await v.validate() } catch (error: any) { expect(error.errors().email[0]).toBe('Email is required!') } @@ -124,22 +124,22 @@ describe('Validator', () => { describe('optional and nullable', () => { it('passes when optional field is missing', async () => { - const validator = new Validator({}, { nickname: 'nullable|min:2' }) - const result = await validator.passes() + const v = new Validator({}, { nickname: 'nullable|min:2' }) + const result = await v.passes() expect(result).toBe(true) }) it('fails when nullable field is provided but invalid', async () => { - const validator = new Validator({ nickname: 'A' }, { nickname: 'nullable|min:2' }) - await expect(validator.validate()).rejects.toThrowError(ValidationException) + const v = new Validator({ nickname: 'A' }, { nickname: 'nullable|min:2' }) + await expect(v.validate()).rejects.toThrowError(ValidationException) }) }) describe('error structure and message', () => { it('provides a structured errors object', async () => { - const validator = new Validator({ email: '' }, { email: 'required|email' }) + const v = new Validator({ email: '' }, { email: 'required|email' }) try { - await validator.validate() + await v.validate() } catch (error: any) { expect(error).toBeInstanceOf(ValidationException) expect(typeof error.errors()).toBe('object') @@ -149,23 +149,97 @@ describe('Validator', () => { }) it('throws with proper message', async () => { - const validator = new Validator({ name: '' }, { name: 'required' }) - await expect(validator.validate()).rejects.toThrow('The name field is required.') + const v = new Validator({ name: '' }, { name: 'required' }) + await expect(v.validate()).rejects.toThrow('The name field is required.') }) }) describe('integration-like behavior', () => { it('can be reused with different data', async () => { - const validator = new Validator( + const v = new Validator( { email: 'invalid' }, { email: 'required|email' } ) - await expect(validator.validate()).rejects.toThrowError(ValidationException) + await expect(v.validate()).rejects.toThrowError(ValidationException) - validator.setData({ email: 'valid@example.com' }) - const result = await validator.passes() + v.setData({ email: 'valid@example.com' }) + const result = await v.passes() expect(result).toBe(true) }) }) + + describe('extended rules', () => { + // let app: Application + + beforeAll(async () => { + // const DatabaseServiceProvider = (await import(('@h3ravel/database'))).DatabaseServiceProvider + // const HttpServiceProvider = (await import(('@h3ravel/http'))).HttpServiceProvider + // const ConfigServiceProvider = (await import(('@h3ravel/config'))).ConfigServiceProvider + // app = await h3ravel( + // [HttpServiceProvider, DatabaseServiceProvider, ConfigServiceProvider, ValidationServiceProvider], + // path.join(process.cwd(), 'packages/validation/tests'), + // { + // autoload: false, + // customPaths: { + // config: 'config' + // } + // }) + + // // const { DB } = await import('@h3ravel/database') + // // class User extends Model { + // // } + // console.log(app) + }) + + it('includes: should validate included item in the given list of values.', async () => { + const v = new Validator( + { choice: 'news' }, + { choice: 'includes:news,marketing' } + ) + + const result = await v.passes() + expect(result).toBe(true) + }) + + it('hex: should validate hexadecimal format', async () => { + const v = new Validator( + { color: '#e1a88b' }, + { color: 'hex' } + ) + + const result = await v.passes() + expect(result).toBe(true) + }) + + it('not_includes: should validate hexadecimal format', async () => { + const v = new Validator( + { choice: 'yam' }, + { choice: 'not_includes:fish,egg' } + ) + + const result = await v.passes() + expect(result).toBe(true) + }) + + it('datetime: should validate datetime format', async () => { + const v = new Validator( + { date: '2025-07-07' }, + { date: 'string|datetime:YYYY-MM-DD' } + ) + + const result = await v.passes() + expect(result).toBe(true) + }) + + // it('exists: the user should exist', async () => { + // const v = new Validator( + // { username: 'legacy' }, + // { username: 'exists:users,username' } + // ) + + // const result = await v.passes() + // expect(result).toBe(true) + // }) + }) }) \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55a04c06..510e5e51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,148 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + '@changesets/cli': + specifier: ^2.29.7 + version: 2.29.7 + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.1 + '@rollup/plugin-run': + specifier: ^3.1.0 + version: 3.1.0 + '@swc/core': + specifier: ^1.15.0 + version: 1.15.0 + '@types/luxon': + specifier: ^3.7.1 + version: 3.7.1 + '@types/nodemailer': + specifier: ^6.4.17 + version: 6.4.17 + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 + '@typescript-eslint/eslint-plugin': + specifier: ^8.46.3 + version: 8.46.3 + '@typescript-eslint/parser': + specifier: ^8.46.3 + version: 8.46.3 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4 + argon2: + specifier: ^0.44.0 + version: 0.44.0 + barrelize: + specifier: 1.6.6 + version: 1.6.6 + barrelsby: + specifier: ^2.8.1 + version: 2.8.1 + bcryptjs: + specifier: ^3.0.2 + version: 3.0.2 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 + dayjs: + specifier: ^1.11.18 + version: 1.11.19 + detect-port: + specifier: ^2.1.0 + version: 2.1.0 + dotenv: + specifier: ^17.2.3 + version: 17.2.3 + dotenv-expand: + specifier: ^12.0.3 + version: 12.0.3 + edge.js: + specifier: ^6.3.0 + version: 6.3.0 + escalade: + specifier: ^3.2.0 + version: 3.2.0 + eslint: + specifier: ^9.39.1 + version: 9.39.1 + execa: + specifier: ^9.6.0 + version: 9.6.0 + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 + husky: + specifier: ^9.1.7 + version: 9.1.7 + knex: + specifier: ^3.1.0 + version: 3.1.0 + luxon: + specifier: ^3.7.2 + version: 3.7.2 + mysql2: + specifier: 3.15.3 + version: 3.15.3 + path: + specifier: ^0.12.7 + version: 0.12.7 + preferred-pm: + specifier: ^4.1.1 + version: 4.1.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + resolve-from: + specifier: ^5.0.0 + version: 5.0.0 + rimraf: + specifier: ^6.1.0 + version: 6.1.0 + semver: + specifier: ^7.7.2 + version: 7.7.3 + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + sqlite3: + specifier: 5.1.7 + version: 5.1.7 + ts-node: + specifier: ^10.9.2 + version: 10.9.2 + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + tsx: + specifier: ^4.20.6 + version: 4.20.6 + typescript-eslint: + specifier: ^8.46.3 + version: 8.46.3 + utility-types: + specifier: ^3.11.0 + version: 3.11.0 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4 + prod: + '@h3ravel/arquebus': + specifier: ^0.6.17 + version: 0.6.17 + '@h3ravel/musket': + specifier: ^0.3.11 + version: 0.3.11 + h3: + specifier: 2.0.1-rc.5 + version: 2.0.1-rc.5 + importers: .: @@ -19,7 +161,7 @@ importers: version: 9.39.1 '@swc/core': specifier: 'catalog:' - version: 1.14.0 + version: 1.15.0 '@types/node': specifier: ^24.10.0 version: 24.10.0 @@ -67,10 +209,13 @@ importers: version: 9.1.7 knex: specifier: 'catalog:' - version: 3.1.0(mysql2@3.15.0)(pg@8.16.3)(sqlite3@5.1.7)(tedious@19.0.0) + version: 3.1.0(mysql2@3.15.3)(pg@8.16.3)(sqlite3@5.1.7)(tedious@19.0.0) madge: specifier: ^8.0.0 version: 8.0.0(typescript@5.9.3) + mysql2: + specifier: 'catalog:' + version: 3.15.3 nodemailer: specifier: ^7.0.10 version: 7.0.10 @@ -91,7 +236,7 @@ importers: version: 6.1.0 ts-node: specifier: 'catalog:' - version: 10.9.2(@swc/core@1.14.0)(@types/node@24.10.0)(typescript@5.9.3) + version: 10.9.2(@swc/core@1.15.0)(@types/node@24.10.0)(typescript@5.9.3) tsconfig-paths: specifier: 'catalog:' version: 4.2.0 @@ -118,7 +263,7 @@ importers: dependencies: '@h3ravel/arquebus': specifier: catalog:prod - version: 0.6.16(@types/node@24.9.2)(sqlite3@5.1.7) + version: 0.6.17(@types/node@24.9.2)(sqlite3@5.1.7) '@h3ravel/cache': specifier: workspace:^ version: link:../../packages/cache @@ -188,7 +333,7 @@ importers: version: 3.1.0(rollup@4.52.5) '@swc/core': specifier: 'catalog:' - version: 1.14.0 + version: 1.15.0 '@types/node': specifier: ^24.9.2 version: 24.9.2 @@ -197,7 +342,7 @@ importers: version: 0.15.12(typescript@5.9.3) tsx: specifier: 'catalog:' - version: 4.20.5 + version: 4.20.6 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -229,7 +374,7 @@ importers: version: link:../core tsx: specifier: 'catalog:' - version: 4.20.5 + version: 4.20.6 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -256,7 +401,7 @@ importers: version: 14.0.1 dayjs: specifier: 'catalog:' - version: 1.11.18 + version: 1.11.19 dotenv: specifier: 'catalog:' version: 17.2.3 @@ -274,7 +419,7 @@ importers: version: 5.0.0 tsx: specifier: 'catalog:' - version: 4.20.5 + version: 4.20.6 devDependencies: typescript: specifier: ^5.9.2 @@ -333,7 +478,7 @@ importers: dependencies: '@h3ravel/arquebus': specifier: catalog:prod - version: 0.6.16(@types/node@24.10.0)(sqlite3@5.1.7) + version: 0.6.17(@types/node@24.10.0)(sqlite3@5.1.7) '@h3ravel/core': specifier: workspace:^ version: link:../core @@ -514,7 +659,7 @@ importers: dependencies: dayjs: specifier: 'catalog:' - version: 1.11.18 + version: 1.11.19 luxon: specifier: 'catalog:' version: 3.7.2 @@ -548,25 +693,6 @@ importers: specifier: ^5.4.0 version: 5.9.2 - packages/validation: - dependencies: - '@h3ravel/shared': - specifier: workspace:^ - version: link:../shared - '@h3ravel/support': - specifier: workspace:^ - version: link:../support - robust-validator: - specifier: ^3.0.0 - version: 3.0.0 - simple-body-validator: - specifier: ^1.3.9 - version: 1.3.9 - devDependencies: - typescript: - specifier: ^5.4.0 - version: 5.9.3 - packages/view: dependencies: '@h3ravel/core': @@ -1005,12 +1131,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.11': - resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -1023,12 +1143,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.11': - resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -1041,12 +1155,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.11': - resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -1059,12 +1167,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.11': - resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -1077,12 +1179,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.11': - resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -1095,12 +1191,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.11': - resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -1113,12 +1203,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.11': - resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -1131,12 +1215,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.11': - resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -1149,12 +1227,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.11': - resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -1167,12 +1239,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.11': - resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -1185,12 +1251,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.11': - resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -1203,12 +1263,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.11': - resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -1221,12 +1275,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.11': - resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -1239,12 +1287,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.11': - resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -1257,12 +1299,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.11': - resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -1275,12 +1311,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.11': - resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -1293,24 +1323,12 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.11': - resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.11': - resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -1323,24 +1341,12 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.11': - resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.11': - resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -1353,24 +1359,12 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.11': - resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.11': - resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -1383,12 +1377,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.11': - resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -1401,12 +1389,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.11': - resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -1419,12 +1401,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.11': - resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -1437,12 +1413,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.11': - resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -1490,8 +1460,8 @@ packages: '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} - '@h3ravel/arquebus@0.6.16': - resolution: {integrity: sha512-sHan8yzUxG5e461ItA8oXOLdvxkEbrKGzBPx7RYRDmSZ8ZokBfidnPQCkulP0CU8TM1dAqH+9gL/yG7KTKWF3Q==} + '@h3ravel/arquebus@0.6.17': + resolution: {integrity: sha512-i94Y4qUr42HMaxuWzoscw0YIs9QWyBtmGp5r8vvltnqDFYQ6YZ4R0cgzhjkPrMKAPPjewQ6Y4Pg1nQtGjuLq5w==} engines: {node: '>=14', pnpm: '>=4'} hasBin: true @@ -2506,68 +2476,68 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} - '@swc/core-darwin-arm64@1.14.0': - resolution: {integrity: sha512-uHPC8rlCt04nvYNczWzKVdgnRhxCa3ndKTBBbBpResOZsRmiwRAvByIGh599j+Oo6Z5eyTPrgY+XfJzVmXnN7Q==} + '@swc/core-darwin-arm64@1.15.0': + resolution: {integrity: sha512-TBKWkbnShnEjlIbO4/gfsrIgAqHBVqgPWLbWmPdZ80bF393yJcLgkrb7bZEnJs6FCbSSuGwZv2rx1jDR2zo6YA==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.14.0': - resolution: {integrity: sha512-2SHrlpl68vtePRknv9shvM9YKKg7B9T13tcTg9aFCwR318QTYo+FzsKGmQSv9ox/Ua0Q2/5y2BNjieffJoo4nA==} + '@swc/core-darwin-x64@1.15.0': + resolution: {integrity: sha512-f5JKL1v1H56CIZc1pVn4RGPOfnWqPwmuHdpf4wesvXunF1Bx85YgcspW5YxwqG5J9g3nPU610UFuExJXVUzOiQ==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.14.0': - resolution: {integrity: sha512-SMH8zn01dxt809svetnxpeg/jWdpi6dqHKO3Eb11u4OzU2PK7I5uKS6gf2hx5LlTbcJMFKULZiVwjlQLe8eqtg==} + '@swc/core-linux-arm-gnueabihf@1.15.0': + resolution: {integrity: sha512-duK6nG+WyuunnfsfiTUQdzC9Fk8cyDLqT9zyXvY2i2YgDu5+BH5W6wM5O4mDNCU5MocyB/SuF5YDF7XySnowiQ==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.14.0': - resolution: {integrity: sha512-q2JRu2D8LVqGeHkmpVCljVNltG0tB4o4eYg+dElFwCS8l2Mnt9qurMCxIeo9mgoqz0ax+k7jWtIRHktnVCbjvQ==} + '@swc/core-linux-arm64-gnu@1.15.0': + resolution: {integrity: sha512-ITe9iDtTRXM98B91rvyPP6qDVbhUBnmA/j4UxrHlMQ0RlwpqTjfZYZkD0uclOxSZ6qIrOj/X5CaoJlDUuQ0+Cw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-arm64-musl@1.14.0': - resolution: {integrity: sha512-uofpVoPCEUjYIv454ZEZ3sLgMD17nIwlz2z7bsn7rl301Kt/01umFA7MscUovFfAK2IRGck6XB+uulMu6aFhKQ==} + '@swc/core-linux-arm64-musl@1.15.0': + resolution: {integrity: sha512-Q5ldc2bzriuzYEoAuqJ9Vr3FyZhakk5hiwDbniZ8tlEXpbjBhbOleGf9/gkhLaouDnkNUEazFW9mtqwUTRdh7Q==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-x64-gnu@1.14.0': - resolution: {integrity: sha512-quTTx1Olm05fBfv66DEBuOsOgqdypnZ/1Bh3yGXWY7ANLFeeRpCDZpljD9BSjdsNdPOlwJmEUZXMHtGm3v1TZQ==} + '@swc/core-linux-x64-gnu@1.15.0': + resolution: {integrity: sha512-pY4is+jEpOxlYCSnI+7N8Oxbap9TmTz5YT84tUvRTlOlTBwFAUlWFCX0FRwWJlsfP0TxbqhIe8dNNzlsEmJbXQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-linux-x64-musl@1.14.0': - resolution: {integrity: sha512-caaNAu+aIqT8seLtCf08i8C3/UC5ttQujUjejhMcuS1/LoCKtNiUs4VekJd2UGt+pyuuSrQ6dKl8CbCfWvWeXw==} + '@swc/core-linux-x64-musl@1.15.0': + resolution: {integrity: sha512-zYEt5eT8y8RUpoe7t5pjpoOdGu+/gSTExj8PV86efhj6ugB3bPlj3Y85ogdW3WMVXr4NvwqvzdaYGCZfXzSyVg==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-win32-arm64-msvc@1.14.0': - resolution: {integrity: sha512-EeW3jFlT3YNckJ6V/JnTfGcX7UHGyh6/AiCPopZ1HNaGiXVCKHPpVQZicmtyr/UpqxCXLrTgjHOvyMke7YN26A==} + '@swc/core-win32-arm64-msvc@1.15.0': + resolution: {integrity: sha512-zC1rmOgFH5v2BCbByOazEqs0aRNpTdLRchDExfcCfgKgeaD+IdpUOqp7i3VG1YzkcnbuZjMlXfM0ugpt+CddoA==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.14.0': - resolution: {integrity: sha512-dPai3KUIcihV5hfoO4QNQF5HAaw8+2bT7dvi8E5zLtecW2SfL3mUZipzampXq5FHll0RSCLzlrXnSx+dBRZIIQ==} + '@swc/core-win32-ia32-msvc@1.15.0': + resolution: {integrity: sha512-7t9U9KwMwQblkdJIH+zX1V4q1o3o41i0HNO+VlnAHT5o+5qHJ963PHKJ/pX3P2UlZnBCY465orJuflAN4rAP9A==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.14.0': - resolution: {integrity: sha512-nm+JajGrTqUA6sEHdghDlHMNfH1WKSiuvljhdmBACW4ta4LC3gKurX2qZuiBARvPkephW9V/i5S8QPY1PzFEqg==} + '@swc/core-win32-x64-msvc@1.15.0': + resolution: {integrity: sha512-VE0Zod5vcs8iMLT64m5QS1DlTMXJFI/qSgtMDRx8rtZrnjt6/9NW8XUaiPJuRu8GluEO1hmHoyf1qlbY19gGSQ==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.14.0': - resolution: {integrity: sha512-oExhY90bes5pDTVrei0xlMVosTxwd/NMafIpqsC4dMbRYZ5KB981l/CX8tMnGsagTplj/RcG9BeRYmV6/J5m3w==} + '@swc/core@1.15.0': + resolution: {integrity: sha512-8SnJV+JV0rYbfSiEiUvYOmf62E7QwsEG+aZueqSlKoxFt0pw333+bgZSQXGUV6etXU88nxur0afVMaINujBMSw==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -3185,12 +3155,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - date-fns@4.1.0: - resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - - dayjs@1.11.18: - resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} - dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} @@ -3421,11 +3385,6 @@ packages: engines: {node: '>=12'} hasBin: true - esbuild@0.25.11: - resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -4323,8 +4282,8 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} - mysql2@3.15.0: - resolution: {integrity: sha512-tT6pomf5Z/I7Jzxu8sScgrYBMK9bUFWd7Kbo6Fs1L0M13OOIJ/ZobGKS3Z7tQ8Re4lj+LnLXIQVZZxa3fhYKzA==} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} engines: {node: '>= 8.0'} named-placeholders@1.1.3: @@ -4811,10 +4770,6 @@ packages: engines: {node: 20 || >=22} hasBin: true - robust-validator@3.0.0: - resolution: {integrity: sha512-/HBCtMSuvCk+01wUNpnBq9BPOOOlMuCfPJnv4obuTS9hmD8M0FSX/z8ppkkYK3qoYcQPHJGPAoSOL2WtSxFf4A==} - engines: {node: '>=18.0.0'} - rolldown-plugin-dts@0.17.3: resolution: {integrity: sha512-8mGnNUVNrqEdTnrlcaDxs4sAZg0No6njO+FuhQd4L56nUbJO1tHxOoKDH3mmMJg7f/BhEj/1KjU5W9kZ9zM/kQ==} engines: {node: '>=20.18.0'} @@ -4919,9 +4874,6 @@ packages: resolution: {integrity: sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==} engines: {node: '>=6'} - simple-body-validator@1.3.9: - resolution: {integrity: sha512-zruc+5Y+L16lHTX+z/aSp8payiGGk1GM7UC9rs8j+lKOwxikfm+/RACsrjh461FCyyOcwAbu7s0UnKRKy9nUyQ==} - simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -5265,11 +5217,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.20.5: - resolution: {integrity: sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==} - engines: {node: '>=18.0.0'} - hasBin: true - tsx@4.20.6: resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} engines: {node: '>=18.0.0'} @@ -6675,225 +6622,147 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true - '@esbuild/aix-ppc64@0.25.11': - optional: true - '@esbuild/aix-ppc64@0.25.12': optional: true '@esbuild/android-arm64@0.21.5': optional: true - '@esbuild/android-arm64@0.25.11': - optional: true - '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm@0.21.5': optional: true - '@esbuild/android-arm@0.25.11': - optional: true - '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-x64@0.21.5': optional: true - '@esbuild/android-x64@0.25.11': - optional: true - '@esbuild/android-x64@0.25.12': optional: true '@esbuild/darwin-arm64@0.21.5': optional: true - '@esbuild/darwin-arm64@0.25.11': - optional: true - '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-x64@0.21.5': optional: true - '@esbuild/darwin-x64@0.25.11': - optional: true - '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.21.5': optional: true - '@esbuild/freebsd-arm64@0.25.11': - optional: true - '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-x64@0.21.5': optional: true - '@esbuild/freebsd-x64@0.25.11': - optional: true - '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/linux-arm64@0.21.5': optional: true - '@esbuild/linux-arm64@0.25.11': - optional: true - '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm@0.21.5': optional: true - '@esbuild/linux-arm@0.25.11': - optional: true - '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-ia32@0.21.5': optional: true - '@esbuild/linux-ia32@0.25.11': - optional: true - '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-loong64@0.21.5': optional: true - '@esbuild/linux-loong64@0.25.11': - optional: true - '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-mips64el@0.21.5': optional: true - '@esbuild/linux-mips64el@0.25.11': - optional: true - '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-ppc64@0.21.5': optional: true - '@esbuild/linux-ppc64@0.25.11': - optional: true - '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-riscv64@0.21.5': optional: true - '@esbuild/linux-riscv64@0.25.11': - optional: true - '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-s390x@0.21.5': optional: true - '@esbuild/linux-s390x@0.25.11': - optional: true - '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-x64@0.21.5': optional: true - '@esbuild/linux-x64@0.25.11': - optional: true - '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.25.11': - optional: true - '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-x64@0.21.5': optional: true - '@esbuild/netbsd-x64@0.25.11': - optional: true - '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.25.11': - optional: true - '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-x64@0.21.5': optional: true - '@esbuild/openbsd-x64@0.25.11': - optional: true - '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.25.11': - optional: true - '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/sunos-x64@0.21.5': optional: true - '@esbuild/sunos-x64@0.25.11': - optional: true - '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/win32-arm64@0.21.5': optional: true - '@esbuild/win32-arm64@0.25.11': - optional: true - '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-ia32@0.21.5': optional: true - '@esbuild/win32-ia32@0.25.11': - optional: true - '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-x64@0.21.5': optional: true - '@esbuild/win32-x64@0.25.11': - optional: true - '@esbuild/win32-x64@0.25.12': optional: true @@ -6946,7 +6815,7 @@ snapshots: '@gar/promisify@1.1.3': optional: true - '@h3ravel/arquebus@0.6.16(@types/node@24.10.0)(sqlite3@5.1.7)': + '@h3ravel/arquebus@0.6.17(@types/node@24.10.0)(sqlite3@5.1.7)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.10.0) '@h3ravel/support': 0.15.6 @@ -6960,9 +6829,9 @@ snapshots: dotenv: 17.2.3 escalade: 3.2.0 husky: 9.1.7 - knex: 3.1.0(mysql2@3.15.0)(pg@8.16.3)(sqlite3@5.1.7)(tedious@19.0.0) + knex: 3.1.0(mysql2@3.15.3)(pg@8.16.3)(sqlite3@5.1.7)(tedious@19.0.0) lint-staged: 16.2.0 - mysql2: 3.15.0 + mysql2: 3.15.3 pg: 8.16.3 pluralize: 8.0.0 radashi: 12.7.0 @@ -6977,7 +6846,7 @@ snapshots: - sqlite3 - supports-color - '@h3ravel/arquebus@0.6.16(@types/node@24.9.2)(sqlite3@5.1.7)': + '@h3ravel/arquebus@0.6.17(@types/node@24.9.2)(sqlite3@5.1.7)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.9.2) '@h3ravel/support': 0.15.6 @@ -6991,9 +6860,9 @@ snapshots: dotenv: 17.2.3 escalade: 3.2.0 husky: 9.1.7 - knex: 3.1.0(mysql2@3.15.0)(pg@8.16.3)(sqlite3@5.1.7)(tedious@19.0.0) + knex: 3.1.0(mysql2@3.15.3)(pg@8.16.3)(sqlite3@5.1.7)(tedious@19.0.0) lint-staged: 16.2.0 - mysql2: 3.15.0 + mysql2: 3.15.3 pg: 8.16.3 pluralize: 8.0.0 radashi: 12.7.0 @@ -8261,51 +8130,51 @@ snapshots: dependencies: tslib: 2.8.1 - '@swc/core-darwin-arm64@1.14.0': + '@swc/core-darwin-arm64@1.15.0': optional: true - '@swc/core-darwin-x64@1.14.0': + '@swc/core-darwin-x64@1.15.0': optional: true - '@swc/core-linux-arm-gnueabihf@1.14.0': + '@swc/core-linux-arm-gnueabihf@1.15.0': optional: true - '@swc/core-linux-arm64-gnu@1.14.0': + '@swc/core-linux-arm64-gnu@1.15.0': optional: true - '@swc/core-linux-arm64-musl@1.14.0': + '@swc/core-linux-arm64-musl@1.15.0': optional: true - '@swc/core-linux-x64-gnu@1.14.0': + '@swc/core-linux-x64-gnu@1.15.0': optional: true - '@swc/core-linux-x64-musl@1.14.0': + '@swc/core-linux-x64-musl@1.15.0': optional: true - '@swc/core-win32-arm64-msvc@1.14.0': + '@swc/core-win32-arm64-msvc@1.15.0': optional: true - '@swc/core-win32-ia32-msvc@1.14.0': + '@swc/core-win32-ia32-msvc@1.15.0': optional: true - '@swc/core-win32-x64-msvc@1.14.0': + '@swc/core-win32-x64-msvc@1.15.0': optional: true - '@swc/core@1.14.0': + '@swc/core@1.15.0': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.25 optionalDependencies: - '@swc/core-darwin-arm64': 1.14.0 - '@swc/core-darwin-x64': 1.14.0 - '@swc/core-linux-arm-gnueabihf': 1.14.0 - '@swc/core-linux-arm64-gnu': 1.14.0 - '@swc/core-linux-arm64-musl': 1.14.0 - '@swc/core-linux-x64-gnu': 1.14.0 - '@swc/core-linux-x64-musl': 1.14.0 - '@swc/core-win32-arm64-msvc': 1.14.0 - '@swc/core-win32-ia32-msvc': 1.14.0 - '@swc/core-win32-x64-msvc': 1.14.0 + '@swc/core-darwin-arm64': 1.15.0 + '@swc/core-darwin-x64': 1.15.0 + '@swc/core-linux-arm-gnueabihf': 1.15.0 + '@swc/core-linux-arm64-gnu': 1.15.0 + '@swc/core-linux-arm64-musl': 1.15.0 + '@swc/core-linux-x64-gnu': 1.15.0 + '@swc/core-linux-x64-musl': 1.15.0 + '@swc/core-win32-arm64-msvc': 1.15.0 + '@swc/core-win32-ia32-msvc': 1.15.0 + '@swc/core-win32-x64-msvc': 1.15.0 '@swc/counter@0.1.3': {} @@ -8978,10 +8847,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - date-fns@4.1.0: {} - - dayjs@1.11.18: {} - dayjs@1.11.19: {} debug@4.3.4: @@ -9217,35 +9082,6 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 - esbuild@0.25.11: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.11 - '@esbuild/android-arm': 0.25.11 - '@esbuild/android-arm64': 0.25.11 - '@esbuild/android-x64': 0.25.11 - '@esbuild/darwin-arm64': 0.25.11 - '@esbuild/darwin-x64': 0.25.11 - '@esbuild/freebsd-arm64': 0.25.11 - '@esbuild/freebsd-x64': 0.25.11 - '@esbuild/linux-arm': 0.25.11 - '@esbuild/linux-arm64': 0.25.11 - '@esbuild/linux-ia32': 0.25.11 - '@esbuild/linux-loong64': 0.25.11 - '@esbuild/linux-mips64el': 0.25.11 - '@esbuild/linux-ppc64': 0.25.11 - '@esbuild/linux-riscv64': 0.25.11 - '@esbuild/linux-s390x': 0.25.11 - '@esbuild/linux-x64': 0.25.11 - '@esbuild/netbsd-arm64': 0.25.11 - '@esbuild/netbsd-x64': 0.25.11 - '@esbuild/openbsd-arm64': 0.25.11 - '@esbuild/openbsd-x64': 0.25.11 - '@esbuild/openharmony-arm64': 0.25.11 - '@esbuild/sunos-x64': 0.25.11 - '@esbuild/win32-arm64': 0.25.11 - '@esbuild/win32-ia32': 0.25.11 - '@esbuild/win32-x64': 0.25.11 - esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -9901,7 +9737,7 @@ snapshots: dependencies: json-buffer: 3.0.1 - knex@3.1.0(mysql2@3.15.0)(pg@8.16.3)(sqlite3@5.1.7)(tedious@19.0.0): + knex@3.1.0(mysql2@3.15.3)(pg@8.16.3)(sqlite3@5.1.7)(tedious@19.0.0): dependencies: colorette: 2.0.19 commander: 10.0.1 @@ -9918,7 +9754,7 @@ snapshots: tarn: 3.0.2 tildify: 2.0.0 optionalDependencies: - mysql2: 3.15.0 + mysql2: 3.15.3 pg: 8.16.3 sqlite3: 5.1.7 tedious: 19.0.0 @@ -10185,7 +10021,7 @@ snapshots: mute-stream@2.0.0: {} - mysql2@3.15.0: + mysql2@3.15.3: dependencies: aws-ssl-profiles: 1.1.2 denque: 2.1.0 @@ -10658,10 +10494,6 @@ snapshots: glob: 11.0.3 package-json-from-dist: 1.0.1 - robust-validator@3.0.0: - dependencies: - date-fns: 4.1.0 - rolldown-plugin-dts@0.17.3(rolldown@1.0.0-beta.45)(typescript@5.9.3): dependencies: '@babel/generator': 7.28.5 @@ -10842,8 +10674,6 @@ snapshots: figures: 2.0.0 pkg-conf: 2.1.0 - simple-body-validator@1.3.9: {} - simple-concat@1.0.1: {} simple-get@4.0.1: @@ -11106,7 +10936,7 @@ snapshots: '@ts-graphviz/common': 2.1.5 '@ts-graphviz/core': 2.0.7 - ts-node@10.9.2(@swc/core@1.14.0)(@types/node@24.10.0)(typescript@5.9.3): + ts-node@10.9.2(@swc/core@1.15.0)(@types/node@24.10.0)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -11124,7 +10954,7 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: - '@swc/core': 1.14.0 + '@swc/core': 1.15.0 tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: @@ -11188,13 +11018,6 @@ snapshots: tslib@2.8.1: {} - tsx@4.20.5: - dependencies: - esbuild: 0.25.11 - get-tsconfig: 4.13.0 - optionalDependencies: - fsevents: 2.3.3 - tsx@4.20.6: dependencies: esbuild: 0.25.12 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e9cea0b2..29c947f4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -53,6 +53,7 @@ catalog: semver: ^7.7.2 source-map-support: ^0.5.21 sqlite3: 5.1.7 + mysql2: 3.15.3 ts-node: ^10.9.2 tsconfig-paths: ^4.2.0 tslib: ^2.8.1 @@ -63,7 +64,7 @@ catalog: catalogs: prod: - '@h3ravel/arquebus': ^0.6.16 + '@h3ravel/arquebus': ^0.6.17 '@h3ravel/musket': ^0.3.11 h3: 2.0.1-rc.5 From e6a72ee54dd95cb1cf7421691c1c553c6c743b9c Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Sat, 8 Nov 2025 07:48:31 +0100 Subject: [PATCH 04/28] feat: add support for custom Rule classes. --- packages/validation/README.md | 2 - packages/validation/src/BaseRule.ts | 6 -- .../validation/src/Contracts/RuleBuilder.ts | 6 +- packages/validation/src/ImplicitRule.ts | 16 +++++ .../validation/src/Rules/ExtendedRules.ts | 16 ++++- packages/validation/src/ValidationRule.ts | 30 ++++++++ packages/validation/src/Validator.ts | 47 ++++++++++-- packages/validation/src/index.ts | 2 +- packages/validation/tests/validator.spec.ts | 71 +++++++++++++++++++ 9 files changed, 176 insertions(+), 20 deletions(-) delete mode 100644 packages/validation/src/BaseRule.ts create mode 100644 packages/validation/src/ImplicitRule.ts create mode 100644 packages/validation/src/ValidationRule.ts diff --git a/packages/validation/README.md b/packages/validation/README.md index d487a257..95163c4d 100644 --- a/packages/validation/README.md +++ b/packages/validation/README.md @@ -42,10 +42,8 @@ npm install @h3ravel/validation - Fail-fast mode — Option to stop at the first failure or collect all errors. - Human-readable summaries — Helper for formatting readable validation reports. -- Rule testing utilities — Built-in helpers for testing custom rules. - TypeScript-first design — Full type inference for rules, messages, and validated data. - Chainable API — Optional fluent syntax for building validators. -- Rule presets — Common validation profiles for forms, authentication, or file uploads. ## Usage diff --git a/packages/validation/src/BaseRule.ts b/packages/validation/src/BaseRule.ts deleted file mode 100644 index 2d98a4f0..00000000 --- a/packages/validation/src/BaseRule.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Rule } from 'simple-body-validator' -import type { RuleCallable } from './Contracts/RuleBuilder' - -export class BaseRule extends Rule { - rules: RuleCallable[] = [] -} \ No newline at end of file diff --git a/packages/validation/src/Contracts/RuleBuilder.ts b/packages/validation/src/Contracts/RuleBuilder.ts index 72304685..cabd2079 100644 --- a/packages/validation/src/Contracts/RuleBuilder.ts +++ b/packages/validation/src/Contracts/RuleBuilder.ts @@ -1,4 +1,4 @@ -import type { BaseRule } from '../BaseRule' +import type { ValidationRule } from '../ValidationRule' export interface RuleCallable { name: string; @@ -6,4 +6,6 @@ export interface RuleCallable { message?: string } -export type CustomRules = BaseRule | RuleCallable \ No newline at end of file +export type CustomRules = ValidationRule | RuleCallable + +export declare class BaseClass { } \ No newline at end of file diff --git a/packages/validation/src/ImplicitRule.ts b/packages/validation/src/ImplicitRule.ts new file mode 100644 index 00000000..c7306f8c --- /dev/null +++ b/packages/validation/src/ImplicitRule.ts @@ -0,0 +1,16 @@ +import { ImplicitRule as Rule } from 'simple-body-validator' +import type { RuleCallable } from './Contracts/RuleBuilder' +import { Validator } from './Validator' + +export abstract class ImplicitRule extends Rule { + rules: RuleCallable[] = [] + + /** + * Run the validation rule. + */ + abstract validate (attribute: string, value: any, fail: (msg: string) => any): void + /** + * Set the current validator. + */ + public setValidator?(validator: Validator): this +} \ No newline at end of file diff --git a/packages/validation/src/Rules/ExtendedRules.ts b/packages/validation/src/Rules/ExtendedRules.ts index a09fc9bb..e45101a2 100644 --- a/packages/validation/src/Rules/ExtendedRules.ts +++ b/packages/validation/src/Rules/ExtendedRules.ts @@ -1,8 +1,19 @@ -import { BaseRule } from '../BaseRule' import { DateTime } from '@h3ravel/support' import type { RuleCallable } from '../Contracts/RuleBuilder' +import { ValidationRule } from '../ValidationRule' +import { Validator } from '../Validator' + +export class ExtendedRules extends ValidationRule { + /** + * The validator instance. + */ + protected validator!: Validator + + public setValidator (validator: Validator): this { + this.validator = validator + return this + } -export class ExtendedRules extends BaseRule { rules: RuleCallable[] = [ { @@ -105,4 +116,5 @@ export class ExtendedRules extends BaseRule { message: 'The :attribute does not exist.' }, ] + validate () { } } \ No newline at end of file diff --git a/packages/validation/src/ValidationRule.ts b/packages/validation/src/ValidationRule.ts new file mode 100644 index 00000000..d04d8b0b --- /dev/null +++ b/packages/validation/src/ValidationRule.ts @@ -0,0 +1,30 @@ +import { Rule } from 'simple-body-validator' +import type { RuleCallable } from './Contracts/RuleBuilder' +import { Validator } from './Validator' + +export abstract class ValidationRule extends Rule { + rules: RuleCallable[] = [] + private passing: boolean = false + + /** + * Run the validation rule. + */ + abstract validate (attribute: string, value: any, fail: (msg: string) => any): void + /** + * Set the current validator. + */ + public setValidator?(validator: Validator): this + /** + * Set the data under validation. + */ + public setData (_data: Record): this { return this } + + passes (value: any, attribute: string): boolean | Promise { + this.passing = true + this.validate(attribute, value, (message: string) => { + this.message = message + this.passing = false + }) + return this.passing + } +} \ No newline at end of file diff --git a/packages/validation/src/Validator.ts b/packages/validation/src/Validator.ts index ea51c46c..eb2c9469 100644 --- a/packages/validation/src/Validator.ts +++ b/packages/validation/src/Validator.ts @@ -1,12 +1,12 @@ +import { BaseClass, CustomRules } from './Contracts/RuleBuilder' import { DotPaths, MessagesForRules, RulesForData } from './Contracts/ValidatorContracts' import { Validator as SimpleBodyValidator, make, register, setTranslationObject } from 'simple-body-validator' -import { BaseRule } from './BaseRule' -import { CustomRules } from './Contracts/RuleBuilder' import { ExtendedRules } from './Rules/ExtendedRules' import { MessageBag } from './utilities/MessageBag' import { RuleSet } from './Contracts/ValidationRuleName' import { ValidationException } from './ValidationException' +import { ValidationRule } from './ValidationRule' register('telephone', function (value) { return /^\d{3}-\d{3}-\d{4}$/.test(value) @@ -17,6 +17,7 @@ export class Validator< R extends RulesForData > { #messages: Partial, string>> + #after: (() => void)[] = [] private data: D private rules: R @@ -39,7 +40,7 @@ export class Validator< this.rules = rules this.#messages = messages this._errors = new MessageBag() - this.registerCustomRules() + this.bindServices() } /** @@ -62,7 +63,14 @@ export class Validator< public async passes (): Promise { if (this.executed) return this._errors.isEmpty() - return (await this.execute()).passing + const exec = (await this.execute()) + + // Let's spin through all the "after" hooks on this validator and ire them off. + for (const after of this.#after) { + after() + } + + return exec.passing } /** @@ -74,6 +82,8 @@ export class Validator< /** * Throw if validation fails, else return executed data + * + * @throws ValidationException if validation fails */ public async validate (): Promise> { const ok = await this.passes() @@ -151,6 +161,24 @@ export class Validator< return this.#messages } + /** + * Add an after validation callback. + * + * @param callback + */ + public after) => void) | BaseClass> (callback: C | C[]) { + + if (Array.isArray(callback)) { + for (const rule of callback as any[]) { + this.#after.push(() => rule.toString().startsWith('class') ? new rule(this) : rule(this)) + } + } else if (typeof callback === 'function') { + this.#after.push(() => callback(this)) + } + + return this + } + /** * Get all errors. @@ -212,11 +240,16 @@ export class Validator< } /** - * Stop validation on first failure. + * Bind all required services here. */ - private registerCustomRules () { + private bindServices () { + /** + * Register all custom rules + */ for (const reged of this.registeredCustomRules) { - if (reged instanceof BaseRule) { + if (reged instanceof ValidationRule) { + if (reged.setData) reged.setData(this.data) + if (reged.setValidator) reged.setValidator(this) for (const rule of reged.rules) { register(rule.name, rule.validator) if (rule.message) { diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 4544ae3b..3b2faed1 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1,4 +1,4 @@ -export * from './BaseRule' +export * from './ValidationRule' export * from './Contracts/RuleBuilder' export * from './Contracts/ValidationRuleName' export * from './Contracts/ValidatorContracts' diff --git a/packages/validation/tests/validator.spec.ts b/packages/validation/tests/validator.spec.ts index b06d8aa9..2ba4091a 100644 --- a/packages/validation/tests/validator.spec.ts +++ b/packages/validation/tests/validator.spec.ts @@ -1,6 +1,8 @@ import { beforeAll, describe, expect, it } from 'vitest' +import RuleContract from 'simple-body-validator/lib/cjs/rules/ruleContract' import { ValidationException } from '../src/ValidationException' +import { ValidationRule } from '../src' import { Validator } from '../src/Validator' describe('Validator', () => { @@ -15,6 +17,21 @@ describe('Validator', () => { await expect(v.validate()).rejects.toThrowError(ValidationException) }) + it('can run after callbacks', async () => { + const v = new Validator( + { email: 'valid@example.com', name: 'John' }, + { email: 'required|email', name: 'required|min:2|max:10' } + ) + + v.after((inst) => { + expect(inst).toBeInstanceOf(Validator) + }) + + const result = await v.passes() + expect(result).toBe(true) + expect(v.errors().isEmpty()).toBe(true) + }) + it('passes validation for valid data', async () => { const v = new Validator( { email: 'valid@example.com', name: 'John' }, @@ -242,4 +259,58 @@ describe('Validator', () => { // expect(result).toBe(true) // }) }) + + describe('custom rules', () => { + it('should fail if the fail callback is called', async () => { + class CustomRule extends ValidationRule { + validate (attribute: string, value: any, fail: (msg: string) => any): void { + if (value === 'H Legacy' && attribute === 'name') fail('custom message') + } + } + + const v = new Validator( + { name: 'H Legacy' }, + { name: ['string', new CustomRule()] } + ) + + const result = await v.fails() + expect(result).toBe(true) + expect(v.errors().get('name')).toEqual(['custom message']) + }) + + it('should fail if the fail callback has not been called', async () => { + class CustomRule extends ValidationRule { + validate (): void { + } + } + + const v = new Validator( + { name: 'H Legacy' }, + { name: ['string', new CustomRule()] } + ) + + const result = await v.passes() + expect(result).toBe(true) + expect(v.errors().get('name')).toEqual([]) + }) + + it('should pass request data via the setData callback', async () => { + const data = { name: 'H Legacy' } + class CustomRule extends ValidationRule { + validate (): void { + expect(this.data).toEqual(data) + + } + setData (data: Record): this { + this.data = data + return this + } + } + + const v = new Validator(data, { name: ['string', new CustomRule()] }) + + const result = await v.passes() + expect(result).toBe(true) + }) + }) }) \ No newline at end of file From cbce679f397be5bd9b4822f1cadf3594683f5c9f Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Tue, 11 Nov 2025 11:39:23 +0100 Subject: [PATCH 05/28] feat: add http error handing and overides --- .barrelize | 11 + examples/basic-app/.h3ravel/tsconfig.json | 46 +- examples/basic-app/package.json | 2 + .../app/Http/Controllers/UserController.ts | 6 + examples/basic-app/src/bootstrap/app.ts | 30 +- examples/basic-app/src/routes/web.ts | 15 + packages/core/package.json | 1 + packages/core/src/Application.ts | 51 +- packages/core/src/Container.ts | 53 +- .../core/src/Exceptions/ConfigException.ts | 6 +- packages/core/src/Exceptions/Handler.ts | 1 - packages/core/src/H3ravel.ts | 46 +- .../src/{Di => Manager}/ContainerResolver.ts | 0 packages/core/src/Manager/Foundation.ts | 27 + packages/core/src/{Di => Manager}/Inject.ts | 0 packages/core/src/ProviderRegistry.ts | 2 +- packages/core/src/index.ts | 6 +- packages/foundation/CHANGELOG.md | 1 + packages/foundation/README.md | 43 ++ packages/foundation/package.json | 71 ++ .../src/Adapters/InMemoryRateLimiter.ts | 31 + .../src/Contracts/RateLimiterAdapter.ts | 31 + .../Exceptions/AccessDeniedHttpException.ts | 12 + .../src/Exceptions/BadRequestHttpException.ts | 12 + .../src/Exceptions/ConflictHttpException.ts | 12 + .../src/Exceptions/ExceptionHandler.ts | 50 ++ .../foundation/src/Exceptions/Exceptions.ts | 148 ++++ .../src/Exceptions/GoneHttpException.ts | 12 + packages/foundation/src/Exceptions/Handler.ts | 713 ++++++++++++++++++ .../src/Exceptions/HttpException.ts | 76 ++ .../src/Exceptions/HttpExceptionFactory.ts | 26 + .../Exceptions/LengthRequiredHttpException.ts | 12 + .../src/Exceptions/LockedHttpException.ts | 12 + .../Exceptions/NotAcceptableHttpException.ts | 12 + .../src/Exceptions/NotFoundHttpException.ts | 12 + .../PreconditionFailedHttpException.ts | 12 + .../PreconditionRequiredHttpException.ts | 12 + .../ServiceUnavailableHttpException.ts | 25 + .../TooManyRequestsHttpException.ts | 25 + .../UnprocessableEntityHttpException.ts | 12 + .../UnsupportedMediaTypeHttpException.ts | 12 + .../foundation/src/Http/RequestException.ts | 67 ++ packages/foundation/src/index.ts | 22 + .../foundation/src/views/errors/error.edge | 113 +++ packages/foundation/tsconfig.json | 15 + packages/foundation/tsdown.config.ts | 9 + packages/http/package.json | 2 +- packages/http/src/HttpContext.ts | 7 +- packages/http/src/Middleware.ts | 2 + packages/http/src/Middleware/LogRequests.ts | 30 +- packages/http/src/Request.ts | 22 +- packages/http/src/Response.ts | 27 + packages/router/package.json | 3 +- .../src/Providers/RouteServiceProvider.ts | 1 + packages/router/src/Route.ts | 46 +- packages/shared/src/Contracts/IRequest.ts | 16 +- packages/shared/src/Utils/FileSystem.ts | 28 + packages/validation/package.json | 6 +- .../Providers/ValidationServiceProvider.ts | 7 +- .../validation/src/Rules/ExtendedRules.ts | 1 - .../validation/src/ValidationException.ts | 16 +- packages/validation/src/index.ts | 3 +- .../view/src/Providers/ViewServiceProvider.ts | 2 +- pnpm-lock.yaml | 91 ++- pnpm-workspace.yaml | 12 +- tsconfig.base.json | 4 +- tsdown.config.ts | 1 + 67 files changed, 2108 insertions(+), 132 deletions(-) delete mode 100644 packages/core/src/Exceptions/Handler.ts rename packages/core/src/{Di => Manager}/ContainerResolver.ts (100%) create mode 100644 packages/core/src/Manager/Foundation.ts rename packages/core/src/{Di => Manager}/Inject.ts (100%) create mode 100644 packages/foundation/CHANGELOG.md create mode 100644 packages/foundation/README.md create mode 100644 packages/foundation/package.json create mode 100644 packages/foundation/src/Adapters/InMemoryRateLimiter.ts create mode 100644 packages/foundation/src/Contracts/RateLimiterAdapter.ts create mode 100644 packages/foundation/src/Exceptions/AccessDeniedHttpException.ts create mode 100644 packages/foundation/src/Exceptions/BadRequestHttpException.ts create mode 100644 packages/foundation/src/Exceptions/ConflictHttpException.ts create mode 100644 packages/foundation/src/Exceptions/ExceptionHandler.ts create mode 100644 packages/foundation/src/Exceptions/Exceptions.ts create mode 100644 packages/foundation/src/Exceptions/GoneHttpException.ts create mode 100644 packages/foundation/src/Exceptions/Handler.ts create mode 100644 packages/foundation/src/Exceptions/HttpException.ts create mode 100644 packages/foundation/src/Exceptions/HttpExceptionFactory.ts create mode 100644 packages/foundation/src/Exceptions/LengthRequiredHttpException.ts create mode 100644 packages/foundation/src/Exceptions/LockedHttpException.ts create mode 100644 packages/foundation/src/Exceptions/NotAcceptableHttpException.ts create mode 100644 packages/foundation/src/Exceptions/NotFoundHttpException.ts create mode 100644 packages/foundation/src/Exceptions/PreconditionFailedHttpException.ts create mode 100644 packages/foundation/src/Exceptions/PreconditionRequiredHttpException.ts create mode 100644 packages/foundation/src/Exceptions/ServiceUnavailableHttpException.ts create mode 100644 packages/foundation/src/Exceptions/TooManyRequestsHttpException.ts create mode 100644 packages/foundation/src/Exceptions/UnprocessableEntityHttpException.ts create mode 100644 packages/foundation/src/Exceptions/UnsupportedMediaTypeHttpException.ts create mode 100644 packages/foundation/src/Http/RequestException.ts create mode 100644 packages/foundation/src/index.ts create mode 100644 packages/foundation/src/views/errors/error.edge create mode 100644 packages/foundation/tsconfig.json create mode 100644 packages/foundation/tsdown.config.ts diff --git a/.barrelize b/.barrelize index 0eda06b1..39f191a6 100644 --- a/.barrelize +++ b/.barrelize @@ -133,6 +133,17 @@ "**/*.d.ts" ] }, + { + "name": "index.ts", + "root": "packages/foundation/src", + "exclude": [ + "**/*.test.ts", + "**/*.d.ts" + ], + "order": [ + "Exceptions/HttpException" + ] + }, { "name": "index.ts", "root": "packages/support/src", diff --git a/examples/basic-app/.h3ravel/tsconfig.json b/examples/basic-app/.h3ravel/tsconfig.json index ac510211..b65c621d 100644 --- a/examples/basic-app/.h3ravel/tsconfig.json +++ b/examples/basic-app/.h3ravel/tsconfig.json @@ -4,27 +4,24 @@ "baseUrl": ".", "outDir": "dist", "paths": { - "src/*": ["./../src/*"], - "App/*": ["./../src/app/*"], - "root/*": ["./../*"], - "routes/*": ["./../src/routes/*"], - "config/*": ["./../src/config/*"], - "resources/*": ["./../src/resources/*"], - "@h3ravel/cache": ["./../../../packages/cache/src/index.ts"], - "@h3ravel/config": ["./../../../packages/config/src/index.ts"], - "@h3ravel/console": ["./../../../packages/console/src/index.ts"], - "@h3ravel/core": ["./../../../packages/core/src/index.ts"], - "@h3ravel/database": ["./../../../packages/database/src/index.ts"], - "@h3ravel/filesystem": ["./../../../packages/filesystem/src/index.ts"], - "@h3ravel/hashing": ["./../../../packages/hashing/src/index.ts"], - "@h3ravel/http": ["./../../../packages/http/src/index.ts"], - "@h3ravel/mail": ["./../../../packages/mail/src/index.ts"], - "@h3ravel/queue": ["./../../../packages/queue/src/index.ts"], - "@h3ravel/router": ["./../../../packages/router/src/index.ts"], - "@h3ravel/shared": ["./../../../packages/shared/src/index.ts"], - "@h3ravel/support": ["./../../../packages/support/src/index.ts"], - "@h3ravel/url": ["./../../../packages/url/src/index.ts"], - "@h3ravel/view": ["./../../../packages/view/src/index.ts"] + "src/*": [ + "./../src/*" + ], + "App/*": [ + "./../src/app/*" + ], + "root/*": [ + "./../*" + ], + "routes/*": [ + "./../src/routes/*" + ], + "config/*": [ + "./../src/config/*" + ], + "resources/*": [ + "./../src/resources/*" + ] }, "target": "es2022", "module": "es2022", @@ -38,7 +35,10 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true }, - "include": ["./**/*.d.ts", "./../**/*"], + "include": [ + "./**/*.d.ts", + "./../**/*" + ], "exclude": [ ".", "./../**/console/bin", @@ -56,4 +56,4 @@ "./../jest.config.ts", "./../arquebus.config.js" ] -} +} \ No newline at end of file diff --git a/examples/basic-app/package.json b/examples/basic-app/package.json index 738a1ff2..b4844a46 100644 --- a/examples/basic-app/package.json +++ b/examples/basic-app/package.json @@ -31,6 +31,8 @@ "@h3ravel/support": "workspace:^", "@h3ravel/url": "workspace:^", "@h3ravel/view": "workspace:^", + "@h3ravel/validation": "workspace:^", + "@h3ravel/foundation": "workspace:^", "cross-env": "catalog:", "h3": "catalog:prod", "reflect-metadata": "catalog:", diff --git a/examples/basic-app/src/app/Http/Controllers/UserController.ts b/examples/basic-app/src/app/Http/Controllers/UserController.ts index 34988f3c..c42cc38e 100644 --- a/examples/basic-app/src/app/Http/Controllers/UserController.ts +++ b/examples/basic-app/src/app/Http/Controllers/UserController.ts @@ -10,6 +10,12 @@ export class UserController extends Controller { @Injectable() async store (request: Request, response: Response) { + const validate = await request.validate({ + name: ['required', 'string'], + }) + + console.log(validate) + return response .setStatusCode(202) .json({ message: `User ${request.input('name')} created` }) diff --git a/examples/basic-app/src/bootstrap/app.ts b/examples/basic-app/src/bootstrap/app.ts index 9e5adc87..e6902b50 100644 --- a/examples/basic-app/src/bootstrap/app.ts +++ b/examples/basic-app/src/bootstrap/app.ts @@ -1,9 +1,37 @@ +import { HttpException, UnprocessableEntityHttpException } from '@h3ravel/foundation' + +import { HttpContext } from '@h3ravel/shared' +import { ValidationException } from '@h3ravel/validation' import { h3ravel } from '@h3ravel/core' import providers from 'src/bootstrap/providers' export default class { async bootstrap () { - const app = await h3ravel(providers, process.cwd(), { autoload: true }, async () => undefined) + const app = await h3ravel(providers, process.cwd(), { autoload: true, initialize: false }, async () => undefined) + + app + .configure() + .withExceptions((exceptions) => { + return exceptions + .render((error, { request, response }: HttpContext) => { + }) + /** + * Register global reporters + */ + .report((error) => { + console.error('🔥 Unhandled Exception:') + }) + /** + * Prevent some exceptions from being reported + */ + .dontReport([ + UnprocessableEntityHttpException, + ]) + /** + * Configure request exception message truncation + */ + .truncateRequestExceptionsAt(200) + }) return await app.fire() } diff --git a/examples/basic-app/src/routes/web.ts b/examples/basic-app/src/routes/web.ts index 2fc56f67..9f3753fe 100644 --- a/examples/basic-app/src/routes/web.ts +++ b/examples/basic-app/src/routes/web.ts @@ -22,4 +22,19 @@ export default (Route: Router) => { } }) }) + + Route.put('/validation', async ({ request, response }) => { + // console.log(request) + const data = await request.validate({ + name: ['required', 'string'], + age: ['required', 'integer'], + }) + + return response + .setStatusCode(202) + .json({ + message: `User ${data.name} created`, + data, + }) + }) } diff --git a/packages/core/package.json b/packages/core/package.json index 7ad509c2..4087a078 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -62,6 +62,7 @@ "dependencies": { "@h3ravel/shared": "workspace:^", "@h3ravel/support": "workspace:^", + "@h3ravel/foundation": "workspace:^", "chalk": "^5.6.2", "commander": "^14.0.1", "detect-port": "catalog:", diff --git a/packages/core/src/Application.ts b/packages/core/src/Application.ts index e77767e4..a5adc3e2 100644 --- a/packages/core/src/Application.ts +++ b/packages/core/src/Application.ts @@ -1,13 +1,12 @@ import 'reflect-metadata' -import { FileSystem, type HttpContext, type IApplication, type IPathName, Logger } from '@h3ravel/shared' +import { FileSystem, type HttpContext, type IApplication, type IPathName, Logger, PathLoader } from '@h3ravel/shared' import type { H3, H3Event } from 'h3' import { InvalidArgumentException, Str } from '@h3ravel/support' import { AServiceProvider } from './Contracts/ServiceProviderConstructor' import { Container } from './Container' -import { ContainerResolver } from './Di/ContainerResolver' -import { PathLoader } from '@h3ravel/shared' +import { ContainerResolver } from './Manager/ContainerResolver' import { ProviderRegistry } from './ProviderRegistry' import { Registerer } from './Registerer' import { ServiceProvider } from './ServiceProvider' @@ -17,16 +16,20 @@ import dotenvExpand from 'dotenv-expand' import path from 'node:path' import { readFile } from 'node:fs/promises' import semver from 'semver' +import { Foundation } from './Manager/Foundation' +import { ConfigException } from './Exceptions/ConfigException' export class Application extends Container implements IApplication { public paths = new PathLoader() public context?: (event: H3Event) => Promise + public h3Event?: H3Event private tries: number = 0 private booted = false private basePath: string private versions: { [key: string]: string, app: string, ts: string } = { app: '0.0.0', ts: '0.0.0' } private static versions: { [key: string]: string, app: string, ts: string } = { app: '0.0.0', ts: '0.0.0' } + private h3App?: H3 private providers: Array = [] protected externalProviders: Array = [] protected filteredProviders: Array = [] @@ -240,6 +243,13 @@ export class Application extends Container implements IApplication { return this } + /** + * Provide safe overides for the app + */ + public configure () { + return new Foundation(this) + } + /** * Fire up the developement server using the user provided arguments * @@ -247,10 +257,34 @@ export class Application extends Container implements IApplication { * * @param h3App The current H3 app instance * @param preferedPort If provided, this will overide the port set in the evironment + * @alias serve */ public async fire (): Promise public async fire (h3App: H3, preferredPort?: number): Promise public async fire (h3App?: H3, preferredPort?: number): Promise { + + if (h3App) { + return await this.serve(h3App, preferredPort) + } + + if (!this.h3App) { + throw new ConfigException('[Provide a H3 app instance in the config or install @h3ravel/http]') + } + + + return await this.serve(this.h3App, preferredPort) + } + + + /** + * Fire up the developement server using the user provided arguments + * + * Port will be auto assigned if provided one is not available + * + * @param h3App The current H3 app instance + * @param preferedPort If provided, this will overide the port set in the evironment + */ + public async serve (h3App?: H3, preferredPort?: number): Promise { if (!h3App) { throw new InvalidArgumentException('No valid H3 app instance was provided.') @@ -296,6 +330,17 @@ export class Application extends Container implements IApplication { return this } + /** + * Save the curretn H3 instance for possible future use. + * + * @param h3App The current H3 app instance + * @returns + */ + setH3App (h3App?: H3) { + this.h3App = h3App + return this + } + /** * Get the base path of the app * diff --git a/packages/core/src/Container.ts b/packages/core/src/Container.ts index aade1959..8783e043 100644 --- a/packages/core/src/Container.ts +++ b/packages/core/src/Container.ts @@ -1,10 +1,13 @@ import type { Bindings, IContainer, UseKey } from '@h3ravel/shared' +import { Handler } from '@h3ravel/foundation' type IBinding = UseKey | (new (..._args: any[]) => unknown) export class Container implements IContainer { public bindings = new Map unknown>() public singletons = new Map() + public exceptionHandler?: Handler + private afterResolvingCallbacks = new Map void)[]>() /** * Check if the target has any decorators @@ -80,20 +83,50 @@ export class Container implements IContainer { /** * Direct factory binding */ + let resolved: any + if (this.bindings.has(key)) { - return this.bindings.get(key)!() + resolved = this.bindings.get(key)!() + } else if (typeof key === 'function') { + /** + * If this is a class constructor, auto-resolve via reflection + */ + resolved = this.build(key) + } else { + throw new Error( + `No binding found for key: ${typeof key === 'string' ? key : (key as any)?.name}` + ) } - /** - * If this is a class constructor, auto-resolve via reflection - */ - if (typeof key === 'function') { - return this.build(key) - } + this.runAfterResolvingCallbacks(key, resolved) + return resolved + } - throw new Error( - `No binding found for key: ${typeof key === 'string' ? key : (key as any)?.name}` - ) + /** + * Register a callback to be executed after a service is resolved + */ + afterResolving ( + key: T | (new (..._args: any[]) => Bindings[T]), + callback: (resolved: Bindings[T], app: this) => void + ) { + const existing = this.afterResolvingCallbacks.get(key) || [] + + existing.push(callback) + this.afterResolvingCallbacks.set(key, existing) + } + + /** + * Execute all registered afterResolving callbacks for a given key + */ + private runAfterResolvingCallbacks ( + key: T, + resolved: Bindings[T] + ) { + const callbacks = this.afterResolvingCallbacks.get(key) || [] + + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](resolved, this) + } } /** diff --git a/packages/core/src/Exceptions/ConfigException.ts b/packages/core/src/Exceptions/ConfigException.ts index c788aaaa..7b60f27c 100644 --- a/packages/core/src/Exceptions/ConfigException.ts +++ b/packages/core/src/Exceptions/ConfigException.ts @@ -5,9 +5,9 @@ export class ConfigException extends Error { constructor(key: string, type: 'any' | 'config' | 'env' = 'config', cause?: unknown) { const info = { - any: `${key} not configured`, - env: `${key} environment variable not configured`, - config: `${key} config not set`, + any: `${key} not configured.`, + env: `${key} environment variable not configured.`, + config: `${key} config not set.`, } const message = Logger.log([['ERROR:', 'bgRed'], [info[type], 'white']], ' ', false) diff --git a/packages/core/src/Exceptions/Handler.ts b/packages/core/src/Exceptions/Handler.ts deleted file mode 100644 index 0b1f950e..00000000 --- a/packages/core/src/Exceptions/Handler.ts +++ /dev/null @@ -1 +0,0 @@ -export default class { } diff --git a/packages/core/src/H3ravel.ts b/packages/core/src/H3ravel.ts index 0b1c98a7..44d12049 100644 --- a/packages/core/src/H3ravel.ts +++ b/packages/core/src/H3ravel.ts @@ -1,4 +1,4 @@ -import { Application, ConfigException, Kernel, OServiceProvider } from '.' +import { Application, Kernel, OServiceProvider } from '.' import { HttpContext, LogRequests, Request, Response } from '@h3ravel/http' import { EntryConfig } from './Contracts/H3ravelContract' @@ -61,7 +61,7 @@ export const h3ravel = async ( app, request: await Request.create(event, app), response: new Response(event, app), - }); + }, event); (event as any)._h3ravelContext = ctx return ctx @@ -78,46 +78,10 @@ export const h3ravel = async ( } } - const originalFire = app.fire - - const proxyThis = (function makeProxy (appRef, orig) { - return new Proxy(appRef, { - get (target, prop, receiver) { - if (prop === 'fire') return orig - // preserve correct behavior for symbols / inspect / prototype lookups - return Reflect.get(target, prop, receiver) - }, - has (target, prop) { - if (prop === 'fire') return true - return Reflect.has(target, prop) - }, - getOwnPropertyDescriptor (target, prop) { - if (prop === 'fire') { - return { - configurable: true, - enumerable: false, - writable: true, - value: orig, - } - } - return Reflect.getOwnPropertyDescriptor(target, prop as PropertyKey) - } - }) - })(app, originalFire) - + // Fire up the dev server if (config.initialize && h3App) { - // Fire up the server - return await Reflect.apply(originalFire, app, [h3App]) - } - - app.fire = function () { - if (!h3App) { - throw new ConfigException('Provide a H3 app instance in the config or install @h3ravel/http') - } - - // call original with proxyThis as `this` so internal `this.fire()` resolves to originalFire - return Reflect.apply(originalFire, proxyThis, [h3App]) + return await app.fire(h3App) } - return app + return app.setH3App(h3App) } \ No newline at end of file diff --git a/packages/core/src/Di/ContainerResolver.ts b/packages/core/src/Manager/ContainerResolver.ts similarity index 100% rename from packages/core/src/Di/ContainerResolver.ts rename to packages/core/src/Manager/ContainerResolver.ts diff --git a/packages/core/src/Manager/Foundation.ts b/packages/core/src/Manager/Foundation.ts new file mode 100644 index 00000000..e4ad0f86 --- /dev/null +++ b/packages/core/src/Manager/Foundation.ts @@ -0,0 +1,27 @@ +import { ExceptionHandler, Exceptions } from '@h3ravel/foundation' + +import { Application } from '..' + +export class Foundation { + constructor(private app: Application) { } + + /** + * Register and wire up the application's exception handling layer. + * + * @param using + **/ + public withExceptions (using: (exceptions: Exceptions) => void) { + // Register the ExceptionHandler as a singleton + this.app.singleton(ExceptionHandler, () => new ExceptionHandler()) + + // Default to a no-op callback if none provided + using ??= () => true + + // Hook into the lifecycle to initialize Exceptions once the handler is resolved + this.app.afterResolving(ExceptionHandler, (handler) => { + using(new Exceptions(handler)) + }) + + return this + } +} \ No newline at end of file diff --git a/packages/core/src/Di/Inject.ts b/packages/core/src/Manager/Inject.ts similarity index 100% rename from packages/core/src/Di/Inject.ts rename to packages/core/src/Manager/Inject.ts diff --git a/packages/core/src/ProviderRegistry.ts b/packages/core/src/ProviderRegistry.ts index addb9ae9..2ed2c547 100644 --- a/packages/core/src/ProviderRegistry.ts +++ b/packages/core/src/ProviderRegistry.ts @@ -1,5 +1,5 @@ import type { Application } from './Application' -import { ContainerResolver } from '../src/Di/ContainerResolver' +import { ContainerResolver } from '../src/Manager/ContainerResolver' import { ServiceProvider } from './ServiceProvider' import fg from 'fast-glob' import path from 'node:path' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b697782a..677efcef 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,12 +3,12 @@ export * from './Container' export * from './Contracts/H3ravelContract' export * from './Contracts/ServiceProviderConstructor' export * from './Controller' -export * from './Di/ContainerResolver' -export * from './Di/Inject' export * from './Exceptions/ConfigException' -export * from './Exceptions/Handler' export { h3ravel } from './H3ravel' export * from './Http/Kernel' +export * from './Manager/ContainerResolver' +export * from './Manager/Foundation' +export * from './Manager/Inject' export * from './ProviderRegistry' export * from './Providers/CoreServiceProvider' export * from './Registerer' diff --git a/packages/foundation/CHANGELOG.md b/packages/foundation/CHANGELOG.md new file mode 100644 index 00000000..1ff10903 --- /dev/null +++ b/packages/foundation/CHANGELOG.md @@ -0,0 +1 @@ +# @h3ravel/foundation diff --git a/packages/foundation/README.md b/packages/foundation/README.md new file mode 100644 index 00000000..d13806e8 --- /dev/null +++ b/packages/foundation/README.md @@ -0,0 +1,43 @@ +
+ + H3ravel Logo + +

H3ravel Foundation

+ +[![Framework][ix]][lx] +[![Foundation Package Version][i1]][l1] +[![Downloads][d1]][d1] +[![Tests][tei]][tel] +[![License][lini]][linl] + +
+ +# About H3ravel/foundation + +We just needed somewhere to dump stuff that we couldn't dump on [@h3ravel/shared](/packages/shared). + +## Contributing + +Thank you for considering contributing to the H3ravel framework! The [Contribution Guide](https://h3ravel.toneflix.net/contributing) can be found in the H3ravel documentation and will provide you with all the information you need to get started. + +## Code of Conduct + +In order to ensure that the H3ravel community is welcoming to all, please review and abide by the [Code of Conduct](#). + +## Security Vulnerabilities + +If you discover a security vulnerability within H3ravel, please send an e-mail to Legacy via hamzas.legacy@toneflix.ng. All security vulnerabilities will be promptly addressed. + +## License + +The H3ravel framework is open-sourced software licensed under the [MIT license](LICENSE). + +[ix]: https://img.shields.io/npm/v/%40h3ravel%2Fcore?style=flat-square&label=Framework&color=%230970ce +[lx]: https://www.npmjs.com/package/@h3ravel/core +[i1]: https://img.shields.io/npm/v/%40h3ravel%2Ffoundation?style=flat-square&label=@h3ravel/foundation&color=%230970ce +[l1]: https://www.npmjs.com/package/@h3ravel/foundation +[d1]: https://img.shields.io/npm/dt/%40h3ravel%2Ffoundation?style=flat-square&label=Downloads&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40h3ravel%2Ffoundation +[linl]: https://github.com/h3ravel/framework/blob/main/LICENSE +[lini]: https://img.shields.io/github/license/h3ravel/framework +[tel]: https://github.com/h3ravel/framework/actions/workflows/test.yml +[tei]: https://github.com/h3ravel/framework/actions/workflows/test.yml/badge.svg diff --git a/packages/foundation/package.json b/packages/foundation/package.json new file mode 100644 index 00000000..a88d4003 --- /dev/null +++ b/packages/foundation/package.json @@ -0,0 +1,71 @@ +{ + "name": "@h3ravel/foundation", + "version": "0.1.0", + "description": "H3ravel Foundation for shared and reuseable services.", + "type": "module", + "main": "./dist/index.cjs", + "types": "./dist/index.d.ts", + "module": "./dist/index.js", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "*": [ + "dist/index.d.ts" + ] + } + }, + "files": [ + "dist", + "tsconfig.json" + ], + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./*": "./*" + } + }, + "homepage": "https://h3ravel.toneflix.net", + "repository": { + "type": "git", + "url": "git+https://github.com/h3ravel/framework.git", + "directory": "packages/foundation" + }, + "keywords": [ + "h3ravel", + "modern", + "web", + "H3", + "framework", + "nodejs", + "typescript", + "laravel", + "Foundation", + "Illuminate" + ], + "scripts": { + "build": "tsdown --config-loader unconfig", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "lint": "eslint . --ext .ts", + "test": "jest --passWithNoTests", + "release:patch": "pnpm build && pnpm version patch && git add . && git commit -m \"version: bump foundation package and publish\" && pnpm publish --tag latest", + "version-patch": "pnpm version patch" + }, + "devDependencies": { + "h3": "catalog:prod" + }, + "dependencies": { + "@h3ravel/shared": "workspace:^", + "@h3ravel/support": "workspace:^" + } +} \ No newline at end of file diff --git a/packages/foundation/src/Adapters/InMemoryRateLimiter.ts b/packages/foundation/src/Adapters/InMemoryRateLimiter.ts new file mode 100644 index 00000000..c477cbed --- /dev/null +++ b/packages/foundation/src/Adapters/InMemoryRateLimiter.ts @@ -0,0 +1,31 @@ +import { RateLimiterAdapter } from '../Contracts/RateLimiterAdapter' + +/** + * Very small in-memory token-bucket / counter limiter. + * + * Suitable for single-process dev / tests. We will replace with a Redis-backed adapter + * implementing `RateLimiterAdapter` in future. + */ +export class InMemoryRateLimiter implements RateLimiterAdapter { + /* A map of key -> { count, expiresAt } */ + protected store = new Map() + + public async attempt (key: string, maxAttempts: number, allowCallback: () => boolean | Promise, decaySeconds: number): Promise { + const now = Date.now() + const record = this.store.get(key) + + if (!record || record.expiresAt <= now) { + this.store.set(key, { count: 1, expiresAt: now + decaySeconds * 1000 }) + return await allowCallback() + } + + if (record.count < maxAttempts) { + record.count++ + this.store.set(key, record) + return await allowCallback() + } + + // limit reached + return false + } +} \ No newline at end of file diff --git a/packages/foundation/src/Contracts/RateLimiterAdapter.ts b/packages/foundation/src/Contracts/RateLimiterAdapter.ts new file mode 100644 index 00000000..29b24e9a --- /dev/null +++ b/packages/foundation/src/Contracts/RateLimiterAdapter.ts @@ -0,0 +1,31 @@ +/** + * RateLimiterAdapter types + */ + +export type LimitSpec = { + key?: string + maxAttempts: number + decaySeconds: number +} + +export type Unlimited = { + unlimited: true +} + +/** + * Rate Limiter Adapter Interface + */ +export interface RateLimiterAdapter { + /** + * Attempt a key with a maxAttempts and decaySeconds. + * + * Return true if this is allowed (i.e., *not* throttled), + * false if the limit is reached. + */ + attempt ( + key: string, + maxAttempts: number, + allowCallback: () => boolean | Promise, + decaySeconds: number + ): Promise +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/AccessDeniedHttpException.ts b/packages/foundation/src/Exceptions/AccessDeniedHttpException.ts new file mode 100644 index 00000000..b976693f --- /dev/null +++ b/packages/foundation/src/Exceptions/AccessDeniedHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class AccessDeniedHttpException extends HttpExceptionFactory { + constructor( + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(403, message, previous, headers, code) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/BadRequestHttpException.ts b/packages/foundation/src/Exceptions/BadRequestHttpException.ts new file mode 100644 index 00000000..bba82152 --- /dev/null +++ b/packages/foundation/src/Exceptions/BadRequestHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class BadRequestHttpException extends HttpExceptionFactory { + constructor( + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(400, message, previous, headers, code) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/ConflictHttpException.ts b/packages/foundation/src/Exceptions/ConflictHttpException.ts new file mode 100644 index 00000000..6729a395 --- /dev/null +++ b/packages/foundation/src/Exceptions/ConflictHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class ConflictHttpException extends HttpExceptionFactory { + constructor( + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(409, message, previous, headers, code) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/ExceptionHandler.ts b/packages/foundation/src/Exceptions/ExceptionHandler.ts new file mode 100644 index 00000000..036cc364 --- /dev/null +++ b/packages/foundation/src/Exceptions/ExceptionHandler.ts @@ -0,0 +1,50 @@ +import { Handler } from './Handler' +import { HttpContext } from '@h3ravel/shared' +import { HttpException } from './HttpException' +import { RequestException } from '../Http/RequestException' + +export class ExceptionHandler extends Handler { + public async handle (error: Error, ctx: HttpContext) { + const e = this.mapException(error) + + try { + /** + * Skip reporting if in dontReport list + */ + if (!this.dontReportList.some((t) => error instanceof t)) { + for (const cb of this.reportCallbacks) { + await cb(error) + } + } + + /** + * Try custom render callbacks + */ + for (const cb of this.renderCallbacks) { + const response = await cb(error, ctx) + if (response) return response + } + + /** + * Default response fallback + */ + if (error instanceof RequestException) { + const status = (error.status ?? 500) + error = HttpException.fromStatusCode(status, error.message || 'Server Error', error) + } + + return this.render(ctx, error) + } catch (handlingError) { + /** + * Fallback for catastrophic errors during handling + */ + return ctx.response + .setStatusCode(500) + .setContent(ctx.request.expectsJson() ? { + message: 'Fatal error while handling exception', + error: (handlingError as any).stack, + } : 'Fatal error while handling exception') + .sendContent(ctx.request.expectsJson() ? 'json' : 'html') + } + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/Exceptions.ts b/packages/foundation/src/Exceptions/Exceptions.ts new file mode 100644 index 00000000..bfc734b5 --- /dev/null +++ b/packages/foundation/src/Exceptions/Exceptions.ts @@ -0,0 +1,148 @@ +import { Arr } from '@h3ravel/support' +import { Handler } from './Handler' +import { RequestException } from '../Http/RequestException' + +export class Exceptions { + /** + * Create a new exception handling configuration instance. + */ + constructor(public handler: Handler) { } + + /** + * Register a reportable callback. + */ + public report (using: (...args: any[]) => any) { + return this.handler.reportable(using) + } + + /** + * Register a reportable callback. + */ + public reportable (reportUsing: (...args: any[]) => any) { + return this.handler.reportable(reportUsing) + } + + /** + * Register a renderable callback. + */ + public render (using: (...args: any[]) => any) { + this.handler.renderable(using) + return this + } + + /** + * Register a renderable callback. + */ + public renderable (renderUsing: (...args: any[]) => any) { + this.handler.renderable(renderUsing) + return this + } + + /** + * Register a callback to prepare the final rendered exception response. + */ + public respond (using: (...args: any[]) => any) { + this.handler.respondUsing(using) + return this + } + + /** + * Specify the callback that should be used to throttle reportable exceptions. + */ + public throttle (throttleUsing: (...args: any[]) => any) { + this.handler.throttleUsing(throttleUsing) + return this + } + + /** + * Register a new exception mapping. + */ + public map (from: typeof RequestException | ((e: any) => any), to?: typeof RequestException | ((e: any) => any)) { + this.handler.map(from as never, to as never) + return this + } + + /** + * Set the log level for the given exception type. + */ + public level (type: string, level: string) { + this.handler.level(type, level) + return this + } + + /** + * Register a closure that should be used to build exception context data. + */ + public context (contextCallback: (...args: any[]) => Record) { + this.handler.buildContextUsing(contextCallback) + return this + } + + /** + * Indicate that the given exception type should not be reported. + */ + public dontReport (classOrArray: typeof RequestException | typeof RequestException[]) { + for (const exceptionClass of Arr.wrap(classOrArray)) { + this.handler.dontReport(exceptionClass) + } + return this + } + + /** + * Register a callback to determine if an exception should not be reported. + */ + public dontReportWhen (dontReportWhen: (error: Error) => boolean) { + this.handler.dontReportWhen(dontReportWhen) + return this + } + + /** + * Do not report duplicate exceptions. + */ + public dontReportDuplicates () { + this.handler.dontReportDuplicates() + return this + } + + /** + * Indicate that the given attributes should never be flashed to the session on validation errors. + */ + public dontFlash (attributes: string | string[]) { + this.handler.dontFlash(attributes) + return this + } + + /** + * Register the callable that determines if the exception handler response should be JSON. + */ + public shouldRenderJsonWhen ( + callback: (request: any, error: Error) => boolean + ) { + this.handler.shouldRenderJsonWhen(callback) + return this + } + + /** + * Indicate that the given exception class should not be ignored. + */ + public stopIgnoring (classOrArray: typeof RequestException | typeof RequestException[]) { + this.handler.stopIgnoring(classOrArray) + return this + } + + /** + * Set the truncation length for request exception messages. + */ + public truncateRequestExceptionsAt (length: number) { + RequestException.truncateAt(length) + return this + } + + /** + * Disable truncation of request exception messages. + */ + public dontTruncateRequestExceptions () { + RequestException.dontTruncate() + return this + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/GoneHttpException.ts b/packages/foundation/src/Exceptions/GoneHttpException.ts new file mode 100644 index 00000000..0400c2b4 --- /dev/null +++ b/packages/foundation/src/Exceptions/GoneHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class GoneHttpException extends HttpExceptionFactory { + constructor( + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(410, message, previous, headers, code) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/Handler.ts b/packages/foundation/src/Exceptions/Handler.ts new file mode 100644 index 00000000..b33954e5 --- /dev/null +++ b/packages/foundation/src/Exceptions/Handler.ts @@ -0,0 +1,713 @@ +/// + +import { FileSystem, HttpContext, IRequest, IResponse } from '@h3ravel/shared' +import { LimitSpec, RateLimiterAdapter, Unlimited } from '../Contracts/RateLimiterAdapter' + +import { InMemoryRateLimiter } from '../Adapters/InMemoryRateLimiter' +import { readFileSync } from 'node:fs' + +type Constructor = new (...args: any[]) => T +type ReportCallback = (error: any) => boolean | void | Promise +type RenderCallback = (error: any, ctx: HttpContext) => IResponse | Promise | undefined | null +type ConditionCallback = (error: any) => boolean +type ThrottleCallback = (error: any) => LimitSpec | Unlimited | null | undefined + +/** + * + * Notes: + * - This file purposely keeps the API surface familiar to Laravel-ish handlers, + * but trimmed to essentials for H3ravel. + * - We will use `RateLimiterAdapter` to plug in Redis / cache-backed limiters later. + */ +export abstract class Handler { + /** + * List of exception constructors that should not be reported. + */ + protected dontReportList: Constructor[] = [] + + /** + * Log Level + */ + protected logLevel: { type?: string, level?: string } = {} + + /** + * Internal exceptions that are not reported by default. Subclasses may expand. + */ + protected internalDontReport: Constructor[] = [] + + /** + * Callbacks that inspect exceptions to determine if they should NOT be reported. + */ + protected dontReportCallbacks: ConditionCallback[] = [] + + /** + * Reportable callbacks (can cancel reporting by returning false). + */ + protected reportCallbacks: ReportCallback[] = [] + + /** + * Render callbacks (can return a Response for a specific exception type). + */ + protected renderCallbacks: RenderCallback[] = [] + + /** + * Exception mapping: from constructor -> mapper function (returns instance or new error). + */ + protected exceptionMap = new Map any>() + + /** + * Throttle callbacks: return limit spec or Unlimited or null + */ + protected throttleCallbacks: ThrottleCallback[] = [] + + /** + * Context callbacks for building log context + */ + protected contextCallbacks: Array<(e: any, current?: Record) => Record> = [] + + /** + * Determines whether to hash throttle keys (default true) + */ + protected hashThrottleKeys = true + + /** + * Whether to avoid reporting duplicates + */ + protected withoutDuplicates = false + + /** + * Map of already reported exceptions (WeakMap to allow GC) + */ + protected reportedExceptionMap = new WeakMap() + + /** + * Rate limiter adapter — can be replaced by container / DI. + */ + protected rateLimiter: RateLimiterAdapter = new InMemoryRateLimiter() + + /** + * no-op; subclasses can extend constructor and call super() + */ + constructor() { + } + + /** + * The exception handler method + * + * @param error + * @param ctx + */ + public handle?(error: Error, ctx: HttpContext): Promise + + /** + * Finalize response callback (respondUsing) + * + * @param response + * @param error + * @param request + */ + protected finalizeResponseCallback?: (response: IResponse, error: any, request: IRequest) => IResponse | Promise + + /** + * Callback to determine if JSON should be returned + * + * @param request + * @param error + */ + protected shouldRenderJsonWhenCallback?: (request: IRequest, error: any) => boolean + + /** + * Register a reportable callback handler + * + * @param cb + * @returns + */ + public reportable (cb: ReportCallback) { + this.reportCallbacks.push(cb) + return this + } + + public renderable (cb: RenderCallback) { + this.renderCallbacks.push(cb) + return this + } + + public dontReport (exceptions: Constructor | Constructor[]) { + const arr = Array.isArray(exceptions) ? exceptions : [exceptions] + this.dontReportList = Array.from(new Set([...this.dontReportList, ...arr])) + return this + } + + public stopIgnoring (exceptions: Constructor | Constructor[]) { + const arr = Array.isArray(exceptions) ? exceptions : [exceptions] + this.dontReportList = this.dontReportList.filter((c) => !arr.includes(c)) + this.internalDontReport = this.internalDontReport.filter((c) => !arr.includes(c)) + return this + } + + public dontReportWhen (cb: ConditionCallback) { + this.dontReportCallbacks.push(cb) + return this + } + + public dontReportDuplicates () { + this.withoutDuplicates = true + return this + } + + public map (from: Constructor, mapper: (error: any) => any) { + this.exceptionMap.set(from, mapper) + return this + } + + public throttleUsing (cb: ThrottleCallback) { + this.throttleCallbacks.push(cb) + return this + } + + public buildContextUsing (cb: (e: any, current?: Record) => Record) { + this.contextCallbacks.push(cb) + return this + } + + public setRateLimiter (adapter: RateLimiterAdapter) { + this.rateLimiter = adapter + return this + } + + public respondUsing (cb: (response: IResponse, error: any, request: IRequest) => IResponse | Promise) { + this.finalizeResponseCallback = cb + return this + } + + public shouldRenderJsonWhen (cb: (request: IRequest, error: any) => boolean) { + this.shouldRenderJsonWhenCallback = cb + return this + } + + /** + * Entry point to reporting an exception. + * + * @param error + * @returns + */ + public async report (error: any): Promise { + const e = this.mapException(error) + + if (this.shouldntReport(e)) { + return + } + + await this.reportThrowable(e) + } + + /** + * Internal reporting pipeline. + * + * @param e + * @returns + */ + protected async reportThrowable (e: any): Promise { + if (this.withoutDuplicates && this.reportedExceptionMap.get(e) === true) { + return + } + + this.reportedExceptionMap.set(e, true) + + /* If the exception itself defines a `report` method, let it run (if callable). */ + try { + if (typeof (e?.report) === 'function') { + const result = await Promise.resolve(e.report()) + if (result === false) { + return + } + } + } catch { + /* If reporting from exception fails, continue to handler callbacks. */ + } + + /* Run registered report callbacks — any callback returning false stops reporting. */ + for (const cb of this.reportCallbacks) { + try { + const result = await Promise.resolve(cb(e)) + if (result === false) { + return + } + } catch { + // swallow callback errors but continue + } + } + + /* Throttle check: if throttled, skip logging */ + const throttled = await this.isThrottled(e) + if (throttled) return + + /* Actual logging — subclasses should override newLogger or this method to plug real loggers. */ + try { + const logger = this.newLogger() + const level = this.mapLogLevel(e) + + const context = this.buildExceptionContext(e) + + if (typeof (logger as any)[level] === 'function') { + ; (logger as any)[level](e?.message ?? String(e), context) + } else if (typeof logger.log === 'function') { + logger.log(level, e?.message ?? String(e), context) + } else { + /* Fallback */ + + console.error(`[${level}]`, e, context) + } + } catch { + /* If logger fails, rethrow original exception to avoid silent failure in critical systems. */ + throw e + } + } + + /** + * Decide whether an exception should not be reported. + * + * @param e + * @returns + */ + protected shouldntReport (e: any): boolean { + if (this.withoutDuplicates && this.reportedExceptionMap.get(e) === true) { + return true + } + + if (this.isInstanceOfAny(e, this.internalDontReport)) { + return true + } + + if (this.isInstanceOfAny(e, this.dontReportList)) { + return true + } + + for (const cb of this.dontReportCallbacks) { + try { + if (cb(e) === true) return true + } catch { + // swallow user callback errors + } + } + + return false + } + + /** + * Throttle evaluation. Returns true when reporting should be skipped. + * + * @param e + * @returns + */ + protected async isThrottled (e: any): Promise { + for (const cb of this.throttleCallbacks) { + try { + const spec = await Promise.resolve(cb(e)) + if (!spec) continue + + if ('unlimited' in (spec as any)) { + return false + } + + const s = spec as LimitSpec + const key = s.key ?? `h3ravel:exceptions:${e.constructor?.name ?? 'unknown'}` + const hashedKey = this.hashThrottleKeys ? this.hashKey(key) : key + + /* rateLimiter.attempt returns true if allowed */ + try { + const allowed = await this.rateLimiter.attempt(hashedKey, s.maxAttempts, () => true, s.decaySeconds) + return !allowed + } catch { + // if limiter crashes, don't throttle (fail-open) + return false + } + } catch { + // ignore callback errors + } + } + + return false + } + + /** + * Apply mappings and unwrap inner exceptions if present. + * + * @param error + * @returns + */ + protected mapException (error: any): any { + /* unwrap common inner pattern */ + if (error && typeof error.getInnerException === 'function') { + try { + const inner = error.getInnerException() + if (inner) return this.mapException(inner) + } catch { + // ignore inner extraction errors + } + } + + /* run registered mappers */ + for (const [from, mapper] of this.exceptionMap.entries()) { + if (error instanceof from) { + try { + return mapper(error) + } catch { + // mapper failed, continue + } + } + } + + return error + } + + /** + * Render an exception into an HTTP Response. + * + * @param ctx + * @param error + * @returns + */ + public async render (ctx: HttpContext, error: any): Promise { + const e = this.mapException(error) + + const { Response } = await import('@h3ravel/http') + + /** + * If the exception instance has its own render(request) method prefer it. + */ + if (e && typeof e.render === 'function') { + try { + const resp = await Promise.resolve(e.render(ctx, e)) + if (resp instanceof Response) return this.finalizeRenderedResponse(ctx.request, resp, e) + } catch { + // ignore and continue to handler-level renderers + } + } + + /** + * If error implements Responsable-like `toResponse(request)` + */ + if (e && typeof e.toResponse === 'function') { + try { + const resp = await Promise.resolve(e.toResponse(ctx.request)) + if (resp instanceof Response) return this.finalizeRenderedResponse(ctx.request, resp, e) + else if (Object.entries(resp).length) return this.getResponse(ctx, resp, e) + } catch { + // ignore and continue + } + } + + /** + * Try render callbacks + */ + for (const cb of this.renderCallbacks) { + try { + const resp = await Promise.resolve(cb(e, ctx)) + if (resp instanceof Response) { + return this.finalizeRenderedResponse(ctx.request, resp, e) + } + } catch { + // swallow render callback errors + } + } + + /** + * Return JSON response when shouldRenderJson / expectsJson, else generic HTML/text + */ + if (this.shouldReturnJson(ctx.request, e)) { + return this.finalizeRenderedResponse(ctx.request, this.prepareJsonResponse(ctx.request, e), e) + } + + return this.finalizeRenderedResponse(ctx.request, await this.prepareResponse(ctx.request, e), e) + } + + /** + * getResponse + */ + public getResponse ({ request }: HttpContext, payload: Record, e: any): IResponse | Promise { + if (this.shouldReturnJson(request, e)) { + return response() + .setStatusCode(this.isHttpException(e) ? (e.status as number) : 500) + .json(payload) + } + + const view = FileSystem.resolveModulePath('@h3ravel/foundation', [ + 'dist/views/errors/error.edge', + 'views/errors/error.edge' + ]) ?? '' + + const body = payload.message ?? (this.isHttpException(e) ? (e.message ?? 'Error') : 'Internal Server Error') + + return response() + .setStatusCode(this.isHttpException(e) ? (e.status as number) : 500) + .viewTemplate(readFileSync(view, { encoding: 'utf-8' }), { + statusCode: this.isHttpException(e) ? (e.status as number) : 500, + message: body, + exception: e, + debug: this.appDebug() + }) + } + + /** + * Default non-JSON response (simple string). Subclass to integrate templating. + * + * @param _request + * @param e + * @returns + */ + protected prepareResponse (_request: IRequest, e: any): IResponse | Promise { + const body = this.isHttpException(e) ? (e.message ?? 'Error') : 'Internal Server Error' + + const view = FileSystem.resolveModulePath('@h3ravel/foundation', [ + 'dist/views/errors/error.edge', + 'views/errors/error.edge' + ]) ?? '' + + return response() + .setStatusCode(this.isHttpException(e) ? (e.status as number) : 500) + .viewTemplate(readFileSync(view, { encoding: 'utf-8' }), { + statusCode: this.isHttpException(e) ? (e.status as number) : 500, + message: body, + exception: e, + debug: this.appDebug() + }) + } + + /** + * Finalizes a rendered response using the finalize callback if present. + * + * @param request + * @param response + * @param e + * @returns + */ + protected async finalizeRenderedResponse (request: IRequest, response: IResponse, e: any): Promise { + if (this.finalizeResponseCallback) { + try { + const out = await Promise.resolve(this.finalizeResponseCallback(response, e, request)) + return out ?? response + } catch { + return response + } + } + + return response + } + + /** + * Decide whether to return JSON. + * + * @param request + * @param e + * @returns + */ + protected shouldReturnJson (request: IRequest, e: any): boolean { + if (this.shouldRenderJsonWhenCallback) { + try { + return this.shouldRenderJsonWhenCallback(request, e) + } catch { + // fallback + } + } + + /** + * assume Request exposes expectsJson() + **/ + try { + return typeof request.expectsJson === 'function' ? request.expectsJson() : false + } catch { + return false + } + } + + /** + * Prepare a Json Response for the exception. + * + * Subclasses can override convertExceptionToArray for different debug behavior. + * + * @param _request + * @param e + * @returns + */ + protected prepareJsonResponse (_request: IRequest, e: any): IResponse { + const payload = this.convertExceptionToArray(e) + return response() + .setStatusCode(this.isHttpException(e) ? (e.status as number) : 500) + .json(payload) + } + + /** + * Convert exception into debug-friendly array/object. + * + * @param e + * @returns + */ + protected convertExceptionToArray (e: any): Record { + const debug = this.appDebug() + if (!debug) { + return { + message: this.isHttpException(e) ? e.message : 'Internal Server Error', + } + } + + const trace = Array.isArray(e?.stack?.split?.('\n')) ? e.stack.split('\n') : [] + + return { + message: e?.message ?? String(e), + exception: e?.constructor?.name ?? typeof e, + trace, + } + } + + /** + * Build final exception context for logging. + * + * @param e + * @returns + */ + protected buildExceptionContext (e: any): Record { + const defaultContext = this.exceptionContext(e) + const extra = this.context() + return { ...defaultContext, ...extra, exception: e } + } + + /** + * Allow exceptions to supply their own context via `context()` method. + * + * @param e + * @returns + */ + protected exceptionContext (e: any): Record { + let ctx: Record = {} + + if (e && typeof e.context === 'function') { + try { + ctx = e.context() ?? {} + } catch { + ctx = {} + } + } + + for (const cb of this.contextCallbacks) { + try { + ctx = { ...ctx, ...cb(e, ctx) } + } catch { + // ignore callback errors + } + } + + return ctx + } + + /** + * Default contextual info for logs (e.g., user id). + * + * Subclasses may override. Try/catch to avoid breaking logging flow. + */ + protected context (): Record { + try { + /* Example: if you have an Auth module, fetch user id here */ + return {} + } catch { + return {} + } + } + + /** + * Check if a method is an instance of any of the listed classes + * + * @param e + * @param list + * @returns + */ + protected isInstanceOfAny (e: any, list: Constructor[]) { + if (!e) return false + for (const c of list) { + try { + if (e instanceof c) return true + } catch { + // ignore invalid constructors + } + } + return false + } + + /** + * Check if an exxeption is an HTTP execption + * + * @param e + * @returns + */ + protected isHttpException (e: any): e is { status: number; headers?: Record, message?: string } { + return e && typeof e.status === 'number' + } + + /** + * Default mapping — subclasses can override for custom logic + * + * @param _e + */ + protected mapLogLevel (_e: any): string { + return 'error' + } + + /** + * Subclasses should return PSR-like logger (object with methods like error, warn, info or a `log` fn) + */ + protected newLogger (): any { + return console + } + + /** + * Hook to read from config/environment. Subclass or container should supply real value. + */ + protected appDebug (): boolean { + return typeof process !== 'undefined' && + process.env && + process.env.NODE_ENV !== 'production' && + process.env.APP_ENV !== 'production' + } + + /** + * Lightweight hash to avoid leaking raw keys in shared stores. + * In the future, will be replaced with a real hash (xxh128 / sha256) if needed. + * + * @param key + */ + protected hashKey (key: string) { + let h = 2166136261 >>> 0 + for (let i = 0; i < key.length; i++) { + h ^= key.charCodeAt(i) + h = Math.imul(h, 16777619) >>> 0 + } + return `h3:${h.toString(16)}` + } + + /** + * Not implemented in core. Subclass can implement and call RequestException helpers. + * + * @param _length + */ + public truncateRequestExceptionsAt (_length: number) { + return this + } + + /** + * Set the log level + * + * @param _attributes + */ + public level (type: string, level: string) { + return this.logLevel = { level, type } + } + + /** + * Not implemented here; applicable to validation pipeline/UI. + * + * @param _attributes + */ + public dontFlash (_attributes: string | string[]) { + return this + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/HttpException.ts b/packages/foundation/src/Exceptions/HttpException.ts new file mode 100644 index 00000000..c76464d8 --- /dev/null +++ b/packages/foundation/src/Exceptions/HttpException.ts @@ -0,0 +1,76 @@ +import { AccessDeniedHttpException } from './AccessDeniedHttpException' +import { BadRequestHttpException } from './BadRequestHttpException' +import { ConflictHttpException } from './ConflictHttpException' +import { GoneHttpException } from './GoneHttpException' +import { LengthRequiredHttpException } from './LengthRequiredHttpException' +import { LockedHttpException } from './LockedHttpException' +import { NotAcceptableHttpException } from './NotAcceptableHttpException' +import { NotFoundHttpException } from './NotFoundHttpException' +import { PreconditionFailedHttpException } from './PreconditionFailedHttpException' +import { PreconditionRequiredHttpException } from './PreconditionRequiredHttpException' +import { ServiceUnavailableHttpException } from './ServiceUnavailableHttpException' +import { TooManyRequestsHttpException } from './TooManyRequestsHttpException' +import { UnprocessableEntityHttpException } from './UnprocessableEntityHttpException' +import { UnsupportedMediaTypeHttpException } from './UnsupportedMediaTypeHttpException' + +/** + * HttpException. + */ +export class HttpException extends Error { + constructor( + protected statusCode: number, + public message: string = '', + protected previous?: Error, + protected headers: Record = {}, + public code: number = 0, + ) { + super(message) + } + + public static fromStatusCode (statusCode: number, message: string = '', previous?: Error, headers: Record = {}, code: number = 0) { + switch (statusCode) { + case 400: + return new BadRequestHttpException(message, previous, code, headers) + case 403: + return new AccessDeniedHttpException(message, previous, code, headers) + case 404: + return new NotFoundHttpException(message, previous, code, headers) + case 406: + return new NotAcceptableHttpException(message, previous, code, headers) + case 409: + return new ConflictHttpException(message, previous, code, headers) + case 410: + return new GoneHttpException(message, previous, code, headers) + case 411: + return new LengthRequiredHttpException(message, previous, code, headers) + case 412: + return new PreconditionFailedHttpException(message, previous, code, headers) + case 423: + return new LockedHttpException(message, previous, code, headers) + case 415: + return new UnsupportedMediaTypeHttpException(message, previous, code, headers) + case 422: + return new UnprocessableEntityHttpException(message, previous, code, headers) + case 428: + return new PreconditionRequiredHttpException(message, previous, code, headers) + case 429: + return new TooManyRequestsHttpException(undefined, message, previous, code, headers) + case 503: + return new ServiceUnavailableHttpException(undefined, message, previous, code, headers) + default: + return new HttpException(statusCode, message, previous, headers, code) + } + } + + public getStatusCode (): number { + return this.statusCode + } + + public getHeaders (): Record { + return this.headers + } + + public setHeaders (headers: Record): void { + this.headers = headers + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/HttpExceptionFactory.ts b/packages/foundation/src/Exceptions/HttpExceptionFactory.ts new file mode 100644 index 00000000..91593b93 --- /dev/null +++ b/packages/foundation/src/Exceptions/HttpExceptionFactory.ts @@ -0,0 +1,26 @@ +/** + * Base HttpException + */ +export class HttpExceptionFactory extends Error { + constructor( + protected statusCode: number, + public message: string = '', + protected previous?: Error, + protected headers: Record = {}, + public code: number = 0, + ) { + super(message) + } + + public getStatusCode (): number { + return this.statusCode + } + + public getHeaders (): Record { + return this.headers + } + + public setHeaders (headers: Record): void { + this.headers = headers + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/LengthRequiredHttpException.ts b/packages/foundation/src/Exceptions/LengthRequiredHttpException.ts new file mode 100644 index 00000000..823a197f --- /dev/null +++ b/packages/foundation/src/Exceptions/LengthRequiredHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class LengthRequiredHttpException extends HttpExceptionFactory { + constructor( + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(411, message, previous, headers, code) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/LockedHttpException.ts b/packages/foundation/src/Exceptions/LockedHttpException.ts new file mode 100644 index 00000000..5b868f74 --- /dev/null +++ b/packages/foundation/src/Exceptions/LockedHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class LockedHttpException extends HttpExceptionFactory { + constructor( + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(423, message, previous, headers, code) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/NotAcceptableHttpException.ts b/packages/foundation/src/Exceptions/NotAcceptableHttpException.ts new file mode 100644 index 00000000..6f823c91 --- /dev/null +++ b/packages/foundation/src/Exceptions/NotAcceptableHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class NotAcceptableHttpException extends HttpExceptionFactory { + constructor( + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(406, message, previous, headers, code) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/NotFoundHttpException.ts b/packages/foundation/src/Exceptions/NotFoundHttpException.ts new file mode 100644 index 00000000..a70edd23 --- /dev/null +++ b/packages/foundation/src/Exceptions/NotFoundHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class NotFoundHttpException extends HttpExceptionFactory { + constructor( + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(404, message, previous, headers, code) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/PreconditionFailedHttpException.ts b/packages/foundation/src/Exceptions/PreconditionFailedHttpException.ts new file mode 100644 index 00000000..91d466c1 --- /dev/null +++ b/packages/foundation/src/Exceptions/PreconditionFailedHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class PreconditionFailedHttpException extends HttpExceptionFactory { + constructor( + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(412, message, previous, headers, code) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/PreconditionRequiredHttpException.ts b/packages/foundation/src/Exceptions/PreconditionRequiredHttpException.ts new file mode 100644 index 00000000..bd531815 --- /dev/null +++ b/packages/foundation/src/Exceptions/PreconditionRequiredHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class PreconditionRequiredHttpException extends HttpExceptionFactory { + constructor( + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(428, message, previous, headers, code) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/ServiceUnavailableHttpException.ts b/packages/foundation/src/Exceptions/ServiceUnavailableHttpException.ts new file mode 100644 index 00000000..388620bf --- /dev/null +++ b/packages/foundation/src/Exceptions/ServiceUnavailableHttpException.ts @@ -0,0 +1,25 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class ServiceUnavailableHttpException extends HttpExceptionFactory { + /** + * + * @param retryAfter The number of seconds or HTTP-date after which the request may be retried + * @param message + * @param previous + * @param code + * @param headers + */ + constructor( + retryAfter?: number | string, + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(429, message, previous, headers, code) + + if (retryAfter) { + this.headers['Retry-After'] = String(retryAfter) + } + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/TooManyRequestsHttpException.ts b/packages/foundation/src/Exceptions/TooManyRequestsHttpException.ts new file mode 100644 index 00000000..c86dcdb1 --- /dev/null +++ b/packages/foundation/src/Exceptions/TooManyRequestsHttpException.ts @@ -0,0 +1,25 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class TooManyRequestsHttpException extends HttpExceptionFactory { + /** + * + * @param retryAfter The number of seconds or HTTP-date after which the request may be retried + * @param message + * @param previous + * @param code + * @param headers + */ + constructor( + retryAfter?: number | string, + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(429, message, previous, headers, code) + + if (retryAfter) { + this.headers['Retry-After'] = String(retryAfter) + } + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/UnprocessableEntityHttpException.ts b/packages/foundation/src/Exceptions/UnprocessableEntityHttpException.ts new file mode 100644 index 00000000..bb32dfe4 --- /dev/null +++ b/packages/foundation/src/Exceptions/UnprocessableEntityHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class UnprocessableEntityHttpException extends HttpExceptionFactory { + constructor( + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(422, message, previous, headers, code) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/UnsupportedMediaTypeHttpException.ts b/packages/foundation/src/Exceptions/UnsupportedMediaTypeHttpException.ts new file mode 100644 index 00000000..67eb9faa --- /dev/null +++ b/packages/foundation/src/Exceptions/UnsupportedMediaTypeHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './HttpExceptionFactory' + +export class UnsupportedMediaTypeHttpException extends HttpExceptionFactory { + constructor( + message: string = '', + previous?: Error, + code: number = 0, + headers: Record = {}, + ) { + super(415, message, previous, headers, code) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Http/RequestException.ts b/packages/foundation/src/Http/RequestException.ts new file mode 100644 index 00000000..12cba56d --- /dev/null +++ b/packages/foundation/src/Http/RequestException.ts @@ -0,0 +1,67 @@ +import { IResponse } from '@h3ravel/shared' + +export class RequestException { + /** + * The HTTP status code for this error. + */ + public status!: number + + /** + * The truncation length for the exception message. + */ + static #truncateAt: number | false = 120 + + /** + * The response instance. + */ + public response: IResponse + + /** + * Create a new exception instance. + */ + public constructor(response: IResponse) { + // super(this.prepareMessage(response), response.getStatusCode()) + + this.response = response + } + + /** + * Enable truncation of request exception messages. + * + * @return void + */ + public static truncate () { + RequestException.#truncateAt = 120 + } + + /** + * Set the truncation length for request exception messages. + * + * @param int $length + */ + public static truncateAt (length: number) { + RequestException.#truncateAt = typeof length === 'boolean' ? 0 : Number(RequestException.#truncateAt) + } + + /** + * Disable truncation of request exception messages. + * + * @return void + */ + public static dontTruncate () { + RequestException.#truncateAt = false + } + + /** + * Prepare the exception message. + */ + protected prepareMessage (response: IResponse) { + const message = `HTTP request returned status code ${response.getStatusCode()}` + + // const summary = RequestException.truncateAt + // ? Message.bodySummary(response.toPsrResponse(), RequestException.truncateAt) + // : Message.toString(response.toPsrResponse()) + return message + // return !summary ? message : message += ':\n{$summary}\n' + } +} \ No newline at end of file diff --git a/packages/foundation/src/index.ts b/packages/foundation/src/index.ts new file mode 100644 index 00000000..526e9842 --- /dev/null +++ b/packages/foundation/src/index.ts @@ -0,0 +1,22 @@ +export * from './Exceptions/HttpException' +export * from './Exceptions/HttpExceptionFactory' +export * from './Adapters/InMemoryRateLimiter' +export * from './Contracts/RateLimiterAdapter' +export * from './Exceptions/AccessDeniedHttpException' +export * from './Exceptions/BadRequestHttpException' +export * from './Exceptions/ConflictHttpException' +export * from './Exceptions/ExceptionHandler' +export * from './Exceptions/Exceptions' +export * from './Exceptions/GoneHttpException' +export * from './Exceptions/Handler' +export * from './Exceptions/LengthRequiredHttpException' +export * from './Exceptions/LockedHttpException' +export * from './Exceptions/NotAcceptableHttpException' +export * from './Exceptions/NotFoundHttpException' +export * from './Exceptions/PreconditionFailedHttpException' +export * from './Exceptions/PreconditionRequiredHttpException' +export * from './Exceptions/ServiceUnavailableHttpException' +export * from './Exceptions/TooManyRequestsHttpException' +export * from './Exceptions/UnprocessableEntityHttpException' +export * from './Exceptions/UnsupportedMediaTypeHttpException' +export * from './Http/RequestException' diff --git a/packages/foundation/src/views/errors/error.edge b/packages/foundation/src/views/errors/error.edge new file mode 100644 index 00000000..f67bf701 --- /dev/null +++ b/packages/foundation/src/views/errors/error.edge @@ -0,0 +1,113 @@ + + + + + + + {{ statusCode || 500 }} | Something went wrong + + + + +
+

{{ statusCode }}

+

{{ statusText || 'Something went wrong' }}

+ + @if(!debug) +

{{ message || 'An unexpected error occurred. Please try again later.' }}

+ @endif + +

+ ← Go back home +

+ + @if(debug) +
+

Error Details

+
{{ exception?.message }}
+ + @if(exception?.stack) +

Stack Trace

+
{{ exception.stack }}
+ @endif +
+ @endif +
+ + + \ No newline at end of file diff --git a/packages/foundation/tsconfig.json b/packages/foundation/tsconfig.json new file mode 100644 index 00000000..1dcb8687 --- /dev/null +++ b/packages/foundation/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "exclude": ["./dist", "./**/dist", "./node_modules"] +} diff --git a/packages/foundation/tsdown.config.ts b/packages/foundation/tsdown.config.ts new file mode 100644 index 00000000..6e778c5e --- /dev/null +++ b/packages/foundation/tsdown.config.ts @@ -0,0 +1,9 @@ +import { baseConfig } from '../../tsdown.config' +import { defineConfig } from 'tsdown' + +export default defineConfig([ + { + ...baseConfig, + copy: 'src/views', + }, +]) diff --git a/packages/http/package.json b/packages/http/package.json index ff1f3331..4ec36733 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -48,7 +48,6 @@ "laravel" ], "scripts": { - "barrel": "barrelsby --directory src --delete --singleQuotes", "build": "tsdown --config-loader unconfig", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", @@ -60,6 +59,7 @@ "@h3ravel/support": "workspace:^", "@h3ravel/musket": "catalog:prod", "@h3ravel/shared": "workspace:^", + "@h3ravel/validation": "workspace:^", "@h3ravel/url": "workspace:^", "h3": "catalog:prod", "srvx": "^0.8.2" diff --git a/packages/http/src/HttpContext.ts b/packages/http/src/HttpContext.ts index 704728f2..9c5429c4 100644 --- a/packages/http/src/HttpContext.ts +++ b/packages/http/src/HttpContext.ts @@ -1,4 +1,5 @@ import { IApplication, type HttpContext as IHttpContext, IRequest, IResponse } from '@h3ravel/shared' +import type { H3Event } from 'h3' /** * Represents the HTTP context for a single request lifecycle. @@ -6,6 +7,7 @@ import { IApplication, type HttpContext as IHttpContext, IRequest, IResponse } f */ export class HttpContext implements IHttpContext { private static contexts = new WeakMap() + public event?: H3Event constructor( public app: IApplication, @@ -18,12 +20,13 @@ export class HttpContext implements IHttpContext { * @param ctx - Object containing app, request, and response * @returns A new HttpContext instance */ - static init (ctx: { app: IApplication; request: IRequest; response: IResponse }, event?: unknown): HttpContext { - if (event && HttpContext.contexts.has(event)) { + static init (ctx: { app: IApplication; request: IRequest; response: IResponse }, event?: H3Event): HttpContext { + if (!!event && HttpContext.contexts.has(event)) { return HttpContext.contexts.get(event)! } const instance = new HttpContext(ctx.app, ctx.request, ctx.response) + instance.event = event if (event) { HttpContext.contexts.set(event, instance) diff --git a/packages/http/src/Middleware.ts b/packages/http/src/Middleware.ts index 158c0154..6a522dd1 100644 --- a/packages/http/src/Middleware.ts +++ b/packages/http/src/Middleware.ts @@ -1,6 +1,8 @@ +import { H3Event } from 'h3' import { HttpContext } from './HttpContext' import { IMiddleware } from '@h3ravel/shared' export abstract class Middleware implements IMiddleware { + constructor(protected event?: H3Event) { } abstract handle (context: HttpContext, next: () => Promise): Promise } diff --git a/packages/http/src/Middleware/LogRequests.ts b/packages/http/src/Middleware/LogRequests.ts index e235fc4c..ed1f7d0c 100644 --- a/packages/http/src/Middleware/LogRequests.ts +++ b/packages/http/src/Middleware/LogRequests.ts @@ -1,10 +1,34 @@ import { HttpContext } from '../HttpContext' import { Logger } from '@h3ravel/shared' import { Middleware } from '../Middleware' +import { toResponse } from 'h3' export class LogRequests extends Middleware { - async handle ({ request }: HttpContext, next: () => Promise): Promise { - Logger.log([[` ${request.method()} `, 'bgBlue'], [request.fullUrl(), 'white']], ' ') - return next() + async handle ({ request, event }: HttpContext, next: () => Promise): Promise { + + await toResponse(await next(), event!) + + // const code = Number(response.status) + const method = request.method().toLowerCase() + // let color = 'bgRed' + + // if (code < 200) color = 'bgWhite' + // else if (code >= 200 && code <= 300) color = 'bgBlue' + // else if (code >= 300 && code <= 400) color = 'bgOrange' + + let mColor = 'bgYellow' + if (method == 'get') mColor = 'bgBlue' + else if (method == 'head') mColor = 'bgGray' + else if (method == 'delete') mColor = 'bgRed' + + Logger.log([ + [` ${method.toUpperCase()} `, mColor as never], + [request.fullUrl(), 'white'], + // ['→', 'blue'], + // [` ${code} `, color as never] + ], ' ') + + // return next() + return } } diff --git a/packages/http/src/Request.ts b/packages/http/src/Request.ts index 3d9df8cc..6d36b008 100644 --- a/packages/http/src/Request.ts +++ b/packages/http/src/Request.ts @@ -9,8 +9,12 @@ import { UploadedFile } from './UploadedFile' import { FormRequest } from './FormRequest' import { Url } from '@h3ravel/url' import { HttpRequest } from './Utilities/HttpRequest' +import { MessagesForRules, RulesForData, Validator } from '@h3ravel/validation' -export class Request extends HttpRequest implements IRequest { +export class Request< + D extends Record = Record, + R extends RulesForData = RulesForData +> extends HttpRequest implements IRequest { /** * The decoded JSON content for the request. */ @@ -100,6 +104,22 @@ export class Request extends HttpRequest implements IRequest { } } + /** + * Validate the incoming request data + * + * @param data + * @param rules + * @param messages + */ + async validate ( + rules: R, + messages: Partial, string>> = {} + ): Promise { + const validator = new Validator(this.all(), rules, messages) + + return await validator.validate() as D + } + /** * Retrieve all data from the instance (query + body). */ diff --git a/packages/http/src/Response.ts b/packages/http/src/Response.ts index 950d47f4..3d5bace4 100644 --- a/packages/http/src/Response.ts +++ b/packages/http/src/Response.ts @@ -40,6 +40,33 @@ export class Response extends HttpResponse implements IResponse { return this.sendContent(type, true) } + /** + * Use an edge view as content + * + * @param viewPath The path to the view file + * @param send if set to true, the content will be returned, instead of the Response instance + * @returns + */ + async view (viewPath: string, data?: Record | undefined): Promise + async view (viewPath: string, data: Record | undefined, parse: boolean): Promise + async view (viewPath: string, data?: Record | undefined, parse?: boolean): Promise { + return this.html(await this.app.make('edge').render(viewPath, data), parse!) as never + } + + /** + * + * Parse content as edge view + * + * @param content The content to serve + * @param send if set to true, the content will be returned, instead of the Response instance + * @returns + */ + async viewTemplate (content: string, data?: Record | undefined): Promise + async viewTemplate (content: string, data: Record | undefined, parse: boolean): Promise + async viewTemplate (content: string, data?: Record | undefined, parse?: boolean): Promise { + return this.html(await this.app.make('edge').renderRaw(content, data), parse!) as never + } + /** * * @param content The content to serve diff --git a/packages/router/package.json b/packages/router/package.json index 08f9d3bd..12e8cb5f 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -64,7 +64,8 @@ "@h3ravel/http": "workspace:^", "@h3ravel/shared": "workspace:^", "@h3ravel/support": "workspace:^", + "@h3ravel/foundation": "workspace:^", "h3": "catalog:prod", "reflect-metadata": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/router/src/Providers/RouteServiceProvider.ts b/packages/router/src/Providers/RouteServiceProvider.ts index f7c9eee0..7e8d51dc 100644 --- a/packages/router/src/Providers/RouteServiceProvider.ts +++ b/packages/router/src/Providers/RouteServiceProvider.ts @@ -21,6 +21,7 @@ export class RouteServiceProvider extends ServiceProvider { this.app.singleton('router', () => { try { const h3App = this.app.make('http.app') + return new Router(h3App, this.app) } catch (error: any) { if (String(error.message).includes('http.app')) diff --git a/packages/router/src/Route.ts b/packages/router/src/Route.ts index 6d9a538a..08112219 100644 --- a/packages/router/src/Route.ts +++ b/packages/router/src/Route.ts @@ -4,10 +4,11 @@ import { Application, Container, Kernel } from '@h3ravel/core' import { Request, Response, HttpContext } from '@h3ravel/http' import { Str } from '@h3ravel/support' import { Resolver, RouteEventHandler } from '@h3ravel/shared' -import type { EventHandler, ExtractControllerMethods, IController, IMiddleware, IRouter, RouterEnd } from '@h3ravel/shared' +import type { EventHandler, ExtractControllerMethods, IController, IMiddleware, IResponse, IRouter, RouterEnd } from '@h3ravel/shared' import { Helpers } from './Helpers' import { Model } from '@h3ravel/database' import { RouteDefinition, RouteMethod } from '@h3ravel/shared' +import { ExceptionHandler } from '@h3ravel/foundation' export class Router implements IRouter { private routes: RouteDefinition[] = [] @@ -37,7 +38,7 @@ export class Router implements IRouter { app: this.app, request: await Request.create(event, this.app), response: new Response(event, this.app), - }); + }, event); (event as any)._h3ravelContext = ctx return ctx @@ -190,11 +191,48 @@ export class Router implements IRouter { /** * Call the controller method, passing all resolved dependencies */ - return await controller[action](...args) + return await this.handleResponse(async () => await controller[action]?.(...args), ctx) } } - return handler as EventHandler + /** + * Call the route callback handler + */ + return async (ctx) => { + return await this.handleResponse(handler as EventHandler, ctx) + } + } + + /** + * Gracefully handle the outgoing response and pass all caught errors + * to the exception handler. + * + * @param handler + * @param ctx + * @returns + */ + private async handleResponse (handler: (ctx: HttpContext) => Promise, ctx: HttpContext): Promise { + this.app.exceptionHandler ??= this.app.make(ExceptionHandler) + + if (!this.app.exceptionHandler) { + return await handler(ctx) + } + + try { + return await handler(ctx) + } catch (error) { + /** + * Handle the exception here. + */ + if (this.app.exceptionHandler.handle) { + return await this.app.exceptionHandler.handle?.(error as Error, ctx) + } + + /** + * If no exception handler has been defined, throw the original exception. + */ + throw error + } } /** diff --git a/packages/shared/src/Contracts/IRequest.ts b/packages/shared/src/Contracts/IRequest.ts index 2ea4d53e..c692bd92 100644 --- a/packages/shared/src/Contracts/IRequest.ts +++ b/packages/shared/src/Contracts/IRequest.ts @@ -11,7 +11,10 @@ type RequestObject = Record; /** * Interface for the Request contract, defining methods for handling HTTP request data. */ -export declare class IRequest { +export declare class IRequest< + D extends Record = Record, + R extends Record = Record +> { /** * The current app instance */ @@ -277,6 +280,17 @@ export declare class IRequest { * @internal use explicit input sources instead */ get (key: string, defaultValue?: any): any; + /** + * Validate the incoming request data + * + * @param data + * @param rules + * @param messages + */ + validate ( + rules: R, + messages?: Partial> + ): Promise; /** * Enables support for the _method request parameter to determine the intended HTTP method. * diff --git a/packages/shared/src/Utils/FileSystem.ts b/packages/shared/src/Utils/FileSystem.ts index 56dcc151..42219d4c 100644 --- a/packages/shared/src/Utils/FileSystem.ts +++ b/packages/shared/src/Utils/FileSystem.ts @@ -1,5 +1,6 @@ import { access } from 'fs/promises' import escalade from 'escalade/sync' +import { existsSync } from 'fs' import path from 'path' export class FileSystem { @@ -70,4 +71,31 @@ export class FileSystem { return false }) ?? undefined } + + /** + * Recursively find files starting from given cwd + * + * @param name + * @param extensions + * @param cwd + * + * @returns + */ + static resolveModulePath ( + moduleId: string, + pathName: string | string[], + cwd?: string + ) { + pathName = Array.isArray(pathName) ? pathName : [pathName] + const module = this.findModulePkg(moduleId, cwd) ?? '' + + for (const name of pathName) { + const file = path.join(module, name) + if (existsSync(file)) { + return file + } + } + + return + } } diff --git a/packages/validation/package.json b/packages/validation/package.json index 5f4abd6f..d374c742 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/validation", - "version": "1.0.15", + "version": "0.1.0", "description": "Lightweight validation library providing expressive rule-based validation for requests, data objects, and custom logic for H3ravel applications.", "h3ravel": { "providers": [ @@ -53,7 +53,6 @@ "builder" ], "scripts": { - "barrel": "barrelsby --directory src --delete --singleQuotes", "build": "tsdown --config-loader unconfig", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", @@ -69,7 +68,8 @@ "peerDependencies": { "@h3ravel/core": "workspace:^", "@h3ravel/config": "workspace:^", - "@h3ravel/database": "workspace:^" + "@h3ravel/database": "workspace:^", + "@h3ravel/foundation": "workspace:^" }, "devDependencies": { "typescript": "^5.4.0" diff --git a/packages/validation/src/Providers/ValidationServiceProvider.ts b/packages/validation/src/Providers/ValidationServiceProvider.ts index 8c8994d1..80066324 100644 --- a/packages/validation/src/Providers/ValidationServiceProvider.ts +++ b/packages/validation/src/Providers/ValidationServiceProvider.ts @@ -1,11 +1,12 @@ -import { ServiceProvider } from '@h3ravel/core' - /** * Service provider for Validation utilities */ -export class ValidationServiceProvider extends ServiceProvider { +export class ValidationServiceProvider { + public registeredCommands?: (new (app: any, kernel: any) => any)[] public static priority = 895 + constructor(private app: any) { } + /** * Register URL services in the container */ diff --git a/packages/validation/src/Rules/ExtendedRules.ts b/packages/validation/src/Rules/ExtendedRules.ts index e45101a2..ab18dc33 100644 --- a/packages/validation/src/Rules/ExtendedRules.ts +++ b/packages/validation/src/Rules/ExtendedRules.ts @@ -61,7 +61,6 @@ export class ExtendedRules extends ValidationRule { { name: 'datetime', validator: (value: any, parameters: string[] = [], attr) => { - console.log(this.data, attr) if (typeof value !== 'string') return false const [format] = parameters diff --git a/packages/validation/src/ValidationException.ts b/packages/validation/src/ValidationException.ts index 0c017648..db6df5ad 100644 --- a/packages/validation/src/ValidationException.ts +++ b/packages/validation/src/ValidationException.ts @@ -1,15 +1,16 @@ import { MessageBag } from './utilities/MessageBag' import { Str } from '@h3ravel/support' +import { UnprocessableEntityHttpException } from '@h3ravel/foundation' import { Validator } from './Validator' -export class ValidationException extends Error { - public validator: Validator +export class ValidationException extends UnprocessableEntityHttpException { + public validator: Validator public response?: any public status: number = 422 public errorBag: string = 'default' public redirectTo?: string - constructor(validator: Validator, response: any = null, errorBag = 'default') { + constructor(validator: Validator, response: any = null, errorBag = 'default') { super(ValidationException.summarize(validator)) this.name = 'ValidationException' @@ -20,6 +21,13 @@ export class ValidationException extends Error { Object.setPrototypeOf(this, ValidationException.prototype) } + public toResponse () { + return { + message: this.message, + errors: this.errors(), + } + } + /** * Create a new validation exception from a plain array of messages. */ @@ -44,7 +52,7 @@ export class ValidationException extends Error { /** * Create a readable summary message from the validation errors. */ - protected static summarize (validator: Validator): string { + protected static summarize (validator: Validator): string { const messages = validator.errors().all() if (!messages.length || typeof messages[0] !== 'string') { diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 3b2faed1..21e006a6 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1,9 +1,10 @@ -export * from './ValidationRule' export * from './Contracts/RuleBuilder' export * from './Contracts/ValidationRuleName' export * from './Contracts/ValidatorContracts' +export * from './ImplicitRule' export * from './Providers/ValidationServiceProvider' export * from './Rules/ExtendedRules' export * from './utilities/MessageBag' export * from './ValidationException' +export * from './ValidationRule' export * from './Validator' diff --git a/packages/view/src/Providers/ViewServiceProvider.ts b/packages/view/src/Providers/ViewServiceProvider.ts index a2df7e96..51881d5a 100644 --- a/packages/view/src/Providers/ViewServiceProvider.ts +++ b/packages/view/src/Providers/ViewServiceProvider.ts @@ -38,7 +38,7 @@ export class ViewServiceProvider extends ServiceProvider { const view = async (template: string, data?: Record) => { const response = this.app.make('http.response') - return response.html(await this.app.make('edge').render(template, data)) + return response.html(await this.app.make('edge').render(template, data), true) } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 510e5e51..7c709335 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,8 +140,8 @@ catalogs: specifier: ^0.6.17 version: 0.6.17 '@h3ravel/musket': - specifier: ^0.3.11 - version: 0.3.11 + specifier: ^0.3.12 + version: 0.3.12 h3: specifier: 2.0.1-rc.5 version: 2.0.1-rc.5 @@ -282,6 +282,9 @@ importers: '@h3ravel/filesystem': specifier: workspace:^ version: link:../../packages/filesystem + '@h3ravel/foundation': + specifier: workspace:^ + version: link:../../packages/foundation '@h3ravel/hashing': specifier: workspace:^ version: link:../../packages/hashing @@ -293,7 +296,7 @@ importers: version: link:../../packages/mail '@h3ravel/musket': specifier: catalog:prod - version: 0.3.11(@h3ravel/support@packages+support)(@types/node@24.9.2) + version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.9.2) '@h3ravel/queue': specifier: workspace:^ version: link:../../packages/queue @@ -309,6 +312,9 @@ importers: '@h3ravel/url': specifier: workspace:^ version: link:../../packages/url + '@h3ravel/validation': + specifier: workspace:^ + version: link:../../packages/validation '@h3ravel/view': specifier: workspace:^ version: link:../../packages/view @@ -361,7 +367,7 @@ importers: dependencies: '@h3ravel/musket': specifier: catalog:prod - version: 0.3.11(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -386,7 +392,7 @@ importers: version: link:../core '@h3ravel/musket': specifier: catalog:prod - version: 0.3.11(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -427,6 +433,9 @@ importers: packages/core: dependencies: + '@h3ravel/foundation': + specifier: workspace:^ + version: link:../foundation '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -487,7 +496,7 @@ importers: version: link:../filesystem '@h3ravel/musket': specifier: catalog:prod - version: 0.3.11(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -509,7 +518,7 @@ importers: version: link:../core '@h3ravel/musket': specifier: catalog:prod - version: 0.3.11(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -521,6 +530,19 @@ importers: specifier: ^5.4.0 version: 5.9.2 + packages/foundation: + dependencies: + '@h3ravel/shared': + specifier: workspace:^ + version: link:../shared + '@h3ravel/support': + specifier: workspace:^ + version: link:../support + devDependencies: + h3: + specifier: catalog:prod + version: 2.0.1-rc.5 + packages/hashing: dependencies: '@h3ravel/core': @@ -547,7 +569,7 @@ importers: version: link:../core '@h3ravel/musket': specifier: catalog:prod - version: 0.3.11(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -557,6 +579,9 @@ importers: '@h3ravel/url': specifier: workspace:^ version: link:../url + '@h3ravel/validation': + specifier: workspace:^ + version: link:../validation h3: specifier: catalog:prod version: 2.0.1-rc.5 @@ -605,12 +630,15 @@ importers: '@h3ravel/database': specifier: workspace:^ version: link:../database + '@h3ravel/foundation': + specifier: workspace:^ + version: link:../foundation '@h3ravel/http': specifier: workspace:^ version: link:../http '@h3ravel/musket': specifier: catalog:prod - version: 0.3.11(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -693,6 +721,34 @@ importers: specifier: ^5.4.0 version: 5.9.2 + packages/validation: + dependencies: + '@h3ravel/config': + specifier: workspace:^ + version: link:../config + '@h3ravel/core': + specifier: workspace:^ + version: link:../core + '@h3ravel/database': + specifier: workspace:^ + version: link:../database + '@h3ravel/foundation': + specifier: workspace:^ + version: link:../foundation + '@h3ravel/shared': + specifier: workspace:^ + version: link:../shared + '@h3ravel/support': + specifier: workspace:^ + version: link:../support + simple-body-validator: + specifier: ^1.3.9 + version: 1.3.9 + devDependencies: + typescript: + specifier: ^5.4.0 + version: 5.9.3 + packages/view: dependencies: '@h3ravel/core': @@ -703,7 +759,7 @@ importers: version: link:../http '@h3ravel/musket': specifier: catalog:prod - version: 0.3.11(@h3ravel/support@0.15.6)(@types/node@24.10.0) + version: 0.3.12(@h3ravel/support@0.15.6)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:* version: link:../shared @@ -1465,8 +1521,8 @@ packages: engines: {node: '>=14', pnpm: '>=4'} hasBin: true - '@h3ravel/musket@0.3.11': - resolution: {integrity: sha512-pm8L3iyuvjyDFipOVOWRFmqReMcZ7JjeZJKGYUqfxlY3oquvt9L4/5tagkb4rM9onchKWOE8KjnIC5daxDJICw==} + '@h3ravel/musket@0.3.12': + resolution: {integrity: sha512-vg6tnihXjchqnIcqyHE7htApl1Z0MACshlNP9Euw0P35aRGWMvNL9O6dtgLyqaDkIXeEQEBeGoxlmom5jfcoZw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@h3ravel/support': ^0.15.6 @@ -4874,6 +4930,9 @@ packages: resolution: {integrity: sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==} engines: {node: '>=6'} + simple-body-validator@1.3.9: + resolution: {integrity: sha512-zruc+5Y+L16lHTX+z/aSp8payiGGk1GM7UC9rs8j+lKOwxikfm+/RACsrjh461FCyyOcwAbu7s0UnKRKy9nUyQ==} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -6877,7 +6936,7 @@ snapshots: - sqlite3 - supports-color - '@h3ravel/musket@0.3.11(@h3ravel/support@0.15.6)(@types/node@24.10.0)': + '@h3ravel/musket@0.3.12(@h3ravel/support@0.15.6)(@types/node@24.10.0)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.10.0) '@h3ravel/support': 0.15.6 @@ -6894,7 +6953,7 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.3.11(@h3ravel/support@packages+support)(@types/node@24.10.0)': + '@h3ravel/musket@0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.10.0) '@h3ravel/support': link:packages/support @@ -6911,7 +6970,7 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.3.11(@h3ravel/support@packages+support)(@types/node@24.9.2)': + '@h3ravel/musket@0.3.12(@h3ravel/support@packages+support)(@types/node@24.9.2)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.9.2) '@h3ravel/support': link:packages/support @@ -10674,6 +10733,8 @@ snapshots: figures: 2.0.0 pkg-conf: 2.1.0 + simple-body-validator@1.3.9: {} + simple-concat@1.0.1: {} simple-get@4.0.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 29c947f4..98ca5efd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,8 @@ packages: - packages/console - packages/view - packages/url + - packages/validation + - packages/foundation - examples/* - docs @@ -45,6 +47,7 @@ catalog: husky: ^9.1.7 knex: ^3.1.0 luxon: ^3.7.2 + mysql2: 3.15.3 path: ^0.12.7 preferred-pm: ^4.1.1 reflect-metadata: ^0.2.2 @@ -53,7 +56,6 @@ catalog: semver: ^7.7.2 source-map-support: ^0.5.21 sqlite3: 5.1.7 - mysql2: 3.15.3 ts-node: ^10.9.2 tsconfig-paths: ^4.2.0 tslib: ^2.8.1 @@ -65,17 +67,19 @@ catalog: catalogs: prod: '@h3ravel/arquebus': ^0.6.17 - '@h3ravel/musket': ^0.3.11 + '@h3ravel/musket': ^0.3.12 h3: 2.0.1-rc.5 ignoredBuiltDependencies: - '@h3ravel/arquebus' - '@swc/core' - argon2 + - esbuild + +nodeLinker: isolated onlyBuiltDependencies: - - esbuild - - sqlite3 - '@h3ravel/musket' + - sqlite3 shamefullyHoist: true diff --git a/tsconfig.base.json b/tsconfig.base.json index 58ba146c..b4c9a99a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,7 +16,9 @@ "@h3ravel/shared": ["packages/shared/src/index.ts"], "@h3ravel/support": ["packages/support/src/index.ts"], "@h3ravel/url": ["packages/url/src/index.ts"], - "@h3ravel/view": ["packages/view/src/index.ts"] + "@h3ravel/view": ["packages/view/src/index.ts"], + "@h3ravel/foundation": ["packages/foundation/src/index.ts"], + "@h3ravel/validation": ["packages/validation/src/index.ts"] }, "experimentalDecorators": true, "emitDecoratorMetadata": true, diff --git a/tsdown.config.ts b/tsdown.config.ts index 149669d9..5ffe9910 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -8,6 +8,7 @@ export const baseConfig: UserConfig = { dts: true, clean: true, shims: true, + unbundle: false, entry: ['src/index.ts'], format: ['esm', 'cjs'], sourcemap: false, From ab38d1e1dfd08ef113e4bc2a3655eb796faa1b3f Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Tue, 11 Nov 2025 12:46:56 +0100 Subject: [PATCH 06/28] feat: redirect to referring page on failed validation if json is not expected. --- examples/basic-app/src/bootstrap/app.ts | 14 ++++------- examples/basic-app/src/routes/web.ts | 4 ++-- packages/foundation/src/Exceptions/Handler.ts | 6 +++++ packages/http/src/Response.ts | 11 +++++---- packages/shared/src/Contracts/IResponse.ts | 23 ++++++++++++++++++- .../validation/src/ValidationException.ts | 15 +++++++++++- 6 files changed, 55 insertions(+), 18 deletions(-) diff --git a/examples/basic-app/src/bootstrap/app.ts b/examples/basic-app/src/bootstrap/app.ts index e6902b50..843c8834 100644 --- a/examples/basic-app/src/bootstrap/app.ts +++ b/examples/basic-app/src/bootstrap/app.ts @@ -1,7 +1,4 @@ -import { HttpException, UnprocessableEntityHttpException } from '@h3ravel/foundation' - -import { HttpContext } from '@h3ravel/shared' -import { ValidationException } from '@h3ravel/validation' +import { UnprocessableEntityHttpException } from '@h3ravel/foundation' import { h3ravel } from '@h3ravel/core' import providers from 'src/bootstrap/providers' @@ -9,17 +6,14 @@ export default class { async bootstrap () { const app = await h3ravel(providers, process.cwd(), { autoload: true, initialize: false }, async () => undefined) - app - .configure() + app.configure() .withExceptions((exceptions) => { return exceptions - .render((error, { request, response }: HttpContext) => { - }) /** - * Register global reporters + * Register global reporters here */ .report((error) => { - console.error('🔥 Unhandled Exception:') + console.error('Unhandled Exception:', error) }) /** * Prevent some exceptions from being reported diff --git a/examples/basic-app/src/routes/web.ts b/examples/basic-app/src/routes/web.ts index 9f3753fe..ef4be410 100644 --- a/examples/basic-app/src/routes/web.ts +++ b/examples/basic-app/src/routes/web.ts @@ -1,4 +1,5 @@ import { HomeController } from 'App/Http/Controllers/HomeController' +import { HttpContext } from '@h3ravel/http' import { MailController } from 'src/app/Http/Controllers/MailController' import { Router } from '@h3ravel/router' import { UrlExampleController } from 'src/app/Http/Controllers/UrlExampleController' @@ -23,8 +24,7 @@ export default (Route: Router) => { }) }) - Route.put('/validation', async ({ request, response }) => { - // console.log(request) + Route.put('/validation', async ({ request, response }: HttpContext) => { const data = await request.validate({ name: ['required', 'string'], age: ['required', 'integer'], diff --git a/packages/foundation/src/Exceptions/Handler.ts b/packages/foundation/src/Exceptions/Handler.ts index b33954e5..5e04d04c 100644 --- a/packages/foundation/src/Exceptions/Handler.ts +++ b/packages/foundation/src/Exceptions/Handler.ts @@ -3,6 +3,7 @@ import { FileSystem, HttpContext, IRequest, IResponse } from '@h3ravel/shared' import { LimitSpec, RateLimiterAdapter, Unlimited } from '../Contracts/RateLimiterAdapter' +import { HTTPResponse } from 'h3' import { InMemoryRateLimiter } from '../Adapters/InMemoryRateLimiter' import { readFileSync } from 'node:fs' @@ -391,6 +392,7 @@ export abstract class Handler { if (e && typeof e.toResponse === 'function') { try { const resp = await Promise.resolve(e.toResponse(ctx.request)) + if (resp instanceof Response) return this.finalizeRenderedResponse(ctx.request, resp, e) else if (Object.entries(resp).length) return this.getResponse(ctx, resp, e) } catch { @@ -428,6 +430,7 @@ export abstract class Handler { public getResponse ({ request }: HttpContext, payload: Record, e: any): IResponse | Promise { if (this.shouldReturnJson(request, e)) { return response() + .setCharset('utf-8') .setStatusCode(this.isHttpException(e) ? (e.status as number) : 500) .json(payload) } @@ -440,6 +443,7 @@ export abstract class Handler { const body = payload.message ?? (this.isHttpException(e) ? (e.message ?? 'Error') : 'Internal Server Error') return response() + .setCharset('utf-8') .setStatusCode(this.isHttpException(e) ? (e.status as number) : 500) .viewTemplate(readFileSync(view, { encoding: 'utf-8' }), { statusCode: this.isHttpException(e) ? (e.status as number) : 500, @@ -465,6 +469,7 @@ export abstract class Handler { ]) ?? '' return response() + .setCharset('utf-8') .setStatusCode(this.isHttpException(e) ? (e.status as number) : 500) .viewTemplate(readFileSync(view, { encoding: 'utf-8' }), { statusCode: this.isHttpException(e) ? (e.status as number) : 500, @@ -533,6 +538,7 @@ export abstract class Handler { protected prepareJsonResponse (_request: IRequest, e: any): IResponse { const payload = this.convertExceptionToArray(e) return response() + .setCharset('utf-8') .setStatusCode(this.isHttpException(e) ? (e.status as number) : 500) .json(payload) } diff --git a/packages/http/src/Response.ts b/packages/http/src/Response.ts index 3d5bace4..6359ab8f 100644 --- a/packages/http/src/Response.ts +++ b/packages/http/src/Response.ts @@ -1,6 +1,5 @@ import type { DotNestedKeys, DotNestedValue } from '@h3ravel/shared' import { type H3Event, HTTPResponse } from 'h3' -import { redirect, } from 'h3' import { Application } from '@h3ravel/core' import { HttpResponse } from './Utilities/HttpResponse' @@ -144,9 +143,13 @@ export class Response extends HttpResponse implements IResponse { /** * Redirect to another URL. */ - redirect (location: string, status: number = 302, statusText?: string | undefined): HTTPResponse { - this.setStatusCode(status, statusText) - return redirect(location, this.statusCode, statusText) + redirect (location: string, status: number = 302, statusText?: string | undefined): this { + return this.setStatusCode(status, statusText || (status === 301 ? 'Moved Permanently' : 'Found')) + .setContent(``) + .withHeaders({ + 'content-type': 'text/html; charset=utf-8', + location + }) } /** diff --git a/packages/shared/src/Contracts/IResponse.ts b/packages/shared/src/Contracts/IResponse.ts index cc2c70f2..c50fa5a7 100644 --- a/packages/shared/src/Contracts/IResponse.ts +++ b/packages/shared/src/Contracts/IResponse.ts @@ -20,6 +20,27 @@ export interface IResponse extends IHttpResponse { * Sends content for the current web response. */ send (type?: 'html' | 'json' | 'text' | 'xml'): unknown; + + /** + * Use an edge view as content + * + * @param viewPath The path to the view file + * @param send if set to true, the content will be returned, instead of the Response instance + * @returns + */ + view (viewPath: string, data?: Record | undefined): Promise + view (viewPath: string, data: Record | undefined, parse: boolean): Promise + + /** + * + * Parse content as edge view + * + * @param content The content to serve + * @param send if set to true, the content will be returned, instead of the Response instance + * @returns + */ + viewTemplate (content: string, data?: Record | undefined): Promise + viewTemplate (content: string, data: Record | undefined, parse: boolean): Promise /** * * @param content The content to serve @@ -46,7 +67,7 @@ export interface IResponse extends IHttpResponse { /** * Redirect to another URL. */ - redirect (location: string, status?: number, statusText?: string | undefined): HTTPResponse; + redirect (location: string, status?: number, statusText?: string | undefined): this; /** * Dump the response. */ diff --git a/packages/validation/src/ValidationException.ts b/packages/validation/src/ValidationException.ts index db6df5ad..4f45329f 100644 --- a/packages/validation/src/ValidationException.ts +++ b/packages/validation/src/ValidationException.ts @@ -1,3 +1,4 @@ +import { IRequest } from '@h3ravel/shared' import { MessageBag } from './utilities/MessageBag' import { Str } from '@h3ravel/support' import { UnprocessableEntityHttpException } from '@h3ravel/foundation' @@ -21,7 +22,19 @@ export class ValidationException extends UnprocessableEntityHttpException { Object.setPrototypeOf(this, ValidationException.prototype) } - public toResponse () { + /** + * Send a custom response body for this exception + * + * @param request + * @returns + */ + public toResponse (request: IRequest) { + if (!request.expectsJson()) { + return response() + .setCharset('utf-8') + .redirect(request.getHeader('referer') || '/', 302) + } + return { message: this.message, errors: this.errors(), From eb719df5cc9b33eece235d2183b566ba7d752374 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 12 Nov 2025 09:26:25 +0100 Subject: [PATCH 07/28] feat: create session management library --- .barrelize | 8 + .github/workflows/review.yml | 11 + .github/workflows/test.yml | 11 + .sessions/undefined.json | 1 + .../framework/.sessions/undefined.json | 1 + examples/basic-app/package.json | 1 + packages/core/src/Application.ts | 2 +- packages/core/src/Http/Kernel.ts | 7 + .../core/tests/single-entry-point.test.ts | 2 +- packages/http/src/Utilities/HeaderBag.ts | 2 +- packages/session/README.md | 43 +++ packages/session/package.json | 68 +++++ .../session/src/Contracts/SessionContract.ts | 59 ++++ packages/session/src/Encryption.ts | 42 +++ .../src/Providers/SessionServiceProvider.ts | 23 ++ packages/session/src/SessionManager.ts | 105 ++++++++ packages/session/src/SessionStore.ts | 29 ++ packages/session/src/adapters.ts | 43 +++ .../session/src/drivers/DatabaseDriver.ts | 147 ++++++++++ packages/session/src/drivers/FileDriver.ts | 128 +++++++++ packages/session/src/drivers/MemoryDriver.ts | 53 ++++ packages/session/src/drivers/RedisDriver.ts | 37 +++ packages/session/src/index.ts | 10 + packages/session/tests/config/database.ts | 160 +++++++++++ packages/session/tests/config/db.sqlite3 | 0 packages/session/tests/session.spec.ts | 255 ++++++++++++++++++ packages/session/tsconfig.json | 10 + .../shared/src/Contracts/BindingsContract.ts | 3 +- packages/shared/src/Contracts/IHttp.ts | 3 +- packages/validation/tests/config/database.ts | 4 +- packages/validation/tests/validator.spec.ts | 74 +++-- pnpm-lock.yaml | 161 ++--------- pnpm-workspace.yaml | 1 + session.json | 44 +++ tsconfig.base.json | 1 + ...44d5a17dd9205784e94441e80094a166dd1b9.json | 1 + ...88afe5f5bbbf7497f314ce13f87b4610d8be0.json | 1 + 37 files changed, 1373 insertions(+), 178 deletions(-) create mode 100644 .sessions/undefined.json create mode 100644 Users/legacy/Documents/Marx/OpenSource/H3ravel/framework/.sessions/undefined.json create mode 100644 packages/session/README.md create mode 100644 packages/session/package.json create mode 100644 packages/session/src/Contracts/SessionContract.ts create mode 100644 packages/session/src/Encryption.ts create mode 100644 packages/session/src/Providers/SessionServiceProvider.ts create mode 100644 packages/session/src/SessionManager.ts create mode 100644 packages/session/src/SessionStore.ts create mode 100644 packages/session/src/adapters.ts create mode 100644 packages/session/src/drivers/DatabaseDriver.ts create mode 100644 packages/session/src/drivers/FileDriver.ts create mode 100644 packages/session/src/drivers/MemoryDriver.ts create mode 100644 packages/session/src/drivers/RedisDriver.ts create mode 100644 packages/session/src/index.ts create mode 100644 packages/session/tests/config/database.ts create mode 100644 packages/session/tests/config/db.sqlite3 create mode 100644 packages/session/tests/session.spec.ts create mode 100644 packages/session/tsconfig.json create mode 100644 session.json create mode 100644 var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/1330775240427184c0c1950560444d5a17dd9205784e94441e80094a166dd1b9.json create mode 100644 var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/ed95f69be93053a7d27b34c7eaa88afe5f5bbbf7497f314ce13f87b4610d8be0.json diff --git a/.barrelize b/.barrelize index 39f191a6..d97a5edf 100644 --- a/.barrelize +++ b/.barrelize @@ -133,6 +133,14 @@ "**/*.d.ts" ] }, + { + "name": "index.ts", + "root": "packages/session/src", + "exclude": [ + "**/*.test.ts", + "**/*.d.ts" + ] + }, { "name": "index.ts", "root": "packages/foundation/src", diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 69e5ec93..76450201 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -14,6 +14,17 @@ jobs: name: Check PR runs-on: ubuntu-latest + services: + # https://github.community/t5/GitHub-Actions/github-actions-cannot-connect-to-mysql-service/td-p/30611# + mysql: + image: mysql:5.7 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: h3ravel_test + ports: + - 3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9e7e491d..fb188c69 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,17 @@ jobs: name: Run Available Tests runs-on: ubuntu-latest + services: + # https://github.community/t5/GitHub-Actions/github-actions-cannot-connect-to-mysql-service/td-p/30611# + mysql: + image: mysql:5.7 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: h3ravel_test + ports: + - 3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.sessions/undefined.json b/.sessions/undefined.json new file mode 100644 index 00000000..e1e6bdec --- /dev/null +++ b/.sessions/undefined.json @@ -0,0 +1 @@ +278ee61201eb1d3e5fbd9d0ea23bd2f6:878b1f86745741c9edaddd4aafe79596 \ No newline at end of file diff --git a/Users/legacy/Documents/Marx/OpenSource/H3ravel/framework/.sessions/undefined.json b/Users/legacy/Documents/Marx/OpenSource/H3ravel/framework/.sessions/undefined.json new file mode 100644 index 00000000..88850a57 --- /dev/null +++ b/Users/legacy/Documents/Marx/OpenSource/H3ravel/framework/.sessions/undefined.json @@ -0,0 +1 @@ +7ee870110f09753f2725e9065149b096:2fab70d9aff4ec1e621b2aaa23d9c09a \ No newline at end of file diff --git a/examples/basic-app/package.json b/examples/basic-app/package.json index b4844a46..83d6f11e 100644 --- a/examples/basic-app/package.json +++ b/examples/basic-app/package.json @@ -33,6 +33,7 @@ "@h3ravel/view": "workspace:^", "@h3ravel/validation": "workspace:^", "@h3ravel/foundation": "workspace:^", + "@h3ravel/session": "workspace:^", "cross-env": "catalog:", "h3": "catalog:prod", "reflect-metadata": "catalog:", diff --git a/packages/core/src/Application.ts b/packages/core/src/Application.ts index a5adc3e2..062b9fbc 100644 --- a/packages/core/src/Application.ts +++ b/packages/core/src/Application.ts @@ -267,7 +267,7 @@ export class Application extends Container implements IApplication { return await this.serve(h3App, preferredPort) } - if (!this.h3App) { + if (!this?.h3App) { throw new ConfigException('[Provide a H3 app instance in the config or install @h3ravel/http]') } diff --git a/packages/core/src/Http/Kernel.ts b/packages/core/src/Http/Kernel.ts index 10195c4d..8f061e14 100644 --- a/packages/core/src/Http/Kernel.ts +++ b/packages/core/src/Http/Kernel.ts @@ -34,6 +34,13 @@ export class Kernel { const { app } = ctx.request + /** + * Bind HTTP Context to the service container + */ + app.bind('http.context', () => { + return ctx + }) + /** * Bind HTTP Response instance to the service container */ diff --git a/packages/core/tests/single-entry-point.test.ts b/packages/core/tests/single-entry-point.test.ts index eba307d0..a4663138 100644 --- a/packages/core/tests/single-entry-point.test.ts +++ b/packages/core/tests/single-entry-point.test.ts @@ -23,7 +23,7 @@ describe('Single Entry Point without @h3ravel/http installed', async () => { }) it('will throw ConfigException when an H3 app instance is not provided and fire() is called', async () => { - expect(app.fire).toThrowError(new ConfigException('Provide a H3 app instance in the config or install @h3ravel/http')) + await expect(app.fire()).rejects.toThrowError(new ConfigException('[Provide a H3 app instance in the config or install @h3ravel/http]')) }) }) diff --git a/packages/http/src/Utilities/HeaderBag.ts b/packages/http/src/Utilities/HeaderBag.ts index a4bbff03..d1c0ae8d 100644 --- a/packages/http/src/Utilities/HeaderBag.ts +++ b/packages/http/src/Utilities/HeaderBag.ts @@ -99,7 +99,7 @@ export class HeaderBag implements Iterable<[string, (string | null)[]]> { key: string, defaultValue: string | null = null ): R extends undefined ? string | null : R { - const headers = this.all(key) as (string | null)[] + const headers = this.all(key) || this.all('http-' + key) if (!headers.length) return defaultValue as R extends undefined ? string | null : R return headers[0] as R extends undefined ? string | null : R } diff --git a/packages/session/README.md b/packages/session/README.md new file mode 100644 index 00000000..06cfcff2 --- /dev/null +++ b/packages/session/README.md @@ -0,0 +1,43 @@ +
+ + H3ravel Logo + +

H3ravel Sessions

+ +[![Framework][ix]][lx] +[![Filesystem Package Version][i1]][l1] +[![Downloads][d1]][d1] +[![Tests][tei]][tel] +[![License][lini]][linl] + +
+ +# About H3ravel/session + +Provides a unified session management layer for the [H3ravel](https://h3ravel.toneflix.net) framework, with secure encryption, consistent API design, and optional adapters (memory, file, redis, db). + +## Contributing + +Thank you for considering contributing to the H3ravel framework! The [Contribution Guide](https://h3ravel.toneflix.net/contributing) can be found in the H3ravel documentation and will provide you with all the information you need to get started. + +## Code of Conduct + +In order to ensure that the H3ravel community is welcoming to all, please review and abide by the [Code of Conduct](#). + +## Security Vulnerabilities + +If you discover a security vulnerability within H3ravel, please send an e-mail to Legacy via hamzas.legacy@toneflix.ng. All security vulnerabilities will be promptly addressed. + +## License + +The H3ravel framework is open-sourced software licensed under the [MIT license](LICENSE). + +[ix]: https://img.shields.io/npm/v/%40h3ravel%2Fcore?style=flat-square&label=Framework&color=%230970ce +[lx]: https://www.npmjs.com/package/@h3ravel/core +[i1]: https://img.shields.io/npm/v/%40h3ravel%2Fhttp?style=flat-square&label=@h3ravel/session&color=%230970ce +[l1]: https://www.npmjs.com/package/@h3ravel/session +[d1]: https://img.shields.io/npm/dt/%40h3ravel%2Fhttp?style=flat-square&label=Downloads&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40h3ravel%2Fhttp +[linl]: https://github.com/h3ravel/framework/blob/main/LICENSE +[lini]: https://img.shields.io/github/license/h3ravel/framework +[tel]: https://github.com/h3ravel/framework/actions/workflows/test.yml +[tei]: https://github.com/h3ravel/framework/actions/workflows/test.yml/badge.svg diff --git a/packages/session/package.json b/packages/session/package.json new file mode 100644 index 00000000..816c89ba --- /dev/null +++ b/packages/session/package.json @@ -0,0 +1,68 @@ +{ + "name": "@h3ravel/session", + "version": "0.1.0", + "description": "Provides a unified session management layer for h3ravel, with secure encryption, consistent API design, and optional adapters (memory, file, redis, db).", + "h3ravel": { + "providers": [ + "SessionServiceProvider" + ] + }, + "type": "module", + "main": "./dist/index.cjs", + "types": "./dist/index.d.ts", + "module": "./dist/index.js", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./*": "./*" + } + }, + "homepage": "https://h3ravel.toneflix.net", + "repository": { + "type": "git", + "url": "git+https://github.com/h3ravel/framework.git", + "directory": "packages/session" + }, + "keywords": [ + "h3ravel", + "modern", + "web", + "H3", + "framework", + "nodejs", + "typescript", + "laravel", + "persist", + "session" + ], + "scripts": { + "build": "tsdown --config-loader unconfig", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "lint": "eslint . --ext .ts", + "test": "jest --passWithNoTests", + "version-patch": "pnpm version patch" + }, + "peerDependencies": { + "@h3ravel/core": "workspace:^", + "@h3ravel/database": "workspace:^", + "@h3ravel/shared": "workspace:^" + }, + "devDependencies": { + "typescript": "^5.4.0" + } +} \ No newline at end of file diff --git a/packages/session/src/Contracts/SessionContract.ts b/packages/session/src/Contracts/SessionContract.ts new file mode 100644 index 00000000..8a1aa8ea --- /dev/null +++ b/packages/session/src/Contracts/SessionContract.ts @@ -0,0 +1,59 @@ +/** + * SessionDriver Interface + * + * All session drivers must implement these methods to ensure + * consistency across different storage mechanisms (memory, file, db, redis). + */ +export interface SessionDriver { + /** + * Retrieve a value from the session by key. + */ + get (key: string): any | Promise + + /** + * Store a value in the session. + */ + set (key: string, value: any): void | Promise + + /** + * Store multiple key/value pairs in the session. + */ + put (data: Record): void | Promise + + /** + * Append a value to an array in the session. + */ + push (key: string, value: any): void | Promise + + /** + * Remove an item from the session by key. + */ + forget (key: string): void | Promise + + /** + * Retrieve all session data. + */ + all (): Record | Promise> + + /** + * Clear all session data. + */ + flush (): void | Promise +} + +export interface DriverOption { + cwd?: string + dir?: string + table?: string + prefix?: string + client?: any + sessionId?: string + sessionDir?: string +} + +/** + * A builder function that returns a SessionDriver for a given sessionId. + * + * The builder receives the sessionId and a driver-specific options bag. + */ +export type DriverBuilder = (sessionId: string, options?: DriverOption) => SessionDriver \ No newline at end of file diff --git a/packages/session/src/Encryption.ts b/packages/session/src/Encryption.ts new file mode 100644 index 00000000..2f263bd7 --- /dev/null +++ b/packages/session/src/Encryption.ts @@ -0,0 +1,42 @@ +import crypto, { createHash } from 'crypto' + +import { ConfigException } from 'packages/core/dist' + +export class Encryption { + private key: Buffer + + constructor() { + const appKey = process.env.APP_KEY + if (!appKey) throw new ConfigException('APP_KEY not set in env') + this.key = createHash('sha256').update(Buffer.from(appKey, 'base64')).digest() + } + + /** + * Encrypt session data using AES-256-CBC and the APP_KEY. + */ + public encrypt (value: any) { + value = typeof value === 'string' ? value : JSON.stringify(value) + + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv('aes-256-cbc', this.key, iv) + const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]) + return iv.toString('hex') + ':' + encrypted.toString('hex') + } + + /** + * Decrypt session data. + */ + public decrypt (value: any) { + const [ivHex, encryptedHex] = value.split(':') + const iv = Buffer.from(ivHex, 'hex') + const encrypted = Buffer.from(encryptedHex, 'hex') + const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, iv) + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8') + + try { + return JSON.parse(decrypted) + } catch { + return decrypted + } + } +} diff --git a/packages/session/src/Providers/SessionServiceProvider.ts b/packages/session/src/Providers/SessionServiceProvider.ts new file mode 100644 index 00000000..d418ee5f --- /dev/null +++ b/packages/session/src/Providers/SessionServiceProvider.ts @@ -0,0 +1,23 @@ +import { dbBuilder, fileBuilder, memoryBuilder, redisBuilder } from '../adapters' + +import { SessionStore } from '../SessionStore' + +export class SessionServiceProvider { + public registeredCommands?: (new (app: any, kernel: any) => any)[] + public static priority = 895 + + constructor(private app: any) { } + + register (): void { + /** + * Register default drivers. + */ + SessionStore.register('file', fileBuilder) + SessionStore.register('database', dbBuilder) + SessionStore.register('memory', memoryBuilder) + SessionStore.register('redis', redisBuilder) + } + + boot (): void { + } +} diff --git a/packages/session/src/SessionManager.ts b/packages/session/src/SessionManager.ts new file mode 100644 index 00000000..573413ae --- /dev/null +++ b/packages/session/src/SessionManager.ts @@ -0,0 +1,105 @@ +import { DriverOption, SessionDriver } from './Contracts/SessionContract' +import { HttpContext, IRequest } from '@h3ravel/shared' +import { createHash, createHmac, randomBytes } from 'crypto' +import { getCookie, setCookie } from 'h3' + +import { SessionStore } from './SessionStore' + +/** + * SessionManager + * + * Handles session initialization, ID generation, and encryption. + * Each request gets a unique session namespace tied to its ID. + */ +export class SessionManager { + private driver: SessionDriver + private appKey: string + private sessionId: string + private request: IRequest + + /** + * @param ctx - incoming request http context + * @param driverName - registered driver key ('file' | 'database' | 'memory' | 'redis') + * @param driverOptions - optional bag for driver-specific options + */ + constructor(private ctx: HttpContext, driverName: 'file' | 'memory' | 'database' | 'redis' = 'file', driverOptions: DriverOption = {}) { + this.appKey = process.env.APP_KEY! + this.request = ctx.request + + this.sessionId = this.resolveSessionId() + + // Then instantiate the driver through the registry so different constructors are supported + this.driver = SessionStore.make(driverName, driverOptions.sessionId ?? this.sessionId, driverOptions) + } + + /** + * Generate a secure session ID unique to the user device. + */ + private generateSessionId (): string { + const userAgent = this.request.getHeader('user-agent') || '' + const ip = this.request.getHeader('x-forwarded-for') || this.request.ip() || '' + const random = randomBytes(32).toString('hex') + const fingerprint = createHash('sha256').update(`${userAgent}-${ip}`).digest('hex') + + return createHmac('sha256', this.appKey) + .update(`${fingerprint}-${random}`) + .digest('hex') + } + + /** + * Resolve the session ID from cookie, header, or create a new one. + */ + private resolveSessionId (): string { + const cookieSession = getCookie(this.ctx.event, 'h3ravel_session') + + if (cookieSession) return cookieSession + + const newId = this.generateSessionId() + + setCookie(this.ctx.event, 'h3ravel_session', newId, { + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7 + }) + return newId + } + + /** + * Access the current session ID. + */ + public id (): string { + return this.sessionId + } + + /** + * Proxy session methods directly to the driver. + */ + public get (key: string): any | Promise { + return this.driver.get(key) + } + + public set (key: string, value: any): void | Promise { + this.driver.set(key, value) + } + + public put (data: Record): void | Promise { + this.driver.put(data) + } + + public push (key: string, value: any): void | Promise { + this.driver.push(key, value) + } + + public forget (key: string): void | Promise { + this.driver.forget(key) + } + + public all (): Record | Promise> { + return this.driver.all() + } + + public flush (): void | Promise { + this.driver.flush() + } +} diff --git a/packages/session/src/SessionStore.ts b/packages/session/src/SessionStore.ts new file mode 100644 index 00000000..1986b4c5 --- /dev/null +++ b/packages/session/src/SessionStore.ts @@ -0,0 +1,29 @@ +import { DriverBuilder, DriverOption } from './Contracts/SessionContract' + +/** + * SessionStore (Driver registry) + * + * Register driver builders under a name and then create instances using: + * SessionStore.make('file', sessionId, options) + */ +export class SessionStore { + private static registry: Map = new Map() + + /** + * Register a driver builder under a key (e.g. 'file', 'database', 'memory'). + */ + public static register (name: 'file' | 'memory' | 'database' | 'redis', builder: DriverBuilder) { + this.registry.set(name, builder) + } + + /** + * Create a driver instance for the given sessionId using the named builder. + * + * If driver not found, throws. Options is a simple key/value bag passed to the builder. + */ + public static make (name: 'file' | 'memory' | 'database' | 'redis', sessionId: string, options: DriverOption = {}) { + const builder = this.registry.get(name) + if (!builder) throw new Error(`Session driver "${name}" is not registered`) + return builder(sessionId, options) + } +} diff --git a/packages/session/src/adapters.ts b/packages/session/src/adapters.ts new file mode 100644 index 00000000..4ffb834a --- /dev/null +++ b/packages/session/src/adapters.ts @@ -0,0 +1,43 @@ +import { DriverBuilder, DriverOption } from './Contracts/SessionContract' + +import { DatabaseDriver } from './drivers/DatabaseDriver' +import { FileDriver } from './drivers/FileDriver' +import { MemoryDriver } from './drivers/MemoryDriver' +import { RedisDriver } from './drivers/RedisDriver' + +/** + * FileDriver builder + * constructor(sessionId: string, sessionDir?: string, cwd?: string) + */ +export const fileBuilder: DriverBuilder = (sessionId, options: DriverOption = {}) => { + const sessionDir = options.sessionDir ?? options.dir ?? './storage/sessions' + const cwd = options.cwd ?? process.cwd() + return new FileDriver(sessionId, sessionDir, cwd) +} + +/** + * DatabaseDriver builder + * constructor(sessionId: string, table?: string) + */ +export const dbBuilder: DriverBuilder = (sessionId, options: DriverOption = {}) => { + const table = options.table ?? 'sessions' + return new DatabaseDriver(options.sessionId ?? sessionId, table) +} + +/** + * MemoryDriver builder + * constructor(sessionId: string) + */ +export const memoryBuilder: DriverBuilder = (sessionId) => { + return new MemoryDriver(sessionId) +} + +/** + * RedisDriver builder + * constructor(sessionId: string, redisClient?: RedisClient, prefix?: string) + */ +export const redisBuilder: DriverBuilder = (sessionId, options: DriverOption = {}) => { + const client = options.client // optional client instance + const prefix = options.prefix ?? 'h3ravel:sessions:' + return new RedisDriver(sessionId, client, prefix) +} diff --git a/packages/session/src/drivers/DatabaseDriver.ts b/packages/session/src/drivers/DatabaseDriver.ts new file mode 100644 index 00000000..c35603bc --- /dev/null +++ b/packages/session/src/drivers/DatabaseDriver.ts @@ -0,0 +1,147 @@ +import { DB } from '@h3ravel/database' +import { Encryption } from '../Encryption' +import { SessionDriver } from '../Contracts/SessionContract' + +/** + * DatabaseDriver + * + * Stores sessions in a database table. Each session ID maps to a row. + * The `payload` column contains all session key/value pairs as JSON. + */ +export class DatabaseDriver implements SessionDriver { + private encryptor = new Encryption() + + constructor( + /** + * The current session ID + */ + private sessionId: string, + private table: string = 'sessions' + ) { } + + /** + * Helper: get the query builder for this table. + */ + private query () { + return DB.table(this.table).where('id', this.sessionId) + } + + /** + * Fetch current payload + * + * @returns + */ + private async fetchPayload (): Promise> { + const row = await this.query().first() + + if (!row) return {} + + try { + const encrypted = row.payload + + if (!encrypted) return {} + const decrypted = this.encryptor.decrypt(encrypted) + return typeof decrypted === 'string' ? JSON.parse(decrypted) : decrypted + } catch { + return {} + } + } + + /** + * Save updated payload + * + * @param payload + */ + private async savePayload (payload: Record) { + const now = Math.floor(Date.now() / 1000) + const exists = await this.query().exists() + const encrypted = this.encryptor.encrypt(JSON.stringify(payload)) + + if (exists) { + await this.query().update({ + payload: encrypted, + last_activity: now, + }) + } else { + await DB.table(this.table).insert({ + id: this.sessionId, + payload: encrypted, + last_activity: now, + }) + } + } + + /** + * Retrieve a value from the session + * + * @param key + * @returns + */ + async get (key: string): Promise { + const payload = await this.fetchPayload() + return payload[key] + } + + /** + * Store a value in the session + * + * @param key + * @param value + */ + async set (key: string, value: any): Promise { + const payload = await this.fetchPayload() + payload[key] = value + await this.savePayload(payload) + } + + /** + * Store multiple key/value pairs + * + * @param values + */ + async put (values: Record): Promise { + const payload = await this.fetchPayload() + Object.assign(payload, values) + await this.savePayload(payload) + } + + /** + * Append a value to an array key + * + * @param key + * @param value + */ + async push (key: string, value: any): Promise { + const payload = await this.fetchPayload() + if (!Array.isArray(payload[key])) payload[key] = [] + payload[key].push(value) + await this.savePayload(payload) + } + + /** + * Remove a key from the session + * + * @param key + */ + async forget (key: string): Promise { + const payload = await this.fetchPayload() + delete payload[key] + await this.savePayload(payload) + } + + /** + * Retrieve all session data + * + * @returns + */ + async all (): Promise> { + return await this.fetchPayload() + } + + /** + * Flush all session data + */ + async flush (): Promise { + await this.savePayload({}) + } +} \ No newline at end of file diff --git a/packages/session/src/drivers/FileDriver.ts b/packages/session/src/drivers/FileDriver.ts new file mode 100644 index 00000000..56f9c3ac --- /dev/null +++ b/packages/session/src/drivers/FileDriver.ts @@ -0,0 +1,128 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' + +import { Encryption } from '../Encryption' +import { SessionDriver } from '../Contracts/SessionContract' +import path from 'path' + +/** + * FileDriver + * + * Stores session data as encrypted JSON files. + * Each session is stored in its own file named after the session ID. + * Ideal for local development or low-scale deployments. + */ +export class FileDriver implements SessionDriver { + private sessionDir: string + private sessionId: string + private encryptor = new Encryption() + + constructor( + sessionId: string, + sessionDir: string = path.resolve('.sessions'), + private cwd: string = process.cwd() + ) { + this.sessionDir = path.join(this.cwd, sessionDir) + this.sessionId = sessionId + + if (!existsSync(this.sessionDir)) { + mkdirSync(this.sessionDir, { recursive: true }) + } + + this.ensureSessionFile() + } + + /** + * Ensures the session file exists and is initialized. + */ + private ensureSessionFile (): void { + const file = this.sessionFilePath() + if (!existsSync(file)) { + this.writeEncrypted({}) + } + } + + /** + * Get the absolute path for the current session file. + */ + private sessionFilePath (): string { + return path.join(this.sessionDir, `${this.sessionId}.json`) + } + + /** + * Read and decrypt session data from file. + */ + private readDecrypted (): Record { + const file = this.sessionFilePath() + if (!existsSync(file)) return {} + const content = readFileSync(file, 'utf8') + return this.encryptor.decrypt(content) + } + + /** + * Write and encrypt session data to file. + */ + private writeEncrypted (data: Record): void { + const file = this.sessionFilePath() + const encrypted = this.encryptor.encrypt(data) + writeFileSync(file, encrypted, 'utf8') + } + + /** + * Retrieve a value from the session. + */ + get (key: string): any { + const data = this.readDecrypted() + return data[key] + } + + /** + * Store a value in the session. + */ + set (key: string, value: any): void { + const data = this.readDecrypted() + data[key] = value + this.writeEncrypted(data) + } + + /** + * Store multiple key/value pairs. + */ + put (values: Record): void { + const data = this.readDecrypted() + Object.assign(data, values) + this.writeEncrypted(data) + } + + /** + * Append a value to an array key in the session. + */ + push (key: string, value: any): void { + const data = this.readDecrypted() + if (!Array.isArray(data[key])) data[key] = [] + data[key].push(value) + this.writeEncrypted(data) + } + + /** + * Remove a key from the session. + */ + forget (key: string): void { + const data = this.readDecrypted() + delete data[key] + this.writeEncrypted(data) + } + + /** + * Retrieve all session data. + */ + all (): Record { + return this.readDecrypted() + } + + /** + * Flush (clear) the session. + */ + flush (): void { + this.writeEncrypted({}) + } +} diff --git a/packages/session/src/drivers/MemoryDriver.ts b/packages/session/src/drivers/MemoryDriver.ts new file mode 100644 index 00000000..a63e137a --- /dev/null +++ b/packages/session/src/drivers/MemoryDriver.ts @@ -0,0 +1,53 @@ +import { SessionDriver } from '../Contracts/SessionContract' + +/** + * MemoryDriver + * + * Lightweight, ephemeral session storage. + * Intended for tests, local development, or short-lived apps. + */ +export class MemoryDriver implements SessionDriver { + private static store: Record> = {} + private sessionId: string + + constructor(sessionId: string) { + this.sessionId = sessionId + if (!MemoryDriver.store[this.sessionId]) { + MemoryDriver.store[this.sessionId] = {} + } + } + + get (key: string): any { + return MemoryDriver.store[this.sessionId][key] + } + + set (key: string, value: any): void { + MemoryDriver.store[this.sessionId][key] = value + } + + put (data: Record): void { + MemoryDriver.store[this.sessionId] = { + ...MemoryDriver.store[this.sessionId], + ...data, + } + } + + push (key: string, value: any): void { + const existing = MemoryDriver.store[this.sessionId][key] || [] + if (!Array.isArray(existing)) throw new Error(`Cannot push to non-array key: ${key}`) + existing.push(value) + MemoryDriver.store[this.sessionId][key] = existing + } + + forget (key: string): void { + delete MemoryDriver.store[this.sessionId][key] + } + + all (): Record { + return { ...MemoryDriver.store[this.sessionId] } + } + + flush (): void { + MemoryDriver.store[this.sessionId] = {} + } +} diff --git a/packages/session/src/drivers/RedisDriver.ts b/packages/session/src/drivers/RedisDriver.ts new file mode 100644 index 00000000..47aa3a9c --- /dev/null +++ b/packages/session/src/drivers/RedisDriver.ts @@ -0,0 +1,37 @@ +import { Encryption } from '../Encryption' +import { SessionDriver } from '../Contracts/SessionContract' + +/** + * RedisDriver (placeholder) + */ +export class RedisDriver implements SessionDriver { + private store: Record> = {} + private encryptor = new Encryption() + + constructor( + /** + * The current session ID + */ + private sessionId: string, + private redisClient?: 'RedisClient', + private prefix?: string + ) { } + + get (key: string, defaultValue: any = null) { + return defaultValue + } + + set (key: string, value: any) { } + + all () { + return {} + } + + put (values: Record) { } + + push (key: string, value: any) { } + + forget (key: string) { } + + flush () { } +} \ No newline at end of file diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts new file mode 100644 index 00000000..eaa51047 --- /dev/null +++ b/packages/session/src/index.ts @@ -0,0 +1,10 @@ +export * from './adapters' +export * from './Contracts/SessionContract' +export * from './drivers/DatabaseDriver' +export * from './drivers/FileDriver' +export * from './drivers/MemoryDriver' +export * from './drivers/RedisDriver' +export * from './Encryption' +export * from './Providers/SessionServiceProvider' +export * from './SessionManager' +export * from './SessionStore' diff --git a/packages/session/tests/config/database.ts b/packages/session/tests/config/database.ts new file mode 100644 index 00000000..2a57943d --- /dev/null +++ b/packages/session/tests/config/database.ts @@ -0,0 +1,160 @@ +export default () => { + return { + /* + |-------------------------------------------------------------------------- + | Default Database Connection Name + |-------------------------------------------------------------------------- + | + | Here you may specify which of the database connections below you wish + | to use as your default connection for database operations. This is + | the connection which will be utilized unless another connection + | is explicitly specified when you execute a query / statement. + | + */ + + default: 'mysql', + + aws_db_host: env('AWS_DB_HOST'), + rds_secret_name: env('AWS_RDS_SECRET_NAME'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by H3ravel. You're free to add / remove connections. + | + */ + + connections: { + + sqlite: { + driver: 'sqlite3', //better-sqlite3 + database: ':memory:', + // database: base_path('config/db.sqlite3'), + prefix: '', + foreign_key_constraints: env('DB_FOREIGN_KEYS', true), + flags: [], + debug: false, + expirationChecker: () => false, + useNullAsDefault: true, + options: { + nativeBinding: undefined, + readonly: false + } + }, + + mysql: { + driver: 'mysql2', //mysql + url: env('DB_URL'), + host: env('DB_HOST', '127.0.0.1'), + port: env('DB_PORT', '3306'), + database: env('DB_DATABASE', 'h3ravel_test'), + username: env('DB_USERNAME', 'root'), + password: env('DB_PASSWORD', 'password'), + unix_socket: env('DB_SOCKET', ''), + charset: env('DB_CHARSET', 'utf8mb4'), + collation: env('DB_COLLATION', 'utf8mb4_unicode_ci'), + prefix: '', + prefix_indexes: true, + strict: true, + engine: null, + options: [ + ], + }, + + mariadb: { + driver: 'mariasql', + url: env('DB_URL'), + host: env('DB_HOST', '127.0.0.1'), + port: env('DB_PORT', '3306'), + database: env('DB_DATABASE', 'H3ravel'), + username: env('DB_USERNAME', 'root'), + password: env('DB_PASSWORD', 'password'), + unix_socket: env('DB_SOCKET', ''), + charset: env('DB_CHARSET', 'utf8mb4'), + collation: env('DB_COLLATION', 'utf8mb4_unicode_ci'), + prefix: '', + prefix_indexes: true, + strict: true, + engine: null, + options: [ + ], + }, + + pgsql: { + driver: 'pg', + url: env('DB_URL'), + host: env('DB_HOST', '127.0.0.1'), + port: env('DB_PORT', '5432'), + database: env('DB_DATABASE', 'H3ravel'), + username: env('DB_USERNAME', 'root'), + password: env('DB_PASSWORD', ''), + charset: env('DB_CHARSET', 'utf8'), + prefix: '', + prefix_indexes: true, + search_path: 'public', + sslmode: 'prefer', + }, + + }, + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + migrations: { + table: 'migrations', + update_date_on_publish: true, + }, + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + redis: { + + client: env('REDIS_CLIENT', 'phpredis'), + + options: { + cluster: env('REDIS_CLUSTER', 'redis'), + prefix: env('REDIS_PREFIX', str(env('APP_NAME', 'h3ravel')).slug('_') + '_database_'), + }, + + default: { + url: env('REDIS_URL'), + host: env('REDIS_HOST', '127.0.0.1'), + username: env('REDIS_USERNAME'), + password: env('REDIS_PASSWORD'), + port: env('REDIS_PORT', '6379'), + database: env('REDIS_DB', '0'), + }, + + cache: { + url: env('REDIS_URL'), + host: env('REDIS_HOST', '127.0.0.1'), + username: env('REDIS_USERNAME'), + password: env('REDIS_PASSWORD'), + port: env('REDIS_PORT', '6379'), + database: env('REDIS_CACHE_DB', '1'), + }, + + }, + } +} diff --git a/packages/session/tests/config/db.sqlite3 b/packages/session/tests/config/db.sqlite3 new file mode 100644 index 00000000..e69de29b diff --git a/packages/session/tests/session.spec.ts b/packages/session/tests/session.spec.ts new file mode 100644 index 00000000..93387a2d --- /dev/null +++ b/packages/session/tests/session.spec.ts @@ -0,0 +1,255 @@ +import { Application, h3ravel } from '@h3ravel/core' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, readFileSync } from 'node:fs' +import { mkdtemp, rmdir } from 'node:fs/promises' + +import { DB } from '@h3ravel/database' +import { DatabaseDriver } from '../src' +import { Encryption } from '../src/Encryption' +import { HttpContext } from '@h3ravel/shared' +import { SessionManager } from '../src/SessionManager' +import { SessionServiceProvider } from '../src/Providers/SessionServiceProvider' +import path from 'node:path' +import { tmpdir } from 'node:os' + +let ctx: HttpContext +let app: Application +let event: any +const appKey = 'base64:dnZm+Ei7ExEHzhj/wO/3YKUckMQtpLjRVk1VLYiV/es=' + +function makeEvent (overides: Record = {}) { + return { + res: { headers: new Headers(), statusCode: 200, cookie: () => { } }, + req: { + headers: new Headers({ + 'user-agent': 'Vitest', + 'x-forwarded-for': '127.0.0.1' + }), + url: overides.url ?? 'http://localhost/test', method: 'get' + }, + } as any +} + +describe('@h3ravel/session', () => { + beforeAll(async () => { + const { DatabaseServiceProvider } = (await import(('@h3ravel/database'))) + const { HttpServiceProvider } = (await import(('@h3ravel/http'))) + const { ConfigServiceProvider } = (await import(('@h3ravel/config'))) + const { RouteServiceProvider } = (await import(('@h3ravel/router'))) + app = await h3ravel( + [HttpServiceProvider, DatabaseServiceProvider, ConfigServiceProvider, RouteServiceProvider, SessionServiceProvider], + path.join(process.cwd(), 'packages/session/tests'), + { + autoload: false, + customPaths: { + config: 'config', + routes: 'routes', + } + }) + }) + + beforeEach(async () => { + event = makeEvent() + const { Request, Response, HttpContext } = (await import(('@h3ravel/http'))) + + ctx = HttpContext.init({ + app, + request: await Request.create(event, app), + response: new Response(event, app), + }, event) + + process.env.APP_KEY = appKey + }) + + + describe('Memory Driver', () => { + let session: SessionManager + + beforeEach(async () => { + session = new SessionManager(ctx, 'memory') + }) + + it('can persist sessions', async () => { + const data = { name: 'string' } + const session = new SessionManager(ctx, 'memory') + session.set('app', data) + + expect(session.get('app')).toMatchObject(data) + }) + + it('can encrypt and decrypt using APP_KEY', async () => { + const str = 'Hello World' + const encryptor = new Encryption() + const enc = encryptor.encrypt(str) + const dec = encryptor.decrypt(enc) + + expect(typeof enc === 'string').toBeTruthy() + expect(typeof dec === 'string').toBeTruthy() + expect(dec).toBe(str) + }) + + it('should generate a session ID', () => { + expect(session.id()).toBeTypeOf('string') + expect(session.id().length).toBeGreaterThan(0) + }) + + it('should set and get a value', () => { + session.set('foo', 'bar') + expect(session.get('foo')).toBe('bar') + }) + + it('should push to an array', () => { + session.set('arr', []) + session.push('arr', 'x') + session.push('arr', 'y') + expect(session.get('arr')).toEqual(['x', 'y']) + }) + + it('should flush all data', () => { + session.set('foo', 'bar') + session.flush() + expect(session.all()).toEqual({}) + }) + + it('should forget a key', () => { + session.set('temp', 123) + session.forget('temp') + expect(session.get('temp')).toBeUndefined() + }) + + it('should put multiple values', () => { + session.put({ a: 1, b: 2 }) + expect(session.get('a')).toBe(1) + expect(session.get('b')).toBe(2) + }) + }) + + describe('File Driver', () => { + let tmpDir: string + let session: SessionManager + + beforeEach(async () => { + session = new SessionManager(ctx, 'file', { cwd: tmpDir, sessionDir: 'storage/sessions' }) + }) + + beforeAll(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), '@h3ravel-session')) + }) + + afterAll(async () => { + await rmdir(tmpDir, { recursive: true, maxRetries: 2 }) + }) + + it('should generate a session ID and create a file', () => { + const file = path.join(tmpDir, `storage/sessions/${session.id()}.json`) + expect(existsSync(file)).toBe(true) + }) + + + it('should set and get values', () => { + session.set('foo', 'bar') + expect(session.get('foo')).toBe('bar') + + const content = readFileSync(path.join(tmpDir, `storage/sessions/${session.id()}.json`), 'utf8') + expect(content).toContain(':') // encrypted string has iv:data + }) + + it('can persist sessions', async () => { + const data = { name: 'string' } + session.set('app', data) + + expect(session.get('app')).toMatchObject(data) + }) + + it('should flush all data', () => { + session.set('x', 1) + session.flush() + const all = session.all() + expect(all).toEqual({}) + }) + }) + + describe('Database Driver', () => { + process.env.APP_KEY = appKey + let session: SessionManager + let driver: DatabaseDriver + const table = 'sessions' + const encryptor = new Encryption() + const sessionId = 'test-session-123' + + beforeAll(async () => { + if (!(await DB.instance().schema.hasTable('sessions'))) { + await DB.instance().schema.createTable(table, (table) => { + table.string('id', 255).primary() + table.bigInteger('user_id').nullable() + table.string('ip_address').nullable() + table.text('user_agent').nullable() + table.text('payload', 'longtext').nullable() + table.integer('last_activity') + }) + } + + driver = new DatabaseDriver(sessionId, table) + session = new SessionManager(ctx, 'database', { table, sessionId }) + }) + + afterAll(async () => { + await DB.instance().schema.dropTableIfExists(table) + }) + + it('should store and retrieve encrypted session data', async () => { + await session.set('app', { data: '123' }) + console.log(await session.get('app')) + + + await driver.set('user', { id: 1, name: 'Legacy' }) + const retrieved = await driver.get('user') + expect(retrieved).toEqual({ id: 1, name: 'Legacy' }) + + const raw = await DB.table(table).where('id', sessionId).first() + expect(raw).toBeTruthy() + expect(typeof raw.payload).toBe('string') + + // Decrypt manually to verify encryption + const decrypted = encryptor.decrypt(raw.payload) + expect(decrypted.user).toEqual({ id: 1, name: 'Legacy' }) + }) + + it('should store multiple values with put()', async () => { + await driver.put({ token: 'abc123', theme: 'dark' }) + const all = await driver.all() + expect(all.token).toBe('abc123') + expect(all.theme).toBe('dark') + }) + + it('should append values with push()', async () => { + await driver.push('logs', 'login') + await driver.push('logs', 'logout') + const all = await driver.all() + expect(all.logs).toEqual(['login', 'logout']) + }) + + it('should forget a key', async () => { + await driver.set('temp', 'should-remove') + await driver.forget('temp') + const all = await driver.all() + expect(all.temp).toBeUndefined() + }) + + it('should flush all data', async () => { + await driver.set('user', 'data') + await driver.flush() + const all = await driver.all() + expect(Object.keys(all).length).toBe(0) + }) + + it('should update last_activity on each save', async () => { + const before = await DB.table(table).where('id', sessionId).first() + const prevActivity = before.last_activity + await new Promise((r) => setTimeout(r, 1000)) + await driver.set('time', Date.now()) + const after = await DB.table(table).where('id', sessionId).first() + expect(after.last_activity).toBeGreaterThan(prevActivity) + }) + }) +}) \ No newline at end of file diff --git a/packages/session/tsconfig.json b/packages/session/tsconfig.json new file mode 100644 index 00000000..8f519188 --- /dev/null +++ b/packages/session/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "paths": { + "@h3ravel/session": ["./src/index.ts"] + } + }, + "exclude": ["dist", "node_modules"] +} diff --git a/packages/shared/src/Contracts/BindingsContract.ts b/packages/shared/src/Contracts/BindingsContract.ts index 5f966bbe..27c897be 100644 --- a/packages/shared/src/Contracts/BindingsContract.ts +++ b/packages/shared/src/Contracts/BindingsContract.ts @@ -1,9 +1,9 @@ import type { H3, HTTPResponse, serve } from 'h3' +import type { HttpContext, IRouter } from './IHttp' import type { Edge } from 'edge.js' import type { IRequest } from './IRequest' import type { IResponse } from './IResponse' -import type { IRouter } from './IHttp' import type { PathLoader } from '../Utils/PathLoader' type RemoveIndexSignature = { @@ -35,6 +35,7 @@ export type Bindings = { 'path.base': string 'load.paths': PathLoader 'http.serve': typeof serve + 'http.context': HttpContext 'http.request': IRequest 'http.response': IResponse } diff --git a/packages/shared/src/Contracts/IHttp.ts b/packages/shared/src/Contracts/IHttp.ts index 11a8481f..f1e527c0 100644 --- a/packages/shared/src/Contracts/IHttp.ts +++ b/packages/shared/src/Contracts/IHttp.ts @@ -1,4 +1,4 @@ -import type { Middleware, MiddlewareOptions } from 'h3' +import type { H3Event, Middleware, MiddlewareOptions } from 'h3' import { IApplication } from './IApplication' import { IRequest } from './IRequest' @@ -141,6 +141,7 @@ export declare class IRouter { */ export declare class HttpContext { app: IApplication + event: H3Event request: IRequest response: IResponse private static contexts: WeakMap diff --git a/packages/validation/tests/config/database.ts b/packages/validation/tests/config/database.ts index 2b96dd32..b93e8070 100644 --- a/packages/validation/tests/config/database.ts +++ b/packages/validation/tests/config/database.ts @@ -12,7 +12,7 @@ export default () => { | */ - default: 'driver', + default: 'mysql', aws_db_host: env('AWS_DB_HOST'), rds_secret_name: env('AWS_RDS_SECRET_NAME'), @@ -51,7 +51,7 @@ export default () => { url: env('DB_URL'), host: env('DB_HOST', '127.0.0.1'), port: env('DB_PORT', '3306'), - database: env('DB_DATABASE', 'H3ravel'), + database: env('DB_DATABASE', 'h3ravel_test'), username: env('DB_USERNAME', 'root'), password: env('DB_PASSWORD', 'password'), unix_socket: env('DB_SOCKET', ''), diff --git a/packages/validation/tests/validator.spec.ts b/packages/validation/tests/validator.spec.ts index 2ba4091a..bda07db9 100644 --- a/packages/validation/tests/validator.spec.ts +++ b/packages/validation/tests/validator.spec.ts @@ -1,9 +1,10 @@ +import { Application, h3ravel } from '@h3ravel/core' +import { ValidationRule, ValidationServiceProvider } from '../src' import { beforeAll, describe, expect, it } from 'vitest' -import RuleContract from 'simple-body-validator/lib/cjs/rules/ruleContract' import { ValidationException } from '../src/ValidationException' -import { ValidationRule } from '../src' import { Validator } from '../src/Validator' +import path from 'node:path' describe('Validator', () => { describe('basic rules', () => { @@ -187,26 +188,31 @@ describe('Validator', () => { }) describe('extended rules', () => { - // let app: Application - beforeAll(async () => { - // const DatabaseServiceProvider = (await import(('@h3ravel/database'))).DatabaseServiceProvider - // const HttpServiceProvider = (await import(('@h3ravel/http'))).HttpServiceProvider - // const ConfigServiceProvider = (await import(('@h3ravel/config'))).ConfigServiceProvider - // app = await h3ravel( - // [HttpServiceProvider, DatabaseServiceProvider, ConfigServiceProvider, ValidationServiceProvider], - // path.join(process.cwd(), 'packages/validation/tests'), - // { - // autoload: false, - // customPaths: { - // config: 'config' - // } - // }) - - // // const { DB } = await import('@h3ravel/database') - // // class User extends Model { - // // } - // console.log(app) + const { DatabaseServiceProvider, DB, Model } = (await import('@h3ravel/database')) + const { HttpServiceProvider } = (await import(('@h3ravel/http'))) + const { ConfigServiceProvider } = (await import(('@h3ravel/config'))) + await h3ravel( + [HttpServiceProvider, DatabaseServiceProvider, ConfigServiceProvider, ValidationServiceProvider], + path.join(process.cwd(), 'packages/validation/tests'), + { + autoload: false, + customPaths: { + config: 'config' + } + }) + + + if (!(await DB.instance().schema.hasTable('users'))) { + await DB.instance().schema.createTable('users', (table: any) => { + table.increments('id') + table.string('username').nullable() + table.timestamps() + }) + } + + class User extends Model { } + await User.query().firstOrCreate({ 'username': 'legacy' }) }) it('includes: should validate included item in the given list of values.', async () => { @@ -249,15 +255,25 @@ describe('Validator', () => { expect(result).toBe(true) }) - // it('exists: the user should exist', async () => { - // const v = new Validator( - // { username: 'legacy' }, - // { username: 'exists:users,username' } - // ) + it('exists: the user should exist', async () => { + const v = new Validator( + { username: 'legacy' }, + { username: 'exists:users,username' } + ) - // const result = await v.passes() - // expect(result).toBe(true) - // }) + const result = await v.passes() + expect(result).toBe(true) + }) + + it('unique: the user should be unique', async () => { + const v = new Validator( + { username: 'kaylah' }, + { username: 'unique:users,username' } + ) + + const result = await v.passes() + expect(result).toBe(true) + }) }) describe('custom rules', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c709335..36349250 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,148 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -catalogs: - default: - '@changesets/cli': - specifier: ^2.29.7 - version: 2.29.7 - '@eslint/js': - specifier: ^9.39.1 - version: 9.39.1 - '@rollup/plugin-run': - specifier: ^3.1.0 - version: 3.1.0 - '@swc/core': - specifier: ^1.15.0 - version: 1.15.0 - '@types/luxon': - specifier: ^3.7.1 - version: 3.7.1 - '@types/nodemailer': - specifier: ^6.4.17 - version: 6.4.17 - '@types/semver': - specifier: ^7.7.1 - version: 7.7.1 - '@typescript-eslint/eslint-plugin': - specifier: ^8.46.3 - version: 8.46.3 - '@typescript-eslint/parser': - specifier: ^8.46.3 - version: 8.46.3 - '@vitest/coverage-v8': - specifier: ^3.2.4 - version: 3.2.4 - argon2: - specifier: ^0.44.0 - version: 0.44.0 - barrelize: - specifier: 1.6.6 - version: 1.6.6 - barrelsby: - specifier: ^2.8.1 - version: 2.8.1 - bcryptjs: - specifier: ^3.0.2 - version: 3.0.2 - cross-env: - specifier: ^10.1.0 - version: 10.1.0 - dayjs: - specifier: ^1.11.18 - version: 1.11.19 - detect-port: - specifier: ^2.1.0 - version: 2.1.0 - dotenv: - specifier: ^17.2.3 - version: 17.2.3 - dotenv-expand: - specifier: ^12.0.3 - version: 12.0.3 - edge.js: - specifier: ^6.3.0 - version: 6.3.0 - escalade: - specifier: ^3.2.0 - version: 3.2.0 - eslint: - specifier: ^9.39.1 - version: 9.39.1 - execa: - specifier: ^9.6.0 - version: 9.6.0 - fast-glob: - specifier: ^3.3.3 - version: 3.3.3 - husky: - specifier: ^9.1.7 - version: 9.1.7 - knex: - specifier: ^3.1.0 - version: 3.1.0 - luxon: - specifier: ^3.7.2 - version: 3.7.2 - mysql2: - specifier: 3.15.3 - version: 3.15.3 - path: - specifier: ^0.12.7 - version: 0.12.7 - preferred-pm: - specifier: ^4.1.1 - version: 4.1.1 - reflect-metadata: - specifier: ^0.2.2 - version: 0.2.2 - resolve-from: - specifier: ^5.0.0 - version: 5.0.0 - rimraf: - specifier: ^6.1.0 - version: 6.1.0 - semver: - specifier: ^7.7.2 - version: 7.7.3 - source-map-support: - specifier: ^0.5.21 - version: 0.5.21 - sqlite3: - specifier: 5.1.7 - version: 5.1.7 - ts-node: - specifier: ^10.9.2 - version: 10.9.2 - tsconfig-paths: - specifier: ^4.2.0 - version: 4.2.0 - tslib: - specifier: ^2.8.1 - version: 2.8.1 - tsx: - specifier: ^4.20.6 - version: 4.20.6 - typescript-eslint: - specifier: ^8.46.3 - version: 8.46.3 - utility-types: - specifier: ^3.11.0 - version: 3.11.0 - vite-tsconfig-paths: - specifier: ^5.1.4 - version: 5.1.4 - prod: - '@h3ravel/arquebus': - specifier: ^0.6.17 - version: 0.6.17 - '@h3ravel/musket': - specifier: ^0.3.12 - version: 0.3.12 - h3: - specifier: 2.0.1-rc.5 - version: 2.0.1-rc.5 - importers: .: @@ -303,6 +161,9 @@ importers: '@h3ravel/router': specifier: workspace:^ version: link:../../packages/router + '@h3ravel/session': + specifier: workspace:^ + version: link:../../packages/session '@h3ravel/shared': specifier: workspace:^ version: link:../../packages/shared @@ -652,6 +513,22 @@ importers: specifier: 'catalog:' version: 0.2.2 + packages/session: + dependencies: + '@h3ravel/core': + specifier: workspace:^ + version: link:../core + '@h3ravel/database': + specifier: workspace:^ + version: link:../database + '@h3ravel/shared': + specifier: workspace:^ + version: link:../shared + devDependencies: + typescript: + specifier: ^5.4.0 + version: 5.9.3 + packages/shared: dependencies: '@inquirer/prompts': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 98ca5efd..c878d320 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,7 @@ packages: - packages/console - packages/view - packages/url + - packages/session - packages/validation - packages/foundation - examples/* diff --git a/session.json b/session.json new file mode 100644 index 00000000..e4983203 --- /dev/null +++ b/session.json @@ -0,0 +1,44 @@ +{ + "/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-consoleY0GJNq": { + "app": "d2cbcb02f5d5b6e34483424313c91384:c71bdf4e64a05bfe64312e0e5ddfbe8a469843b83c3f6e211e814d16f936bca0" + }, + "undefined": { + "app": "405a33924995c66d5f5ac8e257d7ecde:26038d24ee8da49e8342ba903798b9501f487bb5176d8d661c02d2f72fee8855" + }, + "/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-consoleQa6IHi": { + "app": "537eff97b532b0c59d574d39c6eabd16:424a1bc0915b5422f7334fa1c815df04f2e107d5c00651beaab94914d1f10729" + }, + "/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-consoleV6p9tA": { + "app": "70b758a29dfd5583427050addb7b9959:976e4759aed2ab4433d2f65aea7159f5e4edd8077322f08ecfca24fd12be3704" + }, + "/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-consoleQbSHNV": { + "app": "bbecc459e9ac2ce5b70552a628d40466:4023bb635c7ca310f4b4989e60b57a90053c22fa8d1c0238d6e6559516261927" + }, + "/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-console6CNV2G": { + "app": "b0e5cc5fcb49b1c9a419960c160f6fcb:5422505fe868d146e1385dde887b589cbe92a03f3c4e70dcf7bf0b8c8707ada8" + }, + "/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-consolewaSzPj": { + "app": "097ce542bb702a70ad7397519b634a75:a92221d9de6d6a2f1b3ccbddc28ef963f34bbfbc591f4e36be4a4fc2f97a9bd2" + }, + "/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-console93n3bL": { + "app": "a80259af053c508618144aa12a9bd6f8:2e552485be409099f5b6a475ea609082f353097f4468bb648d83b6a9856a7c77" + }, + "/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-consolefQBDjR": { + "app": "7650b4f35446181f2643307e223854b6:8fdfa952dbb8da4fb5e12e02571bab27b8df495b506387e24db509eba000ed55" + }, + "/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-consoleqd6L7Z": { + "app": "c6b0500cca6197e839caf1a52bcae7ce:71c6d4f5b4e880ed764e48f111b94b87fe75256eea7115a56b55ee8d6d6a511d" + }, + "/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-consoleu7j4cd": { + "app": "c9b761213dd59817f2e69063ac41fd0d:d50e0e1ffbe595870ef03403f95ebfb840c54968ae17b31b697c0455351307c9" + }, + "/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-consolejcOBIm": { + "app": "013e44d3949e456c41b6e1bfa9825972:82e021628c5e5113c37ea3194ec0a7a7995af98895cf5014e539fc4a9b091c56" + }, + "/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-consolejj7e1P": { + "app": "494c322d49fd15191af6e92f26fa0d8c:1f27c37de63695a8891b366951715ed1b3145e892a38c402aa448125116c3004" + }, + "/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-consolesaj74r": { + "app": "9b16eee4d5f31cf0a9821479cba9dd2a:83e213e37e1fe4bfd0f222449dbe40b93bd56a2f2897103b32c02859360fcaf1" + } +} \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index b4c9a99a..4f4af95b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,6 +17,7 @@ "@h3ravel/support": ["packages/support/src/index.ts"], "@h3ravel/url": ["packages/url/src/index.ts"], "@h3ravel/view": ["packages/view/src/index.ts"], + "@h3ravel/session": ["packages/session/src/index.ts"], "@h3ravel/foundation": ["packages/foundation/src/index.ts"], "@h3ravel/validation": ["packages/validation/src/index.ts"] }, diff --git a/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/1330775240427184c0c1950560444d5a17dd9205784e94441e80094a166dd1b9.json b/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/1330775240427184c0c1950560444d5a17dd9205784e94441e80094a166dd1b9.json new file mode 100644 index 00000000..ea37b354 --- /dev/null +++ b/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/1330775240427184c0c1950560444d5a17dd9205784e94441e80094a166dd1b9.json @@ -0,0 +1 @@ +a6ce7448e1f2c0035cc675ec9c140c72:607e94b7cad134ce4828b6dee3c8dc1d \ No newline at end of file diff --git a/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/ed95f69be93053a7d27b34c7eaa88afe5f5bbbf7497f314ce13f87b4610d8be0.json b/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/ed95f69be93053a7d27b34c7eaa88afe5f5bbbf7497f314ce13f87b4610d8be0.json new file mode 100644 index 00000000..b2809ef4 --- /dev/null +++ b/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/ed95f69be93053a7d27b34c7eaa88afe5f5bbbf7497f314ce13f87b4610d8be0.json @@ -0,0 +1 @@ +be751fe1ecd1bc6d356b0b13a1d5197d:746d6f3c9c450abd907facd5984ed7dd \ No newline at end of file From ba638c79387918f4c4ca4d682b3b469f0d474ccc Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 12 Nov 2025 21:04:11 +0100 Subject: [PATCH 08/28] feat: add session() method to request class. --- examples/basic-app/src/config/session.ts | 216 ++++++++++++++ examples/basic-app/src/routes/web.ts | 2 + ...680fe7e6830bf068a4e7c90c4716a5cea61ff47d40 | 1 + packages/core/src/ProviderRegistry.ts | 55 ++-- packages/http/package.json | 1 + packages/http/src/HttpContext.ts | 6 +- packages/http/src/Request.ts | 39 ++- packages/http/src/Response.ts | 7 +- packages/http/src/Utilities/HttpRequest.ts | 12 +- packages/http/src/app.globals.d.ts | 16 ++ packages/http/tests/Request.spec.ts | 70 +++-- packages/http/tests/config/session.ts | 216 ++++++++++++++ .../src/Commands/MakeSessionTableCommand.ts | 36 +++ .../session/src/Contracts/SessionContract.ts | 137 ++++++++- .../src/Providers/SessionServiceProvider.ts | 4 + packages/session/src/SessionManager.ts | 204 +++++++++++-- .../session/src/drivers/DatabaseDriver.ts | 203 ++++++++++++- packages/session/src/drivers/Driver.ts | 271 ++++++++++++++++++ packages/session/src/drivers/FileDriver.ts | 86 ++---- packages/session/src/drivers/MemoryDriver.ts | 49 ++-- packages/session/src/index.ts | 2 + packages/session/tests/config/session.ts | 216 ++++++++++++++ packages/session/tests/database.spec.ts | 217 ++++++++++++++ packages/session/tests/file.spec.ts | 168 +++++++++++ packages/session/tests/memory.spec.ts | 178 ++++++++++++ packages/session/tests/session.spec.ts | 271 +++++++----------- packages/shared/src/Contracts/IRequest.ts | 18 +- packages/shared/src/Contracts/IResponse.ts | 5 + .../shared/src/Contracts/ISessionManager.ts | 153 ++++++++++ packages/shared/src/index.ts | 1 + packages/support/src/Helpers/Obj.ts | 2 +- packages/validation/tests/validator.spec.ts | 25 +- pnpm-lock.yaml | 145 ++++++++++ 33 files changed, 2654 insertions(+), 378 deletions(-) create mode 100644 examples/basic-app/src/config/session.ts create mode 100644 examples/basic-app/storage/framework/sessions/9deee7bcd458edd613c199680fe7e6830bf068a4e7c90c4716a5cea61ff47d40 create mode 100644 packages/http/tests/config/session.ts create mode 100644 packages/session/src/Commands/MakeSessionTableCommand.ts create mode 100644 packages/session/src/drivers/Driver.ts create mode 100644 packages/session/tests/config/session.ts create mode 100644 packages/session/tests/database.spec.ts create mode 100644 packages/session/tests/file.spec.ts create mode 100644 packages/session/tests/memory.spec.ts create mode 100644 packages/shared/src/Contracts/ISessionManager.ts diff --git a/examples/basic-app/src/config/session.ts b/examples/basic-app/src/config/session.ts new file mode 100644 index 00000000..5feb4db4 --- /dev/null +++ b/examples/basic-app/src/config/session.ts @@ -0,0 +1,216 @@ +import { Str } from '@h3ravel/support' + +export default () => { + return { + /* + |-------------------------------------------------------------------------- + | Default Session Driver + |-------------------------------------------------------------------------- + | + | This option determines the default session driver that is utilized for + | incoming requests. H3ravel supports a variety of storage options to + | persist session data. Database storage is a great default choice. + | + | Supported: "file", "database", "memory", + | WIP : "apc", "cookie", "memcached", "redis", "dynamodb" + | + */ + + driver: env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + lifetime: env('SESSION_LIFETIME', 120), + + expire_on_close: env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by H3ravel and you may use the session like normal. + | + */ + + encrypt: env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + files: storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + connection: env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + table: env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "apc", "dynamodb", "memcached", "redis" + | + */ + + store: env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + lottery: [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + | + */ + + cookie: env( + 'SESSION_COOKIE', + Str.slug(env('APP_NAME', 'h3ravel'), '_') + '_session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + path: env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain and all subdomains. Typically, this shouldn't be changed. + | + */ + + domain: env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + secure: env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + http_only: env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + same_site: env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + partitioned: env('SESSION_PARTITIONED_COOKIE', false), + } +} diff --git a/examples/basic-app/src/routes/web.ts b/examples/basic-app/src/routes/web.ts index ef4be410..7b626d4d 100644 --- a/examples/basic-app/src/routes/web.ts +++ b/examples/basic-app/src/routes/web.ts @@ -30,6 +30,8 @@ export default (Route: Router) => { age: ['required', 'integer'], }) + session({ data }) + console.log(await request.session().all(), request.session().only(['data']), session('data.age')) return response .setStatusCode(202) .json({ diff --git a/examples/basic-app/storage/framework/sessions/9deee7bcd458edd613c199680fe7e6830bf068a4e7c90c4716a5cea61ff47d40 b/examples/basic-app/storage/framework/sessions/9deee7bcd458edd613c199680fe7e6830bf068a4e7c90c4716a5cea61ff47d40 new file mode 100644 index 00000000..2d462871 --- /dev/null +++ b/examples/basic-app/storage/framework/sessions/9deee7bcd458edd613c199680fe7e6830bf068a4e7c90c4716a5cea61ff47d40 @@ -0,0 +1 @@ +4ae7662b3a5e06b9fc2442dc640645d8:e6b5da57bab89b8563c205413821d27a1981e3829e2c8062f862be88c6a49aac19ac220df63dc4c87813a0d3e148eee58b4f3d950d471325d043ffc19ad8d6a2 \ No newline at end of file diff --git a/packages/core/src/ProviderRegistry.ts b/packages/core/src/ProviderRegistry.ts index 2ed2c547..36f9f5da 100644 --- a/packages/core/src/ProviderRegistry.ts +++ b/packages/core/src/ProviderRegistry.ts @@ -117,44 +117,43 @@ export class ProviderRegistry { * @returns */ static sort (providers: ProviderCtor[]) { - /** - * Base priority (default 0) - */ - providers.forEach((Provider) => { - const key = this.getKey(Provider) - this.priorityMap.set(`${Provider.name}::${key}`, (Provider as any).priority ?? 0) - }) + const makeKey = (Provider: ProviderCtor) => `${Provider.name}::${this.getKey(Provider)}` + + // Step 1: Sort purely by priority (descending) + providers.sort((A, B) => ((B as any).priority ?? 0) - ((A as any).priority ?? 0)) + + // Step 2: Apply order overrides ("before:" / "after:") + const findIndex = (target: string) => { + if (target.includes('::')) { + return providers.findIndex(p => makeKey(p) === target) + } + return providers.findIndex(p => p.name === target) + } - /** - * Handle before/after adjustments - */ providers.forEach((Provider) => { const order = (Provider as any).order if (!order) return - const [direction, target] = order.split(':') - const targetPriority = this.priorityMap.get(target) ?? 0 - const key = this.getKey(Provider) + const [direction, rawTarget] = order.split(':') + const targetIndex = findIndex(rawTarget) + if (targetIndex === -1) return - if (direction === 'before') { - this.priorityMap.set(`${Provider.name}::${key}`, targetPriority - 1) - } else if (direction === 'after') { - this.priorityMap.set(`${Provider.name}::${key}`, targetPriority + 1) - } + const currentIndex = providers.indexOf(Provider) + if (currentIndex === -1) return + + // Remove and reinsert at correct spot + providers.splice(currentIndex, 1) + const insertIndex = direction === 'before' + ? targetIndex + : targetIndex + 1 + + providers.splice(insertIndex, 0, Provider) }) - /** - * Return service providers sorted based on thier name and priority - */ - return providers.sort( - (A, B) => { - const keyA = this.getKey(A) - const keyB = this.getKey(B) - return (this.priorityMap.get(`${B.name}::${keyB}`) ?? 0) - (this.priorityMap.get(`${A.name}::${keyA}`) ?? 0) - } - ) + return providers } + /** * Sort service providers */ diff --git a/packages/http/package.json b/packages/http/package.json index 4ec36733..9a1e3b66 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -59,6 +59,7 @@ "@h3ravel/support": "workspace:^", "@h3ravel/musket": "catalog:prod", "@h3ravel/shared": "workspace:^", + "@h3ravel/session": "workspace:^", "@h3ravel/validation": "workspace:^", "@h3ravel/url": "workspace:^", "h3": "catalog:prod", diff --git a/packages/http/src/HttpContext.ts b/packages/http/src/HttpContext.ts index 9c5429c4..f55c8d4c 100644 --- a/packages/http/src/HttpContext.ts +++ b/packages/http/src/HttpContext.ts @@ -7,7 +7,7 @@ import type { H3Event } from 'h3' */ export class HttpContext implements IHttpContext { private static contexts = new WeakMap() - public event?: H3Event + public event!: H3Event constructor( public app: IApplication, @@ -26,7 +26,9 @@ export class HttpContext implements IHttpContext { } const instance = new HttpContext(ctx.app, ctx.request, ctx.response) - instance.event = event + instance.event = event! + ctx.request.context = instance + ctx.response.context = instance if (event) { HttpContext.contexts.set(event, instance) diff --git a/packages/http/src/Request.ts b/packages/http/src/Request.ts index 6d36b008..0afe3201 100644 --- a/packages/http/src/Request.ts +++ b/packages/http/src/Request.ts @@ -1,6 +1,6 @@ import { getRequestIP, type H3Event } from 'h3' import { Arr, data_get, data_set, Obj, safeDot, Str } from '@h3ravel/support' -import type { DotNestedKeys, DotNestedValue } from '@h3ravel/shared' +import type { DotNestedKeys, DotNestedValue, ISessionManager } from '@h3ravel/shared' import { IRequest } from '@h3ravel/shared' import { Application } from '@h3ravel/core' import { RequestMethod, RequestObject } from '@h3ravel/shared' @@ -58,6 +58,7 @@ export class Request< await instance.setBody() await instance.initialize() globalThis.request = () => instance + globalThis.session = (...args: []) => instance.session(...args) return instance } @@ -337,6 +338,42 @@ export class Request< return [...Object.keys(this.input()), ...this.files.keys()] } + /** + * Get an instance of the current session manager + * + * @param key + * @param defaultValue + * @returns a global instance of the current session manager. + */ + public session | undefined = undefined> (key?: K, defaultValue?: any): K extends undefined + ? ISessionManager + : K extends string + ? any : void | Promise { + const session = new this.sessionManagerClass( + this.context, + config('session.driver', 'file'), + { + cwd: config('session.files'), + sessionDir: '/', + dir: '/', + table: config('session.table'), + prefix: config('database.connections.redis.options.prefix'), + client: config(`database.connections.${config('session.driver', 'file')}.client`), + } + ) + + if (typeof key === 'string') { + return session.get(key, defaultValue) + } else if (typeof key === 'object') { + for (const [k, val] of Object.entries(key)) { + session.put(k, val) + } + return undefined as any + } + + return session as any + } + /** * Determine if the request is sending JSON. * diff --git a/packages/http/src/Response.ts b/packages/http/src/Response.ts index 6359ab8f..9c388b98 100644 --- a/packages/http/src/Response.ts +++ b/packages/http/src/Response.ts @@ -1,4 +1,4 @@ -import type { DotNestedKeys, DotNestedValue } from '@h3ravel/shared' +import type { DotNestedKeys, DotNestedValue, HttpContext } from '@h3ravel/shared' import { type H3Event, HTTPResponse } from 'h3' import { Application } from '@h3ravel/core' @@ -7,6 +7,11 @@ import { IResponse } from '@h3ravel/shared' import { safeDot } from '@h3ravel/support' export class Response extends HttpResponse implements IResponse { + /** + * The current Http Context + */ + context!: HttpContext + constructor( /** * The current H3 H3Event instance diff --git a/packages/http/src/Utilities/HttpRequest.ts b/packages/http/src/Utilities/HttpRequest.ts index 768f7dc9..9aa10ab6 100644 --- a/packages/http/src/Utilities/HttpRequest.ts +++ b/packages/http/src/Utilities/HttpRequest.ts @@ -1,6 +1,6 @@ import { getQuery, getRequestURL, getRouterParams, parseCookies, type H3Event } from 'h3' import { Application } from '@h3ravel/core' -import { RequestMethod } from '@h3ravel/shared' +import { ISessionManager, RequestMethod } from '@h3ravel/shared' import { SuspiciousOperationException } from '../Exceptions/SuspiciousOperationException' import { InputBag } from '../Utilities/InputBag' import { HeaderBag } from '../Utilities/HeaderBag' @@ -13,6 +13,7 @@ import { HeaderUtility } from './HeaderUtility' import { IpUtils } from './IpUtils' import { ConflictingHeadersException } from '../Exceptions/ConflictingHeadersException' import { isIP } from 'node:net' +import { HttpContext } from '../HttpContext' export class HttpRequest { public HEADER_FORWARDED = 0b000001 // When using RFC 7239 @@ -112,6 +113,11 @@ export class HttpRequest { */ public cookies!: InputBag + /** + * The current Http Context + */ + context!: HttpContext + /** * The request attributes (parameters parsed from the PATH_INFO, ...). */ @@ -131,6 +137,8 @@ export class HttpRequest { protected static httpMethodParameterOverride: boolean = false + protected sessionManagerClass!: typeof ISessionManager + /** * List of Acceptable Content Types */ @@ -180,6 +188,8 @@ export class HttpRequest { this.#method = undefined this.format = undefined this.#uri = (await import(String('@h3ravel/url'))).Url.of(getRequestURL(this.event).toString(), this.app) + + this.sessionManagerClass = (await import(('@h3ravel/session'))).SessionManager } /** diff --git a/packages/http/src/app.globals.d.ts b/packages/http/src/app.globals.d.ts index d942b25b..97c2002a 100644 --- a/packages/http/src/app.globals.d.ts +++ b/packages/http/src/app.globals.d.ts @@ -1,14 +1,30 @@ +import { ISessionManager } from '@h3ravel/shared' import { Request, Response } from '.' export { } declare global { /** + * Get an instance of the Request class + * * @returns a global instance of the Request class. */ function request (): Request /** + * Get an instance of the Response class + * * @returns a global instance of the Response class. */ function response (): Response + /** + * Get an instance of the current session manager + * + * @param key + * @param defaultValue + * @returns a global instance of the current session manager. + */ + function session | undefined = undefined> (key?: K, defaultValue?: any): K extends undefined + ? ISessionManager + : K extends string + ? any : void | Promise } diff --git a/packages/http/tests/Request.spec.ts b/packages/http/tests/Request.spec.ts index 4d3d7e9e..98831d32 100644 --- a/packages/http/tests/Request.spec.ts +++ b/packages/http/tests/Request.spec.ts @@ -1,10 +1,13 @@ -import { beforeEach, describe, expect, it, test, vi } from 'vitest' +import { Application, h3ravel } from '@h3ravel/core' // if this exists +import { HttpContext, Response } from '@h3ravel/http' +import { beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest' -import { Application } from '@h3ravel/core' // if this exists +import { HttpServiceProvider } from '../src/Providers/HttpServiceProvider' import { InputBag } from '../src/Utilities/InputBag' import { ParamBag } from '../src/Utilities/ParamBag' import { Request } from '../src/Request' import { UploadedFile } from '../src/UploadedFile' +import path from 'node:path' // ---- mocks: FormRequest and UploadedFile.createFromBase ---- // The Request class uses FormRequest and UploadedFile.createFromBase. @@ -50,13 +53,7 @@ vi.spyOn(UploadedFile, 'createFromBase' as any).mockImplementation((fileBase: an // ---- helper to craft a fake H3Event ---- function makeEvent (overrides: Partial = {}) { // minimal header map that implements .get and .entries() - const headersMap = new Map(Object.entries((overrides.headers as Record) || {})) - const headers = { - get: (k: string) => headersMap.get(k.toLowerCase()) ?? headersMap.get(k) ?? null, - entries: () => headersMap.entries(), - // keep an iterator too - [Symbol.iterator]: () => headersMap[Symbol.iterator](), - } + const headers = new Headers((overrides.headers as Record) || {}) const req = { method: (overrides.method || 'GET'), @@ -71,6 +68,7 @@ function makeEvent (overrides: Partial = {}) { const event: any = { req, + res: { headers: new Headers() }, context: { params: overrides.params || {}, } @@ -82,24 +80,30 @@ function makeEvent (overrides: Partial = {}) { return event as any } +const appKey = 'base64:dnZm+Ei7ExEHzhj/wO/3YKUckMQtpLjRVk1VLYiV/es=' const TestFile = new File([Buffer.from('TestFile')], 'a.png') const TestUpload = UploadedFile.createFromBase(TestFile) -// Minimal Application stub if you don't have real Application importable in test env. -// If you DO have a real Application class you can remove this stub and use the real one. -class AppStub implements Partial { - basePath = process.cwd() - make () { return undefined } - fire () { return undefined as never } -} - -// ---- TESTS ---- - describe('Request', () => { - let app: any + let app: Application + process.env.APP_KEY = appKey + + beforeEach(async () => { + const { SessionServiceProvider } = (await import(('@h3ravel/session'))) + const { ConfigServiceProvider } = (await import(('@h3ravel/config'))) + const { RouteServiceProvider } = (await import(('@h3ravel/router'))) + app = await h3ravel( + [SessionServiceProvider, HttpServiceProvider, ConfigServiceProvider, RouteServiceProvider], + path.join(process.cwd(), 'packages/http/tests'), + { + autoload: false, + customPaths: { + config: 'config', + routes: 'routes', + } + }) + app.make('config') - beforeEach(() => { - app = new AppStub() vi.restoreAllMocks() // restore in case any global spy persists }) @@ -395,5 +399,27 @@ describe('Request', () => { expect(request()).toBe(req) expect(request()).toBeInstanceOf(Request) }) + + // describe('Request', () => { + // it('session() has access to session', async () => { + // const event = makeEvent({ + // method: 'POST', + // headers: new Headers({ 'content-type': 'application/json' }), + // json: async () => ({ nested: { x: 'y' } }) + // }) + // const ctx = HttpContext.init({ + // app, + // request: await Request.create(event, app), + // response: new Response(event, app), + // }, event) + + + // const jsonBag = ctx.request.session({}) + // console.log(jsonBag) + // // expect(jsonBag.get('nested.x')).toBe('y') + // // subsequent calls return same InputBag instance + // // expect(req.json()).toBe(jsonBag) + // }) + // }) }) diff --git a/packages/http/tests/config/session.ts b/packages/http/tests/config/session.ts new file mode 100644 index 00000000..28106052 --- /dev/null +++ b/packages/http/tests/config/session.ts @@ -0,0 +1,216 @@ +import { Str } from '@h3ravel/support' + +export default () => { + return { + /* + |-------------------------------------------------------------------------- + | Default Session Driver + |-------------------------------------------------------------------------- + | + | This option determines the default session driver that is utilized for + | incoming requests. H3ravel supports a variety of storage options to + | persist session data. Database storage is a great default choice. + | + | Supported: "file", "database", "memory", + | WIP : "apc", "cookie", "memcached", "redis", "dynamodb" + | + */ + + driver: env('SESSION_DRIVER', 'file'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + lifetime: env('SESSION_LIFETIME', 120), + + expire_on_close: env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by H3ravel and you may use the session like normal. + | + */ + + encrypt: env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + files: storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + connection: env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + table: env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "apc", "dynamodb", "memcached", "redis" + | + */ + + store: env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + lottery: [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + | + */ + + cookie: env( + 'SESSION_COOKIE', + Str.slug(env('APP_NAME', 'h3ravel'), '_') + '_session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + path: env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain and all subdomains. Typically, this shouldn't be changed. + | + */ + + domain: env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + secure: env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + http_only: env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + same_site: env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + partitioned: env('SESSION_PARTITIONED_COOKIE', false), + } +} diff --git a/packages/session/src/Commands/MakeSessionTableCommand.ts b/packages/session/src/Commands/MakeSessionTableCommand.ts new file mode 100644 index 00000000..779b5a0d --- /dev/null +++ b/packages/session/src/Commands/MakeSessionTableCommand.ts @@ -0,0 +1,36 @@ +import { Command } from '@h3ravel/musket' +import { DB } from '@h3ravel/database' + +export class MakeSessionTableCommand extends Command { + + /** + * The name and signature of the console command. + * + * @var string + */ + protected signature: string = 'make:session-table' + + /** + * The console command description. + * + * @var string + */ + protected description: string = 'Create a migration for the session database table' + + public async handle () { + await DB.instance().schema.hasTable('sessions').then(async function (exists) { + if (!exists) { + return DB.instance().schema.createTable('sessions', (table) => { + table.string('id', 255).primary() + table.bigInteger('user_id').nullable().index() + table.string('ip_address', 45).nullable() + table.text('user_agent').nullable() + table.text('payload', 'longtext').nullable() + table.integer('last_activity').index() + }) + } + }) + + this.info('INFO: session table created successfully.') + } +} diff --git a/packages/session/src/Contracts/SessionContract.ts b/packages/session/src/Contracts/SessionContract.ts index 8a1aa8ea..1091beef 100644 --- a/packages/session/src/Contracts/SessionContract.ts +++ b/packages/session/src/Contracts/SessionContract.ts @@ -2,43 +2,154 @@ * SessionDriver Interface * * All session drivers must implement these methods to ensure - * consistency across different storage mechanisms (memory, file, db, redis). + * consistency across different storage mechanisms (memory, files, database, redis). */ export interface SessionDriver { /** * Retrieve a value from the session by key. + * + * @param key + * @param defaultValue */ - get (key: string): any | Promise + get (key: string, defaultValue?: any): any | Promise + + /** + * Store multiple values in the session. + * + * @param key + * @param defaultValue + */ + set (value: Record): void | Promise /** * Store a value in the session. + * + * @param key + * @param value + */ + put (key: string, value: any): void | Promise + + /** + * Append a value to an array key + * + * @param key + * @param value + */ + push (key: string, value: any): Promise | void + + /** + * Remove a key from the session. + * + * @param key + */ + forget (key: string): Promise | void + + /** + * Determine if a key is present in the session. + * + * @param key + */ + has (key: string): Promise | boolean + + /** + * Determine if a key exists in the session (even if null). + * + * @param key + */ + exists (key: string): Promise | boolean + + /** + * Get all data from the session. + */ + all (): Promise> | Record + + /** + * Get only a subset of session keys. + * + * @param keys */ - set (key: string, value: any): void | Promise + only (keys: string[]): Promise> | Record /** - * Store multiple key/value pairs in the session. + * Get all session data except the specified keys. + * + * @param keys */ - put (data: Record): void | Promise + except (keys: string[]): Promise> | Record /** - * Append a value to an array in the session. + * Get and remove an item from the session. + * + * @param key + * @param defaultValue */ - push (key: string, value: any): void | Promise + pull (key: string, defaultValue?: any): Promise | any /** - * Remove an item from the session by key. + * Increment a numeric session value. + * + * @param key + * @param amount */ - forget (key: string): void | Promise + increment (key: string, amount?: number): Promise | number /** - * Retrieve all session data. + * Decrement a numeric session value. + * + * @param key + * @param amount */ - all (): Record | Promise> + decrement (key: string, amount?: number): Promise | number /** - * Clear all session data. + * Flash a key/value pair for the next request only. + * + * @param key + * @param value + */ + flash (key: string, value: any): Promise | void + + /** + * Reflash all current flash data for another request cycle. + */ + reflash (): Promise | void + + /** + * Keep only specific flash data for another request. + * + * @param keys + */ + keep (keys: string[]): Promise | void + + /** + * Store data for the current request only (not persisted). + * + * @param key + * @param value + */ + now (key: string, value: any): Promise | void + + /** + * Regenerate the session ID and optionally persist the data. + */ + regenerate (): Promise | void + + /** + * Invalidate the session completely and regenerate ID. + */ + invalidate (): Promise | void + + /** + * Determine if an item is not present in the session. + * + * @param key + */ + missing (key: string): Promise | boolean + + /** + * Flush all session data */ - flush (): void | Promise + flush (): Promise | void } export interface DriverOption { diff --git a/packages/session/src/Providers/SessionServiceProvider.ts b/packages/session/src/Providers/SessionServiceProvider.ts index d418ee5f..99de6ec4 100644 --- a/packages/session/src/Providers/SessionServiceProvider.ts +++ b/packages/session/src/Providers/SessionServiceProvider.ts @@ -1,10 +1,12 @@ import { dbBuilder, fileBuilder, memoryBuilder, redisBuilder } from '../adapters' +import { MakeSessionTableCommand } from '../Commands/MakeSessionTableCommand' import { SessionStore } from '../SessionStore' export class SessionServiceProvider { public registeredCommands?: (new (app: any, kernel: any) => any)[] public static priority = 895 + public static order = 'before:HttpServiceProvider' constructor(private app: any) { } @@ -16,6 +18,8 @@ export class SessionServiceProvider { SessionStore.register('database', dbBuilder) SessionStore.register('memory', memoryBuilder) SessionStore.register('redis', redisBuilder) + + this.registeredCommands = [MakeSessionTableCommand] } boot (): void { diff --git a/packages/session/src/SessionManager.ts b/packages/session/src/SessionManager.ts index 573413ae..8685640a 100644 --- a/packages/session/src/SessionManager.ts +++ b/packages/session/src/SessionManager.ts @@ -1,5 +1,5 @@ import { DriverOption, SessionDriver } from './Contracts/SessionContract' -import { HttpContext, IRequest } from '@h3ravel/shared' +import { HttpContext, IRequest, ISessionManager } from '@h3ravel/shared' import { createHash, createHmac, randomBytes } from 'crypto' import { getCookie, setCookie } from 'h3' @@ -11,7 +11,7 @@ import { SessionStore } from './SessionStore' * Handles session initialization, ID generation, and encryption. * Each request gets a unique session namespace tied to its ID. */ -export class SessionManager { +export class SessionManager implements ISessionManager { private driver: SessionDriver private appKey: string private sessionId: string @@ -73,33 +73,205 @@ export class SessionManager { } /** - * Proxy session methods directly to the driver. + * Retrieve a value from the session + * + * @param key + * @param defaultValue + * @returns */ - public get (key: string): any | Promise { - return this.driver.get(key) + get (key: string, defaultValue?: any): Promise | any { + return this.driver.get(key, defaultValue) } - public set (key: string, value: any): void | Promise { - this.driver.set(key, value) + /** + * Store a value in the session + * + * @param key + * @param value + */ + set (value: Record): Promise | void { + return this.driver.set(value) } - public put (data: Record): void | Promise { - this.driver.put(data) + /** + * Store multiple key/value pairs + * + * @param values + */ + put (key: string, value: any): void | Promise { + return this.driver.put(key, value) } - public push (key: string, value: any): void | Promise { - this.driver.push(key, value) + /** + * Append a value to an array key + * + * @param key + * @param value + */ + push (key: string, value: any): void | Promise { + return this.driver.push(key, value) } - public forget (key: string): void | Promise { - this.driver.forget(key) + /** + * Remove a key from the session + * + * @param key + */ + forget (key: string) { + return this.driver.forget(key) } - public all (): Record | Promise> { + /** + * Retrieve all session data + * + * @returns + */ + all () { return this.driver.all() } - public flush (): void | Promise { - this.driver.flush() + /** + * Determine if a key exists (even if null). + * + * @param key + * @returns + */ + exists (key: string): Promise | boolean { + return this.driver.exists(key) + } + + /** + * Determine if a key has a non-null value. + * + * @param key + * @returns + */ + has (key: string): Promise | boolean { + return this.driver.has(key) + } + + /** + * Get only specific keys. + * + * @param keys + * @returns + */ + only (keys: string[]) { + return this.driver.only(keys) + } + + /** + * Return all keys except the specified ones. + * + * @param keys + * @returns + */ + except (keys: string[]) { + return this.driver.except(keys) + } + + /** + * Return and delete a key from the session. + * + * @param key + * @param defaultValue + * @returns + */ + pull (key: string, defaultValue: any = null) { + return this.driver.pull(key, defaultValue) + } + + /** + * Increment a numeric value by amount (default 1). + * + * @param key + * @param amount + * @returns + */ + increment (key: string, amount = 1): Promise | number { + return this.driver.increment(key, amount) + } + + /** + * Decrement a numeric value by amount (default 1). + * + * @param key + * @param amount + * @returns + */ + decrement (key: string, amount = 1) { + return this.driver.decrement(key, amount) + } + + /** + * Flash a value for next request only. + * + * @param key + * @param value + */ + flash (key: string, value: any) { + return this.driver.flash(key, value) + } + + /** + * Reflash all flash data for one more cycle. + * + * @returns + */ + reflash () { + return this.driver.reflash() + } + + /** + * Keep only selected flash data. + * + * @param keys + * @returns + */ + keep (keys: string[]) { + return this.driver.keep(keys) + } + + /** + * Store data only for current request cycle (not persisted). + * + * @param key + * @param value + */ + now (key: string, value: any) { + return this.driver.now(key, value) + } + + /** + * Regenerate session ID and persist data under new ID. + */ + regenerate () { + return this.driver.regenerate() + } + + /** + * Determine if an item is not present in the session. + * + * @param key + * @returns + */ + missing (key: string): Promise | boolean { + return this.driver.missing(key) + } + + /** + * Flush all session data + */ + flush () { + return this.driver.flush() + } + + /** + * Invalidate the session completely and regenerate ID. + * + * @returns + */ + invalidate () { + return this.driver.invalidate() } } diff --git a/packages/session/src/drivers/DatabaseDriver.ts b/packages/session/src/drivers/DatabaseDriver.ts index c35603bc..d22e4905 100644 --- a/packages/session/src/drivers/DatabaseDriver.ts +++ b/packages/session/src/drivers/DatabaseDriver.ts @@ -1,5 +1,7 @@ +import { safeDot, setNested } from '@h3ravel/support' + import { DB } from '@h3ravel/database' -import { Encryption } from '../Encryption' +import { Driver } from './Driver' import { SessionDriver } from '../Contracts/SessionContract' /** @@ -8,16 +10,16 @@ import { SessionDriver } from '../Contracts/SessionContract' * Stores sessions in a database table. Each session ID maps to a row. * The `payload` column contains all session key/value pairs as JSON. */ -export class DatabaseDriver implements SessionDriver { - private encryptor = new Encryption() - +export class DatabaseDriver extends Driver implements SessionDriver { constructor( /** * The current session ID */ - private sessionId: string, + protected sessionId: string, private table: string = 'sessions' - ) { } + ) { + super() + } /** * Helper: get the query builder for this table. @@ -31,7 +33,7 @@ export class DatabaseDriver implements SessionDriver { * * @returns */ - private async fetchPayload (): Promise> { + protected async fetchPayload (): Promise> { const row = await this.query().first() if (!row) return {} @@ -52,7 +54,7 @@ export class DatabaseDriver implements SessionDriver { * * @param payload */ - private async savePayload (payload: Record) { + protected async savePayload (payload: Record) { const now = Math.floor(Date.now() / 1000) const exists = await this.query().exists() const encrypted = this.encryptor.encrypt(JSON.stringify(payload)) @@ -75,11 +77,12 @@ export class DatabaseDriver implements SessionDriver { * Retrieve a value from the session * * @param key + * @param defaultValue * @returns */ - async get (key: string): Promise { + async get (key: string, defaultValue?: any): Promise { const payload = await this.fetchPayload() - return payload[key] + return safeDot(payload, key) || defaultValue } /** @@ -88,9 +91,9 @@ export class DatabaseDriver implements SessionDriver { * @param key * @param value */ - async set (key: string, value: any): Promise { + async set (value: Record): Promise { const payload = await this.fetchPayload() - payload[key] = value + Object.assign(payload, value) await this.savePayload(payload) } @@ -99,9 +102,9 @@ export class DatabaseDriver implements SessionDriver { * * @param values */ - async put (values: Record): Promise { + async put (key: string, value: any): Promise { const payload = await this.fetchPayload() - Object.assign(payload, values) + setNested(payload, key, value) await this.savePayload(payload) } @@ -138,10 +141,182 @@ export class DatabaseDriver implements SessionDriver { return await this.fetchPayload() } + /** + * Determine if a key exists (even if null). + * + * @param key + * @returns + */ + async exists (key: string) { + const data = await this.fetchPayload() + return Object.prototype.hasOwnProperty.call(data, key) + } + + /** + * Determine if a key has a non-null value. + * + * @param key + * @returns + */ + async has (key: string) { + const data = await this.fetchPayload() + return data[key] !== undefined && data[key] !== null + } + + /** + * Get only specific keys. + * + * @param keys + * @returns + */ + async only (keys: string[]) { + const data = await this.fetchPayload() + const result: Record = {} + keys.forEach(k => { + if (k in data) result[k] = data[k] + }) + return result + } + + /** + * Return all keys except the specified ones. + * + * @param keys + * @returns + */ + async except (keys: string[]) { + const data = await this.fetchPayload() + keys.forEach(k => delete data[k]) + return data + } + + /** + * Return and delete a key from the session. + * + * @param key + * @param defaultValue + * @returns + */ + async pull (key: string, defaultValue: any = null) { + const data = await this.fetchPayload() + const value = data[key] ?? defaultValue + delete data[key] + await this.savePayload(data) + return value + } + + /** + * Increment a numeric value by amount (default 1). + * + * @param key + * @param amount + * @returns + */ + async increment (key: string, amount = 1) { + const data = await this.fetchPayload() + const newVal = (parseFloat(data[key]) || 0) + amount + data[key] = newVal + await this.savePayload(data) + return newVal + } + + /** + * Decrement a numeric value by amount (default 1). + * + * @param key + * @param amount + * @returns + */ + async decrement (key: string, amount = 1) { + return this.increment(key, -amount) + } + + /** + * Flash a value for next request only. + * + * @param key + * @param value + */ + async flash (key: string, value: any) { + const data = await this.fetchPayload() + data._flash = data._flash || {} + data._flash[key] = value + await this.savePayload(data) + } + + /** + * Reflash all flash data for one more cycle. + * + * @returns + */ + async reflash () { + const data = await this.fetchPayload() + if (!data._flash) return + data._flash_keep = { ...data._flash } + await this.savePayload(data) + } + + /** + * Keep only selected flash data. + * + * @param keys + * @returns + */ + async keep (keys: string[]) { + const data = await this.fetchPayload() + if (!data._flash) return + const kept: Record = {} + keys.forEach(k => { + if (data._flash[k]) kept[k] = data._flash[k] + }) + data._flash_keep = kept + await this.savePayload(data) + } + + /** + * Store data only for current request cycle (not persisted). + * + * @param key + * @param value + */ + async now (key: string, value: any) { + // Not persisted to DB — use in-memory only. + ; (global as any).__session_now = (global as any).__session_now || {} + ; (global as any).__session_now[key] = value + } + + /** + * Regenerate session ID and persist data under new ID. + */ + async regenerate () { + const oldData = await this.fetchPayload() + this.sessionId = crypto.randomUUID() + await this.savePayload(oldData) + } + + /** + * Determine if an item is not present in the session. + * + * @param key + * @returns + */ + async missing (key: string): Promise { + return !(await this.exists(key)) + } + /** * Flush all session data */ async flush (): Promise { await this.savePayload({}) } + + /** + * Invalidate session completely and regenerate empty session. + */ + async invalidate () { + await DB.table(this.table).where('id', this.sessionId).delete() + this.sessionId = crypto.randomUUID() + await this.savePayload({}) + } } \ No newline at end of file diff --git a/packages/session/src/drivers/Driver.ts b/packages/session/src/drivers/Driver.ts new file mode 100644 index 00000000..bc582836 --- /dev/null +++ b/packages/session/src/drivers/Driver.ts @@ -0,0 +1,271 @@ +import { safeDot, setNested } from 'packages/support/dist' + +import { Encryption } from '../Encryption' +import { SessionDriver } from '../Contracts/SessionContract' + +/** + * Driver + * + * Base Session driver. + */ +export abstract class Driver implements SessionDriver { + protected encryptor = new Encryption() + protected sessionId!: string + + /** + * Invalidate session completely and regenerate empty session. + */ + public abstract invalidate (): void + + /** + * Fetch current payload + * + * @returns + */ + protected abstract fetchPayload (): Record + + /** + * Save updated payload + * + * @param payload + */ + protected abstract savePayload (payload: Record): void + + /** + * Retrieve a value from the session + * + * @param key + * @param defaultValue + * @returns + */ + get (key: string, defaultValue?: any): Promise | any { + const payload = this.fetchPayload() as Record + return safeDot(payload, key) || defaultValue + } + + /** + * Store a value in the session + * + * @param key + * @param value + */ + set (value: Record): Promise | void { + const payload = this.fetchPayload() + Object.assign(payload, value) + return this.savePayload(payload) + } + + /** + * Store multiple key/value pairs + * + * @param values + */ + put (key: string, value: any): void { + const payload = this.fetchPayload() + setNested(payload, key, value) + return this.savePayload(payload) + } + + /** + * Append a value to an array key + * + * @param key + * @param value + */ + push (key: string, value: any): void { + const payload = this.fetchPayload() + if (!Array.isArray(payload[key])) payload[key] = [] + payload[key].push(value) + return this.savePayload(payload) + } + + /** + * Remove a key from the session + * + * @param key + */ + forget (key: string) { + const payload = this.fetchPayload() + delete payload[key] + return this.savePayload(payload) + } + + /** + * Retrieve all session data + * + * @returns + */ + all () { + return this.fetchPayload() + } + + /** + * Determine if a key exists (even if null). + * + * @param key + * @returns + */ + exists (key: string): Promise | boolean { + const data = this.fetchPayload() + return Object.prototype.hasOwnProperty.call(data, key) + } + + /** + * Determine if a key has a non-null value. + * + * @param key + * @returns + */ + has (key: string): Promise | boolean { + const data = this.fetchPayload() + return data[key] !== undefined && data[key] !== null + } + + /** + * Get only specific keys. + * + * @param keys + * @returns + */ + only (keys: string[]) { + const data = this.fetchPayload() + const result: Record = {} + keys.forEach(k => { + if (k in data) result[k] = data[k] + }) + return result + } + + /** + * Return all keys except the specified ones. + * + * @param keys + * @returns + */ + except (keys: string[]) { + const data = this.fetchPayload() + keys.forEach(k => delete data[k]) + return data + } + + /** + * Return and delete a key from the session. + * + * @param key + * @param defaultValue + * @returns + */ + pull (key: string, defaultValue: any = null) { + const data = this.fetchPayload() + const value = data[key] ?? defaultValue + delete data[key] + this.savePayload(data) + return value + } + + /** + * Increment a numeric value by amount (default 1). + * + * @param key + * @param amount + * @returns + */ + increment (key: string, amount = 1): Promise | number { + const data = this.fetchPayload() + const newVal = (parseFloat(data[key]) || 0) + amount + data[key] = newVal + this.savePayload(data) + return newVal + } + + /** + * Decrement a numeric value by amount (default 1). + * + * @param key + * @param amount + * @returns + */ + decrement (key: string, amount = 1) { + return this.increment(key, -amount) + } + + /** + * Flash a value for next request only. + * + * @param key + * @param value + */ + flash (key: string, value: any) { + const data = this.fetchPayload() + data._flash = data._flash || {} + data._flash[key] = value + this.savePayload(data) + } + + /** + * Reflash all flash data for one more cycle. + * + * @returns + */ + reflash () { + const data = this.fetchPayload() + if (!data._flash) return + data._flash_keep = { ...data._flash } + this.savePayload(data) + } + + /** + * Keep only selected flash data. + * + * @param keys + * @returns + */ + keep (keys: string[]) { + const data = this.fetchPayload() + if (!data._flash) return + const kept: Record = {} + keys.forEach(k => { + if (data._flash[k]) kept[k] = data._flash[k] + }) + data._flash_keep = kept + this.savePayload(data) + } + + /** + * Store data only for current request cycle (not persisted). + * + * @param key + * @param value + */ + now (key: string, value: any) { + // Not persisted to DB — use in-memory only. + ; (global as any).__session_now = (global as any).__session_now || {} + ; (global as any).__session_now[key] = value + } + + /** + * Regenerate session ID and persist data under new ID. + */ + regenerate () { + const oldData = this.fetchPayload() + this.sessionId = crypto.randomUUID() + this.savePayload(oldData) + } + + /** + * Determine if an item is not present in the session. + * + * @param key + * @returns + */ + missing (key: string): Promise | boolean { + return !this.exists(key) + } + + /** + * Flush all session data + */ + flush () { + return this.savePayload({}) + } +} \ No newline at end of file diff --git a/packages/session/src/drivers/FileDriver.ts b/packages/session/src/drivers/FileDriver.ts index 56f9c3ac..b8ac3ee8 100644 --- a/packages/session/src/drivers/FileDriver.ts +++ b/packages/session/src/drivers/FileDriver.ts @@ -1,6 +1,6 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' -import { Encryption } from '../Encryption' +import { Driver } from './Driver' import { SessionDriver } from '../Contracts/SessionContract' import path from 'path' @@ -11,16 +11,13 @@ import path from 'path' * Each session is stored in its own file named after the session ID. * Ideal for local development or low-scale deployments. */ -export class FileDriver implements SessionDriver { - private sessionDir: string - private sessionId: string - private encryptor = new Encryption() - +export class FileDriver extends Driver implements SessionDriver { constructor( - sessionId: string, - sessionDir: string = path.resolve('.sessions'), + protected sessionId: string, + private sessionDir: string = path.resolve('.sessions'), private cwd: string = process.cwd() ) { + super() this.sessionDir = path.join(this.cwd, sessionDir) this.sessionId = sessionId @@ -37,7 +34,7 @@ export class FileDriver implements SessionDriver { private ensureSessionFile (): void { const file = this.sessionFilePath() if (!existsSync(file)) { - this.writeEncrypted({}) + this.savePayload({}) } } @@ -45,13 +42,13 @@ export class FileDriver implements SessionDriver { * Get the absolute path for the current session file. */ private sessionFilePath (): string { - return path.join(this.sessionDir, `${this.sessionId}.json`) + return path.join(this.sessionDir, this.sessionId) } /** * Read and decrypt session data from file. */ - private readDecrypted (): Record { + protected fetchPayload (): Record { const file = this.sessionFilePath() if (!existsSync(file)) return {} const content = readFileSync(file, 'utf8') @@ -61,68 +58,19 @@ export class FileDriver implements SessionDriver { /** * Write and encrypt session data to file. */ - private writeEncrypted (data: Record): void { + protected savePayload (data: Record): void { const file = this.sessionFilePath() const encrypted = this.encryptor.encrypt(data) writeFileSync(file, encrypted, 'utf8') } - /** - * Retrieve a value from the session. - */ - get (key: string): any { - const data = this.readDecrypted() - return data[key] - } - - /** - * Store a value in the session. - */ - set (key: string, value: any): void { - const data = this.readDecrypted() - data[key] = value - this.writeEncrypted(data) - } - - /** - * Store multiple key/value pairs. - */ - put (values: Record): void { - const data = this.readDecrypted() - Object.assign(data, values) - this.writeEncrypted(data) - } - - /** - * Append a value to an array key in the session. + /** + * Invalidate session completely and regenerate empty session. */ - push (key: string, value: any): void { - const data = this.readDecrypted() - if (!Array.isArray(data[key])) data[key] = [] - data[key].push(value) - this.writeEncrypted(data) - } - - /** - * Remove a key from the session. - */ - forget (key: string): void { - const data = this.readDecrypted() - delete data[key] - this.writeEncrypted(data) - } - - /** - * Retrieve all session data. - */ - all (): Record { - return this.readDecrypted() - } - - /** - * Flush (clear) the session. - */ - flush (): void { - this.writeEncrypted({}) + invalidate () { + const file = this.sessionFilePath() + rmSync(file, { recursive: true }) + this.sessionId = crypto.randomUUID() + this.savePayload({}) } } diff --git a/packages/session/src/drivers/MemoryDriver.ts b/packages/session/src/drivers/MemoryDriver.ts index a63e137a..9d170861 100644 --- a/packages/session/src/drivers/MemoryDriver.ts +++ b/packages/session/src/drivers/MemoryDriver.ts @@ -1,3 +1,4 @@ +import { Driver } from './Driver' import { SessionDriver } from '../Contracts/SessionContract' /** @@ -6,48 +7,40 @@ import { SessionDriver } from '../Contracts/SessionContract' * Lightweight, ephemeral session storage. * Intended for tests, local development, or short-lived apps. */ -export class MemoryDriver implements SessionDriver { +export class MemoryDriver extends Driver implements SessionDriver { private static store: Record> = {} - private sessionId: string - constructor(sessionId: string) { + constructor(protected sessionId: string) { + super() this.sessionId = sessionId if (!MemoryDriver.store[this.sessionId]) { MemoryDriver.store[this.sessionId] = {} } } - get (key: string): any { - return MemoryDriver.store[this.sessionId][key] - } - - set (key: string, value: any): void { - MemoryDriver.store[this.sessionId][key] = value + /** + * Read and decrypt session data from file. + */ + protected fetchPayload (): Record { + return { ...MemoryDriver.store[this.sessionId] } } - put (data: Record): void { - MemoryDriver.store[this.sessionId] = { + /** + * Write and encrypt session data to file. + */ + protected savePayload (data: Record): void { + MemoryDriver.store[this.sessionId] = Object.entries(data).length < 1 ? {} : { ...MemoryDriver.store[this.sessionId], ...data, } } - push (key: string, value: any): void { - const existing = MemoryDriver.store[this.sessionId][key] || [] - if (!Array.isArray(existing)) throw new Error(`Cannot push to non-array key: ${key}`) - existing.push(value) - MemoryDriver.store[this.sessionId][key] = existing - } - - forget (key: string): void { - delete MemoryDriver.store[this.sessionId][key] - } - - all (): Record { - return { ...MemoryDriver.store[this.sessionId] } - } - - flush (): void { - MemoryDriver.store[this.sessionId] = {} + /** + * Invalidate session completely and regenerate empty session. + */ + invalidate () { + delete MemoryDriver.store[this.sessionId] + this.sessionId = crypto.randomUUID() + this.savePayload({}) } } diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index eaa51047..196c7cc1 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -1,6 +1,8 @@ export * from './adapters' +export * from './Commands/MakeSessionTableCommand' export * from './Contracts/SessionContract' export * from './drivers/DatabaseDriver' +export * from './drivers/Driver' export * from './drivers/FileDriver' export * from './drivers/MemoryDriver' export * from './drivers/RedisDriver' diff --git a/packages/session/tests/config/session.ts b/packages/session/tests/config/session.ts new file mode 100644 index 00000000..5feb4db4 --- /dev/null +++ b/packages/session/tests/config/session.ts @@ -0,0 +1,216 @@ +import { Str } from '@h3ravel/support' + +export default () => { + return { + /* + |-------------------------------------------------------------------------- + | Default Session Driver + |-------------------------------------------------------------------------- + | + | This option determines the default session driver that is utilized for + | incoming requests. H3ravel supports a variety of storage options to + | persist session data. Database storage is a great default choice. + | + | Supported: "file", "database", "memory", + | WIP : "apc", "cookie", "memcached", "redis", "dynamodb" + | + */ + + driver: env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + lifetime: env('SESSION_LIFETIME', 120), + + expire_on_close: env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by H3ravel and you may use the session like normal. + | + */ + + encrypt: env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + files: storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + connection: env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + table: env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "apc", "dynamodb", "memcached", "redis" + | + */ + + store: env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + lottery: [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + | + */ + + cookie: env( + 'SESSION_COOKIE', + Str.slug(env('APP_NAME', 'h3ravel'), '_') + '_session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + path: env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain and all subdomains. Typically, this shouldn't be changed. + | + */ + + domain: env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + secure: env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + http_only: env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + same_site: env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + partitioned: env('SESSION_PARTITIONED_COOKIE', false), + } +} diff --git a/packages/session/tests/database.spec.ts b/packages/session/tests/database.spec.ts new file mode 100644 index 00000000..d86d653c --- /dev/null +++ b/packages/session/tests/database.spec.ts @@ -0,0 +1,217 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +import { DB } from '@h3ravel/database' +import { DatabaseDriver } from '../src' +import { Encryption } from '../src/Encryption' +import { SessionServiceProvider } from '../src/Providers/SessionServiceProvider' +import { h3ravel } from '@h3ravel/core' +import path from 'node:path' + +const appKey = 'base64:dnZm+Ei7ExEHzhj/wO/3YKUckMQtpLjRVk1VLYiV/es=' + +describe('@h3ravel/session Database Driver', () => { + process.env.APP_KEY = appKey + let driver: DatabaseDriver + const table = 'sessions' + const encryptor = new Encryption() + const sessionId = 'test-session-123' + + beforeAll(async () => { + const { DatabaseServiceProvider } = (await import(('@h3ravel/database'))) + const { HttpServiceProvider } = (await import(('@h3ravel/http'))) + const { ConfigServiceProvider } = (await import(('@h3ravel/config'))) + const { RouteServiceProvider } = (await import(('@h3ravel/router'))) + + await h3ravel( + [HttpServiceProvider, DatabaseServiceProvider, ConfigServiceProvider, RouteServiceProvider, SessionServiceProvider], + path.join(process.cwd(), 'packages/session/tests'), + { + autoload: false, + customPaths: { + config: 'config', + routes: 'routes', + } + }) + + + await DB.instance().schema.hasTable('sessions').then(async function (exists) { + if (!exists) { + return DB.instance().schema.createTable('sessions', (table) => { + table.string('id', 255).primary() + table.bigInteger('user_id').nullable().index() + table.string('ip_address', 45).nullable() + table.text('user_agent').nullable() + table.text('payload', 'longtext').nullable() + table.integer('last_activity').index() + }) + } + }) + + driver = new DatabaseDriver(sessionId, table) + }) + + beforeEach(async () => { + process.env.APP_KEY = appKey + await driver.flush() + }) + + afterAll(async () => { + await DB.instance().schema.dropTableIfExists(table) + }) + + it('should store and retrieve encrypted session data', async () => { + await driver.put('user', { id: 1, name: 'Legacy' }) + const retrieved = await driver.get('user') + expect(retrieved).toEqual({ id: 1, name: 'Legacy' }) + + const raw = await DB.table(table).where('id', sessionId).first() + expect(raw).toBeTruthy() + expect(typeof raw.payload).toBe('string') + + // Decrypt manually to verify encryption + const decrypted = encryptor.decrypt(raw.payload) + expect(decrypted.user).toEqual({ id: 1, name: 'Legacy' }) + }) + + it('should store multiple values with set()', async () => { + await driver.set({ token: 'abc123', theme: 'dark' }) + const all = await driver.all() + expect(all.token).toBe('abc123') + expect(all.theme).toBe('dark') + }) + + it('should append values with push()', async () => { + await driver.push('logs', 'login') + await driver.push('logs', 'logout') + const all = await driver.all() + expect(all.logs).toEqual(['login', 'logout']) + }) + + it('should forget a key', async () => { + await driver.put('temp', 'should-remove') + await driver.forget('temp') + const all = await driver.all() + expect(all.temp).toBeUndefined() + }) + + it('should flush all data', async () => { + await driver.put('user', 'data') + await driver.flush() + const all = await driver.all() + expect(Object.keys(all).length).toBe(0) + }) + + it('should update last_activity on each save', async () => { + const before = await DB.table(table).where('id', sessionId).first() + const prevActivity = before.last_activity + await new Promise((r) => setTimeout(r, 1000)) + await driver.put('time', Date.now()) + const after = await DB.table(table).where('id', sessionId).first() + expect(after.last_activity).toBeGreaterThan(prevActivity) + }) + + it('returns default value when key not found', async () => { + const result = await driver.get('missing', 'default') + expect(result).toBe('default') + }) + + it('checks if key exists and has', async () => { + await driver.put('existsKey', null) + await driver.put('hasKey', 'something') + expect(await driver.exists('existsKey')).toBe(true) + expect(await driver.has('existsKey')).toBe(false) + expect(await driver.has('hasKey')).toBe(true) + }) + + it('forgets a key', async () => { + await driver.put('temp', 'gone') + await driver.forget('temp') + const val = await driver.get('temp') + expect(val).toBeOneOf([null, undefined]) + }) + + it('returns only specific keys', async () => { + await driver.put('a', 1) + await driver.put('b', 2) + const result = await driver.only(['a']) + expect(result).toEqual({ a: 1 }) + }) + + it('returns all except specified keys', async () => { + await driver.put('a', 1) + await driver.put('b', 2) + const result = await driver.except(['b']) + expect(result).toEqual({ a: 1 }) + }) + + it('pulls and removes a key', async () => { + await driver.put('pullable', 'data') + const val = await driver.pull('pullable') + expect(val).toBe('data') + expect(await driver.exists('pullable')).toBe(false) + }) + + it('increments and decrements values', async () => { + await driver.put('counter', 1) + await driver.increment('counter', 2) + expect(await driver.get('counter')).toBe(3) + await driver.decrement('counter', 1) + expect(await driver.get('counter')).toBe(2) + }) + + it('flashes data for the next request', async () => { + await driver.flash('flashKey', 'flashVal') + const session = await DB.table(table).where('id', sessionId).first() + const payload = encryptor.decrypt(session.payload) + expect(payload._flash.flashKey).toBe('flashVal') + }) + + it('reflashes data', async () => { + await driver.flash('f1', 'val') + await driver.reflash() + const session = await DB.table(table).where('id', sessionId).first() + const payload = encryptor.decrypt(session.payload) + expect(payload._flash_keep.f1).toBe('val') + }) + + it('keeps selected flash keys', async () => { + await driver.flash('keep1', 'val1') + await driver.flash('keep2', 'val2') + await driver.keep(['keep1']) + const session = await DB.table(table).where('id', sessionId).first() + const payload = encryptor.decrypt(session.payload) + expect(payload._flash_keep).toHaveProperty('keep1') + expect(payload._flash_keep).not.toHaveProperty('keep2') + }) + + it('stores temporary data with now()', async () => { + await driver.now('tmp', 'one-time') + expect((global as any).__session_now.tmp).toBe('one-time') + }) + + it('regenerates session id while keeping data', async () => { + await driver.put('persist', 'value') + const oldId = (driver as any).sessionId + await driver.regenerate() + const newId = (driver as any).sessionId + expect(newId).not.toBe(oldId) + + const count = await DB.table(table).count('id') + expect(count).toBeGreaterThan(0) + }) + + it('invalidates session and creates a new empty one', async () => { + await driver.put('temp', 'data') + const oldId = (driver as any).sessionId + await driver.invalidate() + const newId = (driver as any).sessionId + expect(newId).not.toBe(oldId) + expect(await driver.all()).toEqual({}) + }) + + it('determine if an item is not present in the session', async () => { + await driver.put('present', 1) + const missing = await driver.missing('absent') + expect(missing).toEqual(true) + }) +}) \ No newline at end of file diff --git a/packages/session/tests/file.spec.ts b/packages/session/tests/file.spec.ts new file mode 100644 index 00000000..883110f1 --- /dev/null +++ b/packages/session/tests/file.spec.ts @@ -0,0 +1,168 @@ +import { Application, h3ravel } from '@h3ravel/core' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, readFileSync } from 'node:fs' + +import { HttpContext } from '@h3ravel/shared' +import { SessionManager } from '../src/SessionManager' +import { SessionServiceProvider } from '../src/Providers/SessionServiceProvider' +import path from 'node:path' +import { rmdir } from 'node:fs/promises' + +let ctx: HttpContext +let app: Application +let event: any +const appKey = 'base64:dnZm+Ei7ExEHzhj/wO/3YKUckMQtpLjRVk1VLYiV/es=' + +function makeEvent (overides: Record = {}) { + return { + res: { headers: new Headers(), statusCode: 200, cookie: () => { } }, + req: { + headers: new Headers({ + 'user-agent': 'Vitest', + 'x-forwarded-for': '127.0.0.1' + }), + url: overides.url ?? 'http://localhost/test', method: 'get' + }, + } as any +} + +describe('@h3ravel/session FileDriver', () => { + let tmpDir: string + let session: SessionManager + + beforeAll(async () => { + const { DatabaseServiceProvider } = (await import(('@h3ravel/database'))) + const { HttpServiceProvider } = (await import(('@h3ravel/http'))) + const { ConfigServiceProvider } = (await import(('@h3ravel/config'))) + const { RouteServiceProvider } = (await import(('@h3ravel/router'))) + app = await h3ravel( + [HttpServiceProvider, DatabaseServiceProvider, ConfigServiceProvider, RouteServiceProvider, SessionServiceProvider], + path.join(process.cwd(), 'packages/session/tests'), + { + autoload: false, + customPaths: { + config: 'config', + routes: 'routes', + } + }) + + tmpDir = config('session.files') + }) + + beforeEach(async () => { + event = makeEvent() + const { Request, Response, HttpContext } = (await import(('@h3ravel/http'))) + + ctx = HttpContext.init({ + app, + request: await Request.create(event, app), + response: new Response(event, app), + }, event) + + process.env.APP_KEY = appKey + + session = new SessionManager(ctx, 'file', { cwd: tmpDir, sessionDir: '/' }) + }) + + afterAll(async () => { + await rmdir(tmpDir, { recursive: true, maxRetries: 2 }) + }) + + + it('should generate a session ID and create a file', () => { + const file = path.join(tmpDir, session.id()) + expect(existsSync(file)).toBe(true) + }) + + + it('should put and get values', () => { + session.put('foo', 'bar') + expect(session.get('foo')).toBe('bar') + + const content = readFileSync(path.join(tmpDir, session.id()), 'utf8') + expect(content).toContain(':') // encrypted string has iv:data + }) + + it('can persist sessions', async () => { + const data = { name: 'string' } + session.put('app', data) + + expect(session.get('app')).toMatchObject(data) + }) + + it('should flush all data', () => { + session.put('x', 1) + session.flush() + const all = session.all() + expect(all).toEqual({}) + }) + + it('should forget a key', async () => { + await session.put('temp', 'should-remove') + await session.forget('temp') + const all = await session.all() + expect(all.temp).toBeUndefined() + }) + + it('returns default value when key not found', async () => { + const result = await session.get('missing', 'default') + expect(result).toBe('default') + }) + + it('checks if key exists and has', async () => { + await session.put('existsKey', null) + await session.put('hasKey', 'something') + expect(await session.exists('existsKey')).toBe(true) + expect(await session.has('existsKey')).toBe(false) + expect(await session.has('hasKey')).toBe(true) + }) + + it('forgets a key', async () => { + await session.put('temp', 'gone') + await session.forget('temp') + const val = await session.get('temp') + expect(val).toBeOneOf([null, undefined]) + }) + + it('returns only specific keys', async () => { + await session.put('a', 1) + await session.put('b', 2) + const result = await session.only(['a']) + expect(result).toEqual({ a: 1 }) + }) + + it('returns all except specified keys', async () => { + await session.put('a', 1) + await session.put('b', 2) + const result = await session.except(['b']) + expect(result).toEqual({ a: 1 }) + }) + + it('pulls and removes a key', async () => { + await session.put('pullable', 'data') + const val = await session.pull('pullable') + expect(val).toBe('data') + expect(await session.exists('pullable')).toBe(false) + }) + + + it('increments and decrements values', async () => { + await session.put('counter', 1) + await session.increment('counter', 2) + expect(await session.get('counter')).toBe(3) + await session.decrement('counter', 1) + expect(await session.get('counter')).toBe(2) + }) + + + it('stores temporary data with now()', async () => { + await session.now('tmp', 'one-time') + expect((global as any).__session_now.tmp).toBe('one-time') + }) + + it('determine if an item is not present in the session', async () => { + await session.put('present', 1) + const missing = await session.missing('absent') + expect(missing).toEqual(true) + }) +}) \ No newline at end of file diff --git a/packages/session/tests/memory.spec.ts b/packages/session/tests/memory.spec.ts new file mode 100644 index 00000000..76257787 --- /dev/null +++ b/packages/session/tests/memory.spec.ts @@ -0,0 +1,178 @@ +import { Application, h3ravel } from '@h3ravel/core' +import { beforeAll, beforeEach, describe, expect, it } from 'vitest' + +import { Encryption } from '../src/Encryption' +import { HttpContext } from '@h3ravel/shared' +import { SessionManager } from '../src/SessionManager' +import { SessionServiceProvider } from '../src/Providers/SessionServiceProvider' +import path from 'node:path' + +let ctx: HttpContext +let app: Application +let event: any +const appKey = 'base64:dnZm+Ei7ExEHzhj/wO/3YKUckMQtpLjRVk1VLYiV/es=' + +function makeEvent (overides: Record = {}) { + return { + res: { headers: new Headers(), statusCode: 200, cookie: () => { } }, + req: { + headers: new Headers({ + 'user-agent': 'Vitest', + 'x-forwarded-for': '127.0.0.1' + }), + url: overides.url ?? 'http://localhost/test', method: 'get' + }, + } as any +} + +describe('@h3ravel/session MemoryDriver', () => { + let session: SessionManager + + beforeAll(async () => { + const { DatabaseServiceProvider } = (await import(('@h3ravel/database'))) + const { HttpServiceProvider } = (await import(('@h3ravel/http'))) + const { ConfigServiceProvider } = (await import(('@h3ravel/config'))) + const { RouteServiceProvider } = (await import(('@h3ravel/router'))) + app = await h3ravel( + [HttpServiceProvider, DatabaseServiceProvider, ConfigServiceProvider, RouteServiceProvider, SessionServiceProvider], + path.join(process.cwd(), 'packages/session/tests'), + { + autoload: false, + customPaths: { + config: 'config', + routes: 'routes', + } + }) + }) + + beforeEach(async () => { + event = makeEvent() + const { Request, Response, HttpContext } = (await import(('@h3ravel/http'))) + + ctx = HttpContext.init({ + app, + request: await Request.create(event, app), + response: new Response(event, app), + }, event) + + process.env.APP_KEY = appKey + + session = new SessionManager(ctx, 'memory') + }) + + it('can persist sessions', async () => { + const data = { name: 'string' } + const session = new SessionManager(ctx, 'memory') + session.put('app', data) + + expect(session.get('app')).toMatchObject(data) + }) + + it('can encrypt and decrypt using APP_KEY', async () => { + const str = 'Hello World' + const encryptor = new Encryption() + const enc = encryptor.encrypt(str) + const dec = encryptor.decrypt(enc) + + expect(typeof enc === 'string').toBeTruthy() + expect(typeof dec === 'string').toBeTruthy() + expect(dec).toBe(str) + }) + + it('should generate a session ID', () => { + expect(session.id()).toBeTypeOf('string') + expect(session.id().length).toBeGreaterThan(0) + }) + + it('should set and get a value', () => { + session.put('foo', 'bar') + expect(session.get('foo')).toBe('bar') + }) + + it('should push to an array', () => { + session.put('arr', []) + session.push('arr', 'x') + session.push('arr', 'y') + expect(session.get('arr')).toEqual(['x', 'y']) + }) + + it('should flush all data', () => { + session.put('foo', 'bar') + session.flush() + expect(session.all()).toEqual({}) + }) + + it('should forget a key', () => { + session.put('temp', 123) + session.forget('temp') + expect(session.get('temp')).toBeUndefined() + }) + + it('should set multiple values', () => { + session.set({ a: 1, b: 2 }) + expect(session.get('a')).toBe(1) + expect(session.get('b')).toBe(2) + }) + + it('returns default value when key not found', async () => { + const result = await session.get('missing', 'default') + expect(result).toBe('default') + }) + + it('checks if key exists and has', async () => { + await session.put('existsKey', null) + await session.put('hasKey', 'something') + expect(await session.exists('existsKey')).toBe(true) + expect(await session.has('existsKey')).toBe(false) + expect(await session.has('hasKey')).toBe(true) + }) + + it('forgets a key', async () => { + await session.put('temp', 'gone') + await session.forget('temp') + const val = await session.get('temp') + expect(val).toBeOneOf([null, undefined]) + }) + + it('returns only specific keys', async () => { + await session.put('a', 1) + await session.put('b', 2) + const result = await session.only(['a']) + expect(result).toEqual({ a: 1 }) + }) + + it('returns all except specified keys', async () => { + await session.put('a', 1) + await session.put('b', 2) + const result = await session.except(['b']) + expect(result).toEqual({ a: 1 }) + }) + + it('pulls and removes a key', async () => { + await session.put('pullable', 'data') + const val = await session.pull('pullable') + expect(val).toBe('data') + expect(await session.exists('pullable')).toBe(false) + }) + + + it('increments and decrements values', async () => { + await session.put('counter', 1) + await session.increment('counter', 2) + expect(await session.get('counter')).toBe(3) + await session.decrement('counter', 1) + expect(await session.get('counter')).toBe(2) + }) + + + it('stores temporary data with now()', async () => { + await session.now('tmp', 'one-time') + expect((global as any).__session_now.tmp).toBe('one-time') + }) + + it('determine if an item is not present in the session', async () => { + await session.put('present', 1) + const missing = await session.missing('absent') + expect(missing).toEqual(true) + }) +}) \ No newline at end of file diff --git a/packages/session/tests/session.spec.ts b/packages/session/tests/session.spec.ts index 93387a2d..17cf6e91 100644 --- a/packages/session/tests/session.spec.ts +++ b/packages/session/tests/session.spec.ts @@ -1,7 +1,6 @@ import { Application, h3ravel } from '@h3ravel/core' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { existsSync, readFileSync } from 'node:fs' -import { mkdtemp, rmdir } from 'node:fs/promises' import { DB } from '@h3ravel/database' import { DatabaseDriver } from '../src' @@ -10,7 +9,7 @@ import { HttpContext } from '@h3ravel/shared' import { SessionManager } from '../src/SessionManager' import { SessionServiceProvider } from '../src/Providers/SessionServiceProvider' import path from 'node:path' -import { tmpdir } from 'node:os' +import { rmdir } from 'node:fs/promises' let ctx: HttpContext let app: Application @@ -30,7 +29,9 @@ function makeEvent (overides: Record = {}) { } as any } -describe('@h3ravel/session', () => { +describe('@h3ravel/session MemoryDriver', () => { + let session: SessionManager + beforeAll(async () => { const { DatabaseServiceProvider } = (await import(('@h3ravel/database'))) const { HttpServiceProvider } = (await import(('@h3ravel/http'))) @@ -59,197 +60,123 @@ describe('@h3ravel/session', () => { }, event) process.env.APP_KEY = appKey - }) - - describe('Memory Driver', () => { - let session: SessionManager - - beforeEach(async () => { - session = new SessionManager(ctx, 'memory') - }) + session = new SessionManager(ctx, 'memory') + }) - it('can persist sessions', async () => { - const data = { name: 'string' } - const session = new SessionManager(ctx, 'memory') - session.set('app', data) + it('can persist sessions', async () => { + const data = { name: 'string' } + const session = new SessionManager(ctx, 'memory') + session.put('app', data) - expect(session.get('app')).toMatchObject(data) - }) + expect(session.get('app')).toMatchObject(data) + }) - it('can encrypt and decrypt using APP_KEY', async () => { - const str = 'Hello World' - const encryptor = new Encryption() - const enc = encryptor.encrypt(str) - const dec = encryptor.decrypt(enc) + it('can encrypt and decrypt using APP_KEY', async () => { + const str = 'Hello World' + const encryptor = new Encryption() + const enc = encryptor.encrypt(str) + const dec = encryptor.decrypt(enc) - expect(typeof enc === 'string').toBeTruthy() - expect(typeof dec === 'string').toBeTruthy() - expect(dec).toBe(str) - }) + expect(typeof enc === 'string').toBeTruthy() + expect(typeof dec === 'string').toBeTruthy() + expect(dec).toBe(str) + }) - it('should generate a session ID', () => { - expect(session.id()).toBeTypeOf('string') - expect(session.id().length).toBeGreaterThan(0) - }) + it('should generate a session ID', () => { + expect(session.id()).toBeTypeOf('string') + expect(session.id().length).toBeGreaterThan(0) + }) - it('should set and get a value', () => { - session.set('foo', 'bar') - expect(session.get('foo')).toBe('bar') - }) + it('should set and get a value', () => { + session.put('foo', 'bar') + expect(session.get('foo')).toBe('bar') + }) - it('should push to an array', () => { - session.set('arr', []) - session.push('arr', 'x') - session.push('arr', 'y') - expect(session.get('arr')).toEqual(['x', 'y']) - }) + it('should push to an array', () => { + session.put('arr', []) + session.push('arr', 'x') + session.push('arr', 'y') + expect(session.get('arr')).toEqual(['x', 'y']) + }) - it('should flush all data', () => { - session.set('foo', 'bar') - session.flush() - expect(session.all()).toEqual({}) - }) + it('should flush all data', () => { + session.put('foo', 'bar') + session.flush() + expect(session.all()).toEqual({}) + }) - it('should forget a key', () => { - session.set('temp', 123) - session.forget('temp') - expect(session.get('temp')).toBeUndefined() - }) + it('should forget a key', () => { + session.put('temp', 123) + session.forget('temp') + expect(session.get('temp')).toBeUndefined() + }) - it('should put multiple values', () => { - session.put({ a: 1, b: 2 }) - expect(session.get('a')).toBe(1) - expect(session.get('b')).toBe(2) - }) + it('should set multiple values', () => { + session.set({ a: 1, b: 2 }) + expect(session.get('a')).toBe(1) + expect(session.get('b')).toBe(2) }) - describe('File Driver', () => { - let tmpDir: string - let session: SessionManager + it('returns default value when key not found', async () => { + const result = await session.get('missing', 'default') + expect(result).toBe('default') + }) - beforeEach(async () => { - session = new SessionManager(ctx, 'file', { cwd: tmpDir, sessionDir: 'storage/sessions' }) - }) + it('checks if key exists and has', async () => { + await session.put('existsKey', null) + await session.put('hasKey', 'something') + expect(await session.exists('existsKey')).toBe(true) + expect(await session.has('existsKey')).toBe(false) + expect(await session.has('hasKey')).toBe(true) + }) - beforeAll(async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), '@h3ravel-session')) - }) + it('forgets a key', async () => { + await session.put('temp', 'gone') + await session.forget('temp') + const val = await session.get('temp') + expect(val).toBeOneOf([null, undefined]) + }) - afterAll(async () => { - await rmdir(tmpDir, { recursive: true, maxRetries: 2 }) - }) + it('returns only specific keys', async () => { + await session.put('a', 1) + await session.put('b', 2) + const result = await session.only(['a']) + expect(result).toEqual({ a: 1 }) + }) - it('should generate a session ID and create a file', () => { - const file = path.join(tmpDir, `storage/sessions/${session.id()}.json`) - expect(existsSync(file)).toBe(true) - }) + it('returns all except specified keys', async () => { + await session.put('a', 1) + await session.put('b', 2) + const result = await session.except(['b']) + expect(result).toEqual({ a: 1 }) + }) + it('pulls and removes a key', async () => { + await session.put('pullable', 'data') + const val = await session.pull('pullable') + expect(val).toBe('data') + expect(await session.exists('pullable')).toBe(false) + }) - it('should set and get values', () => { - session.set('foo', 'bar') - expect(session.get('foo')).toBe('bar') - const content = readFileSync(path.join(tmpDir, `storage/sessions/${session.id()}.json`), 'utf8') - expect(content).toContain(':') // encrypted string has iv:data - }) + it('increments and decrements values', async () => { + await session.put('counter', 1) + await session.increment('counter', 2) + expect(await session.get('counter')).toBe(3) + await session.decrement('counter', 1) + expect(await session.get('counter')).toBe(2) + }) - it('can persist sessions', async () => { - const data = { name: 'string' } - session.set('app', data) - expect(session.get('app')).toMatchObject(data) - }) + it('stores temporary data with now()', async () => { + await session.now('tmp', 'one-time') + expect((global as any).__session_now.tmp).toBe('one-time') + }) - it('should flush all data', () => { - session.set('x', 1) - session.flush() - const all = session.all() - expect(all).toEqual({}) - }) - }) - - describe('Database Driver', () => { - process.env.APP_KEY = appKey - let session: SessionManager - let driver: DatabaseDriver - const table = 'sessions' - const encryptor = new Encryption() - const sessionId = 'test-session-123' - - beforeAll(async () => { - if (!(await DB.instance().schema.hasTable('sessions'))) { - await DB.instance().schema.createTable(table, (table) => { - table.string('id', 255).primary() - table.bigInteger('user_id').nullable() - table.string('ip_address').nullable() - table.text('user_agent').nullable() - table.text('payload', 'longtext').nullable() - table.integer('last_activity') - }) - } - - driver = new DatabaseDriver(sessionId, table) - session = new SessionManager(ctx, 'database', { table, sessionId }) - }) - - afterAll(async () => { - await DB.instance().schema.dropTableIfExists(table) - }) - - it('should store and retrieve encrypted session data', async () => { - await session.set('app', { data: '123' }) - console.log(await session.get('app')) - - - await driver.set('user', { id: 1, name: 'Legacy' }) - const retrieved = await driver.get('user') - expect(retrieved).toEqual({ id: 1, name: 'Legacy' }) - - const raw = await DB.table(table).where('id', sessionId).first() - expect(raw).toBeTruthy() - expect(typeof raw.payload).toBe('string') - - // Decrypt manually to verify encryption - const decrypted = encryptor.decrypt(raw.payload) - expect(decrypted.user).toEqual({ id: 1, name: 'Legacy' }) - }) - - it('should store multiple values with put()', async () => { - await driver.put({ token: 'abc123', theme: 'dark' }) - const all = await driver.all() - expect(all.token).toBe('abc123') - expect(all.theme).toBe('dark') - }) - - it('should append values with push()', async () => { - await driver.push('logs', 'login') - await driver.push('logs', 'logout') - const all = await driver.all() - expect(all.logs).toEqual(['login', 'logout']) - }) - - it('should forget a key', async () => { - await driver.set('temp', 'should-remove') - await driver.forget('temp') - const all = await driver.all() - expect(all.temp).toBeUndefined() - }) - - it('should flush all data', async () => { - await driver.set('user', 'data') - await driver.flush() - const all = await driver.all() - expect(Object.keys(all).length).toBe(0) - }) - - it('should update last_activity on each save', async () => { - const before = await DB.table(table).where('id', sessionId).first() - const prevActivity = before.last_activity - await new Promise((r) => setTimeout(r, 1000)) - await driver.set('time', Date.now()) - const after = await DB.table(table).where('id', sessionId).first() - expect(after.last_activity).toBeGreaterThan(prevActivity) - }) + it('determine if an item is not present in the session', async () => { + await session.put('present', 1) + const missing = await session.missing('absent') + expect(missing).toEqual(true) }) }) \ No newline at end of file diff --git a/packages/shared/src/Contracts/IRequest.ts b/packages/shared/src/Contracts/IRequest.ts index c692bd92..d4d58965 100644 --- a/packages/shared/src/Contracts/IRequest.ts +++ b/packages/shared/src/Contracts/IRequest.ts @@ -1,10 +1,11 @@ import type { DotNestedKeys, DotNestedValue } from './ObjContract' +import { HttpContext, RequestMethod } from './IHttp' import type { H3Event } from 'h3' import type { IApplication } from './IApplication' import { IParamBag } from './IParamBag' +import { ISessionManager } from './ISessionManager' import { IUploadedFile } from './IUploadedFile' -import { RequestMethod } from './IHttp' type RequestObject = Record; @@ -23,6 +24,10 @@ export declare class IRequest< * Parsed request body */ body: unknown + /** + * The current Http Context + */ + context: HttpContext /** * Gets route parameters. * @returns An object containing route parameters. @@ -147,6 +152,17 @@ export declare class IRequest< * Get the keys for all of the input and files. */ keys (): string[]; + /** + * Get an instance of the current session manager + * + * @param key + * @param defaultValue + * @returns an instance of the current session manager. + */ + public session | undefined = undefined> (key?: K, defaultValue?: any): K extends undefined + ? ISessionManager + : K extends string + ? any : void | Promise /** * Determine if the request is sending JSON. * diff --git a/packages/shared/src/Contracts/IResponse.ts b/packages/shared/src/Contracts/IResponse.ts index c50fa5a7..0e7485f0 100644 --- a/packages/shared/src/Contracts/IResponse.ts +++ b/packages/shared/src/Contracts/IResponse.ts @@ -1,6 +1,7 @@ import type { DotNestedKeys, DotNestedValue } from '@h3ravel/shared' import type { H3Event, HTTPResponse } from 'h3' +import { HttpContext } from './IHttp' import type { IApplication } from './IApplication' import { IHttpResponse } from './IHttpResponse' @@ -12,6 +13,10 @@ export interface IResponse extends IHttpResponse { * The current app instance */ app: IApplication; + /** + * The current Http Context + */ + context: HttpContext /** * Sends content for the current web response. */ diff --git a/packages/shared/src/Contracts/ISessionManager.ts b/packages/shared/src/Contracts/ISessionManager.ts new file mode 100644 index 00000000..c43999a6 --- /dev/null +++ b/packages/shared/src/Contracts/ISessionManager.ts @@ -0,0 +1,153 @@ +import { HttpContext } from './IHttp' + +/** + * SessionManager + * + * Handles session initialization, ID generation, and encryption. + * Each request gets a unique session namespace tied to its ID. + */ +export declare class ISessionManager { + /** + * @param ctx - incoming request http context + * @param driverName - registered driver key ('file' | 'database' | 'memory' | 'redis') + * @param driverOptions - optional bag for driver-specific options + */ + constructor(ctx: HttpContext, driverName?: 'file' | 'memory' | 'database' | 'redis', driverOptions?: any); + /** + * Access the current session ID. + */ + id (): string; + /** + * Retrieve a value from the session + * + * @param key + * @returns + */ + get (key: string, defaultValue?: any): Promise | any; + /** + * Store a value in the session + * + * @param key + * @param value + */ + set (value: Record): Promise | void; + /** + * Store multiple key/value pairs + * + * @param values + */ + put (key: string, value: any): void | Promise; + /** + * Append a value to an array key + * + * @param key + * @param value + */ + push (key: string, value: any): void | Promise; + /** + * Remove a key from the session + * + * @param key + */ + forget (key: string): void | Promise; + /** + * Retrieve all session data + * + * @returns + */ + all (): Record | Promise>; + /** + * Determine if a key exists (even if null). + * + * @param key + * @returns + */ + exists (key: string): Promise | boolean; + /** + * Determine if a key has a non-null value. + * + * @param key + * @returns + */ + has (key: string): Promise | boolean; + /** + * Get only specific keys. + * + * @param keys + * @returns + */ + only (keys: string[]): Record | Promise>; + /** + * Return all keys except the specified ones. + * + * @param keys + * @returns + */ + except (keys: string[]): Record | Promise>; + /** + * Return and delete a key from the session. + * + * @param key + * @param defaultValue + * @returns + */ + pull (key: string, defaultValue?: any): any; + /** + * Increment a numeric value by amount (default 1). + * + * @param key + * @param amount + * @returns + */ + increment (key: string, amount?: number): Promise | number; + /** + * Decrement a numeric value by amount (default 1). + * + * @param key + * @param amount + * @returns + */ + decrement (key: string, amount?: number): number | Promise; + /** + * Flash a value for next request only. + * + * @param key + * @param value + */ + flash (key: string, value: any): void | Promise; + /** + * Reflash all flash data for one more cycle. + * + * @returns + */ + reflash (): void | Promise; + /** + * Keep only selected flash data. + * + * @param keys + * @returns + */ + keep (keys: string[]): void | Promise; + /** + * Store data only for current request cycle (not persisted). + * + * @param key + * @param value + */ + now (key: string, value: any): void | Promise; + /** + * Regenerate session ID and persist data under new ID. + */ + regenerate (): void | Promise; + /** + * Determine if an item is not present in the session. + * + * @param key + * @returns + */ + missing (key: string): Promise | boolean; + /** + * Flush all session data + */ + flush (): void | Promise; +} \ No newline at end of file diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index bdbe8aa6..4f103fe3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export * from './Contracts/IParamBag' export * from './Contracts/IRequest' export * from './Contracts/IResponse' export * from './Contracts/IServiceProvider' +export * from './Contracts/ISessionManager' export * from './Contracts/IUploadedFile' export * from './Contracts/ObjContract' export * from './Contracts/PromptsContract' diff --git a/packages/support/src/Helpers/Obj.ts b/packages/support/src/Helpers/Obj.ts index e955b0e6..7888a7c4 100644 --- a/packages/support/src/Helpers/Obj.ts +++ b/packages/support/src/Helpers/Obj.ts @@ -6,7 +6,7 @@ import type { DotPath, KeysToSnakeCase } from '../Contracts/ObjContract' * with dot-separated keys. * * Example: - * doter({ + * dot({ * user: { name: "John", address: { city: "NY" } }, * active: true * }) diff --git a/packages/validation/tests/validator.spec.ts b/packages/validation/tests/validator.spec.ts index bda07db9..65881141 100644 --- a/packages/validation/tests/validator.spec.ts +++ b/packages/validation/tests/validator.spec.ts @@ -1,9 +1,9 @@ -import { Application, h3ravel } from '@h3ravel/core' import { ValidationRule, ValidationServiceProvider } from '../src' import { beforeAll, describe, expect, it } from 'vitest' import { ValidationException } from '../src/ValidationException' import { Validator } from '../src/Validator' +import { h3ravel } from '@h3ravel/core' import path from 'node:path' describe('Validator', () => { @@ -202,14 +202,21 @@ describe('Validator', () => { } }) - - if (!(await DB.instance().schema.hasTable('users'))) { - await DB.instance().schema.createTable('users', (table: any) => { - table.increments('id') - table.string('username').nullable() - table.timestamps() - }) - } + await DB.instance().schema.hasTable('users').then((exists) => { + if (!exists) { + return DB.instance().schema.createTable('users', (table: any) => { + table.increments('id') + table.string('username').nullable() + table.timestamps() + }) + } else { + return DB.instance().schema.alterTable('users', async (table: any) => { + if (!await DB.instance().schema.hasColumn('users', 'username')) { + table.string('username').nullable() + } + }) + } + }) class User extends Model { } await User.query().firstOrCreate({ 'username': 'legacy' }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36349250..da3d405a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,148 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + '@changesets/cli': + specifier: ^2.29.7 + version: 2.29.7 + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.1 + '@rollup/plugin-run': + specifier: ^3.1.0 + version: 3.1.0 + '@swc/core': + specifier: ^1.15.0 + version: 1.15.0 + '@types/luxon': + specifier: ^3.7.1 + version: 3.7.1 + '@types/nodemailer': + specifier: ^6.4.17 + version: 6.4.17 + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 + '@typescript-eslint/eslint-plugin': + specifier: ^8.46.3 + version: 8.46.3 + '@typescript-eslint/parser': + specifier: ^8.46.3 + version: 8.46.3 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4 + argon2: + specifier: ^0.44.0 + version: 0.44.0 + barrelize: + specifier: 1.6.6 + version: 1.6.6 + barrelsby: + specifier: ^2.8.1 + version: 2.8.1 + bcryptjs: + specifier: ^3.0.2 + version: 3.0.2 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 + dayjs: + specifier: ^1.11.18 + version: 1.11.19 + detect-port: + specifier: ^2.1.0 + version: 2.1.0 + dotenv: + specifier: ^17.2.3 + version: 17.2.3 + dotenv-expand: + specifier: ^12.0.3 + version: 12.0.3 + edge.js: + specifier: ^6.3.0 + version: 6.3.0 + escalade: + specifier: ^3.2.0 + version: 3.2.0 + eslint: + specifier: ^9.39.1 + version: 9.39.1 + execa: + specifier: ^9.6.0 + version: 9.6.0 + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 + husky: + specifier: ^9.1.7 + version: 9.1.7 + knex: + specifier: ^3.1.0 + version: 3.1.0 + luxon: + specifier: ^3.7.2 + version: 3.7.2 + mysql2: + specifier: 3.15.3 + version: 3.15.3 + path: + specifier: ^0.12.7 + version: 0.12.7 + preferred-pm: + specifier: ^4.1.1 + version: 4.1.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + resolve-from: + specifier: ^5.0.0 + version: 5.0.0 + rimraf: + specifier: ^6.1.0 + version: 6.1.0 + semver: + specifier: ^7.7.2 + version: 7.7.3 + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + sqlite3: + specifier: 5.1.7 + version: 5.1.7 + ts-node: + specifier: ^10.9.2 + version: 10.9.2 + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + tsx: + specifier: ^4.20.6 + version: 4.20.6 + typescript-eslint: + specifier: ^8.46.3 + version: 8.46.3 + utility-types: + specifier: ^3.11.0 + version: 3.11.0 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4 + prod: + '@h3ravel/arquebus': + specifier: ^0.6.17 + version: 0.6.17 + '@h3ravel/musket': + specifier: ^0.3.12 + version: 0.3.12 + h3: + specifier: 2.0.1-rc.5 + version: 2.0.1-rc.5 + importers: .: @@ -431,6 +573,9 @@ importers: '@h3ravel/musket': specifier: catalog:prod version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0) + '@h3ravel/session': + specifier: workspace:^ + version: link:../session '@h3ravel/shared': specifier: workspace:^ version: link:../shared From 3babec939d272f6049f0d104170354cfc7a7437d Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Sun, 23 Nov 2025 20:48:47 +0100 Subject: [PATCH 09/28] refactor: load middlewares directly from router package and more. - bind global default middleware via app.globalMiddleware - improve core http kernel - add withMiddleware handler to core Foundation class - create middleware handling pipeline. --- examples/basic-app/storage/app/public/.gitkeep | 0 ...ee7bcd458edd613c199680fe7e6830bf068a4e7c90c4716a5cea61ff47d40 | 1 - 2 files changed, 1 deletion(-) delete mode 100644 examples/basic-app/storage/app/public/.gitkeep delete mode 100644 examples/basic-app/storage/framework/sessions/9deee7bcd458edd613c199680fe7e6830bf068a4e7c90c4716a5cea61ff47d40 diff --git a/examples/basic-app/storage/app/public/.gitkeep b/examples/basic-app/storage/app/public/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/basic-app/storage/framework/sessions/9deee7bcd458edd613c199680fe7e6830bf068a4e7c90c4716a5cea61ff47d40 b/examples/basic-app/storage/framework/sessions/9deee7bcd458edd613c199680fe7e6830bf068a4e7c90c4716a5cea61ff47d40 deleted file mode 100644 index 2d462871..00000000 --- a/examples/basic-app/storage/framework/sessions/9deee7bcd458edd613c199680fe7e6830bf068a4e7c90c4716a5cea61ff47d40 +++ /dev/null @@ -1 +0,0 @@ -4ae7662b3a5e06b9fc2442dc640645d8:e6b5da57bab89b8563c205413821d27a1981e3829e2c8062f862be88c6a49aac19ac220df63dc4c87813a0d3e148eee58b4f3d950d471325d043ffc19ad8d6a2 \ No newline at end of file From e3f2a95052de8a6e7515ff08def4539d905d088c Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Sun, 23 Nov 2025 21:20:19 +0100 Subject: [PATCH 10/28] refactor: load middlewares directly from router package and more. - bind global default middleware via app.globalMiddleware - improve core http kernel - add withMiddleware handler to core Foundation class - create middleware handling pipeline. --- examples/basic-app/.gitignore | 5 + examples/basic-app/src/bootstrap/app.ts | 2 + examples/basic-app/src/routes/web.ts | 2 - .../basic-app/storage/app/public/.gitkeep | 0 .../storage/framework/sessions/.gitkeep | 0 packages/core/src/Application.ts | 2 +- packages/core/src/Container.ts | 32 +- packages/core/src/H3ravel.ts | 12 +- packages/core/src/Http/Kernel.ts | 123 ++-- packages/core/src/Manager/Foundation.ts | 22 +- .../core/tests/single-entry-point.test.ts | 15 +- .../src/Configuration/Middleware.ts | 591 ++++++++++++++++++ .../src/Contracts/MiddlewareContract.ts | 5 + packages/foundation/src/Exceptions/Handler.ts | 46 +- .../foundation/src/Http/MiddlewareHandler.ts | 57 ++ packages/foundation/src/index.ts | 3 + .../src/Middleware/FlashDataMiddleware.ts | 13 + packages/http/src/Middleware/LogRequests.ts | 22 +- packages/http/src/Request.ts | 8 +- packages/http/src/Utilities/HttpRequest.ts | 1 + packages/http/src/index.ts | 1 + .../src/Providers/RouteServiceProvider.ts | 2 +- packages/router/src/{Route.ts => Router.ts} | 30 +- packages/router/src/index.ts | 2 +- .../session/src/Contracts/SessionContract.ts | 26 +- packages/session/src/FlashBag.ts | 151 +++++ packages/session/src/SessionManager.ts | 12 + .../session/src/drivers/DatabaseDriver.ts | 215 +++---- packages/session/src/drivers/Driver.ts | 127 ++-- packages/session/src/drivers/FileDriver.ts | 44 +- packages/session/src/drivers/MemoryDriver.ts | 32 +- packages/session/src/drivers/RedisDriver.ts | 49 +- packages/session/src/index.ts | 1 + packages/session/tests/database.spec.ts | 16 +- packages/session/tests/file.spec.ts | 2 +- packages/session/tests/memory.spec.ts | 2 +- packages/session/tests/session.spec.ts | 182 ------ packages/shared/src/Contracts/IContainer.ts | 42 +- .../shared/src/Contracts/IExceptionHandler.ts | 100 +++ .../src/Contracts/IMiddlewareHandler.ts | 19 + .../shared/src/Contracts/ISessionManager.ts | 6 + packages/shared/src/index.ts | 2 + packages/support/src/Helpers/Arr.ts | 9 +- packages/support/src/Helpers/Obj.ts | 125 +++- 44 files changed, 1565 insertions(+), 593 deletions(-) create mode 100644 examples/basic-app/storage/app/public/.gitkeep create mode 100644 examples/basic-app/storage/framework/sessions/.gitkeep create mode 100644 packages/foundation/src/Configuration/Middleware.ts create mode 100644 packages/foundation/src/Contracts/MiddlewareContract.ts create mode 100644 packages/foundation/src/Http/MiddlewareHandler.ts create mode 100644 packages/http/src/Middleware/FlashDataMiddleware.ts rename packages/router/src/{Route.ts => Router.ts} (96%) create mode 100644 packages/session/src/FlashBag.ts delete mode 100644 packages/session/tests/session.spec.ts create mode 100644 packages/shared/src/Contracts/IExceptionHandler.ts create mode 100644 packages/shared/src/Contracts/IMiddlewareHandler.ts diff --git a/examples/basic-app/.gitignore b/examples/basic-app/.gitignore index 4e8d5533..9304814e 100644 --- a/examples/basic-app/.gitignore +++ b/examples/basic-app/.gitignore @@ -6,11 +6,16 @@ yarn-debug.log* yarn-error.log* lerna-debug.log* .DS_Store +!.gitkeep # H3ravel Files .h3ravel/serve src/config/hashing.ts src/database/*.sqlite +storage/framework/sessions/* +!storage/framework/sessions/.gitkeep +storage/app/public/* +!storage/app/public/.gitkeep # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/examples/basic-app/src/bootstrap/app.ts b/examples/basic-app/src/bootstrap/app.ts index 843c8834..42416a2f 100644 --- a/examples/basic-app/src/bootstrap/app.ts +++ b/examples/basic-app/src/bootstrap/app.ts @@ -26,6 +26,8 @@ export default class { */ .truncateRequestExceptionsAt(200) }) + .withMiddleware(() => { + }) return await app.fire() } diff --git a/examples/basic-app/src/routes/web.ts b/examples/basic-app/src/routes/web.ts index 7b626d4d..ef4be410 100644 --- a/examples/basic-app/src/routes/web.ts +++ b/examples/basic-app/src/routes/web.ts @@ -30,8 +30,6 @@ export default (Route: Router) => { age: ['required', 'integer'], }) - session({ data }) - console.log(await request.session().all(), request.session().only(['data']), session('data.age')) return response .setStatusCode(202) .json({ diff --git a/examples/basic-app/storage/app/public/.gitkeep b/examples/basic-app/storage/app/public/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/examples/basic-app/storage/framework/sessions/.gitkeep b/examples/basic-app/storage/framework/sessions/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/core/src/Application.ts b/packages/core/src/Application.ts index 062b9fbc..36ddd1d7 100644 --- a/packages/core/src/Application.ts +++ b/packages/core/src/Application.ts @@ -9,7 +9,7 @@ import { Container } from './Container' import { ContainerResolver } from './Manager/ContainerResolver' import { ProviderRegistry } from './ProviderRegistry' import { Registerer } from './Registerer' -import { ServiceProvider } from './ServiceProvider' +import { type ServiceProvider } from './ServiceProvider' import { detect } from 'detect-port' import dotenv from 'dotenv' import dotenvExpand from 'dotenv-expand' diff --git a/packages/core/src/Container.ts b/packages/core/src/Container.ts index 8783e043..bd44c024 100644 --- a/packages/core/src/Container.ts +++ b/packages/core/src/Container.ts @@ -1,5 +1,5 @@ import type { Bindings, IContainer, UseKey } from '@h3ravel/shared' -import { Handler } from '@h3ravel/foundation' +import { Handler, MiddlewareHandler } from '@h3ravel/foundation' type IBinding = UseKey | (new (..._args: any[]) => unknown) @@ -7,6 +7,7 @@ export class Container implements IContainer { public bindings = new Map unknown>() public singletons = new Map() public exceptionHandler?: Handler + public middlewareHandler?: MiddlewareHandler private afterResolvingCallbacks = new Map void)[]>() /** @@ -33,6 +34,9 @@ export class Container implements IContainer { /** * Bind a transient service to the container + * + * @param key + * @param factory */ bind (key: new (...args: any[]) => T, factory: () => T): void bind (key: T, factory: () => Bindings[T]): void @@ -45,6 +49,8 @@ export class Container implements IContainer { /** * Remove one or more transient services from the container + * + * @param key */ unbind (key: T | T[]) { if (Array.isArray(key)) { @@ -59,7 +65,10 @@ export class Container implements IContainer { } /** - * Bind a singleton service to the container + * Bind a singleton service to the container + * + * @param key + * @param factory */ singleton ( key: T | (new (..._args: any[]) => Bindings[T]), @@ -75,6 +84,8 @@ export class Container implements IContainer { /** * Resolve a service from the container + * + * @param key */ make (key: T): Bindings[T] make any> (key: C): InstanceType @@ -104,6 +115,9 @@ export class Container implements IContainer { /** * Register a callback to be executed after a service is resolved + * + * @param key + * @param callback */ afterResolving ( key: T | (new (..._args: any[]) => Bindings[T]), @@ -117,6 +131,9 @@ export class Container implements IContainer { /** * Execute all registered afterResolving callbacks for a given key + * + * @param key + * @param resolved */ private runAfterResolvingCallbacks ( key: T, @@ -131,6 +148,9 @@ export class Container implements IContainer { /** * Automatically build a class with constructor dependency injection + * + * @param ClassType + * @returns */ private build (ClassType: new (..._args: any[]) => Bindings[T]): Bindings[T] { let dependencies: any[] = [] @@ -149,8 +169,14 @@ export class Container implements IContainer { /** * Check if a service is registered + * + * @param key + * @returns */ - has (key: UseKey): boolean { + has (key: T): boolean + has any> (key: C): boolean + has any> (key: F): boolean + has (key: any): boolean { return this.bindings.has(key) } } diff --git a/packages/core/src/H3ravel.ts b/packages/core/src/H3ravel.ts index 44d12049..0c85e292 100644 --- a/packages/core/src/H3ravel.ts +++ b/packages/core/src/H3ravel.ts @@ -1,8 +1,8 @@ import { Application, Kernel, OServiceProvider } from '.' -import { HttpContext, LogRequests, Request, Response } from '@h3ravel/http' import { EntryConfig } from './Contracts/H3ravelContract' import { H3 } from 'h3' +import { HttpContext } from '@h3ravel/shared' /** * Simple global entry point for H3ravel applications @@ -29,6 +29,9 @@ export const h3ravel = async ( */ middleware: (ctx: HttpContext) => Promise = async () => undefined, ): Promise => { + + const { FlashDataMiddleware, HttpContext, LogRequests, Request, Response } = await import('@h3ravel/http') + // Initialize the H3 app instance let h3App: H3 | undefined @@ -67,8 +70,13 @@ export const h3ravel = async ( return ctx } + app.singleton('app.globalMiddleware', () => [ + new LogRequests(), + new FlashDataMiddleware(), + ]) + // Initialize the Application Kernel - const kernel = new Kernel(async (event) => app.context!(event), [new LogRequests()]) + const kernel = new Kernel(app) // Register kernel with H3 h3App.use((event) => kernel.handle(event, middleware)) diff --git a/packages/core/src/Http/Kernel.ts b/packages/core/src/Http/Kernel.ts index 8f061e14..8fa96be5 100644 --- a/packages/core/src/Http/Kernel.ts +++ b/packages/core/src/Http/Kernel.ts @@ -1,6 +1,8 @@ -import type { HttpContext, IMiddleware } from '@h3ravel/shared' - +import { Arr, Obj } from '@h3ravel/support' +import { Resolver, type HttpContext, type IMiddleware } from '@h3ravel/shared' +import { Application } from '..' import type { H3Event } from 'h3' +import { MiddlewareHandler } from '@h3ravel/foundation' /** * Kernel class handles middleware execution and response transformations. @@ -8,13 +10,21 @@ import type { H3Event } from 'h3' */ export class Kernel { /** - * @param context - A factory function that converts an H3Event into an HttpContext. + * A factory function that converts an H3Event into an HttpContext. + */ + protected context: (event: H3Event) => HttpContext | Promise + protected applicationContext!: HttpContext + + /** + * @param app - The current application instance * @param middleware - An array of middleware classes that will be executed in sequence. */ constructor( - protected context: (event: H3Event) => HttpContext | Promise, - protected middleware: IMiddleware[] = [], - ) { } + public app: Application, + public middleware: IMiddleware[] = [], + ) { + this.context = async (event) => app.context!(event) + } /** * Handles an incoming request and passes it through middleware before invoking the next handler. @@ -30,41 +40,32 @@ export class Kernel { /** * Convert the raw event into a standardized HttpContext */ - const ctx = await this.context(event) - - const { app } = ctx.request + this.applicationContext = await this.context(event) - /** - * Bind HTTP Context to the service container - */ - app.bind('http.context', () => { - return ctx - }) - - /** - * Bind HTTP Response instance to the service container + /** + * Bind HttpContext, request, and response to the container */ - app.bind('http.response', () => { - return ctx.response - }) + this.app.bind('http.context', () => this.applicationContext) + this.app.bind('http.request', () => this.applicationContext.request) + this.app.bind('http.response', () => this.applicationContext.response) - /** - * Bind HTTP Request instance to the service container - */ - app.bind('http.request', () => { - return ctx.request - }) + // Resolve or create MiddlewareHandler + this.app.middlewareHandler = this.app.has(MiddlewareHandler) + ? this.app.make(MiddlewareHandler) + : new MiddlewareHandler() /** * Run middleware stack and obtain result */ - const result = await this.runMiddleware(ctx, () => next(ctx)) + const result = await this.app.middlewareHandler + .register(this.middleware) + .run(this.applicationContext, next) /** - * If a plain object is returned from a controller or middleware, - * automatically set the JSON Content-Type header for the response. - */ - if (result !== undefined && this.isPlainObject(result)) { + * If a plain object is returned from a controller or middleware, + * automatically set the JSON Content-Type header for the response. + */ + if (result !== undefined && Obj.isPlainObject(result, true) && !result?.headers) { event.res.headers.set('Content-Type', 'application/json; charset=UTF-8') } @@ -72,48 +73,30 @@ export class Kernel { } /** - * Sequentially runs middleware in the order they were registered. - * - * @param context - The standardized HttpContext. - * @param next - Callback to execute when middleware completes. - * @returns A promise resolving to the final handler's result. + * Resolve the provided callback using the current H3 event instance */ - private async runMiddleware ( - context: HttpContext, - next: (ctx: HttpContext) => Promise - ) { - let index = -1 + public async resolve ( + event: H3Event, + middleware: IMiddleware | IMiddleware[], + handler: (ctx: HttpContext) => Promise + ): Promise { + const { Response } = await import('@h3ravel/http') + + this.middleware = Array.from(new Set([...this.middleware, ...Arr.wrap(middleware)])) - const runner = async (i: number): Promise => { - if (i <= index) throw new Error('next() called multiple times') - index = i - const middleware = this.middleware[i] + return this.handle(event, (ctx) => new Promise((resolve) => { + if (Resolver.isAsyncFunction(handler)) { + handler(ctx).then((response: any) => { - if (middleware) { - /** - * Execute the current middleware and proceed to the next one - */ - return middleware.handle(context, () => runner(i + 1)) + if (response instanceof Response) { + resolve(response.prepare(ctx.request as never).send()) + } else { + resolve(response) + } + }) } else { - /** - * If no more middleware, call the final handler - */ - return next(context) + resolve(handler(ctx)) } - } - - return runner(0) - } - - /** - * Utility function to determine if a value is a plain object or array. - * - * @param value - The value to check. - * @returns True if the value is a plain object or array, otherwise false. - */ - private isPlainObject (value: unknown): value is Record { - return typeof value === 'object' && - value !== null && - (value.constructor === Object || value.constructor === Array) + })) } } diff --git a/packages/core/src/Manager/Foundation.ts b/packages/core/src/Manager/Foundation.ts index e4ad0f86..708a81c6 100644 --- a/packages/core/src/Manager/Foundation.ts +++ b/packages/core/src/Manager/Foundation.ts @@ -1,4 +1,4 @@ -import { ExceptionHandler, Exceptions } from '@h3ravel/foundation' +import { ExceptionHandler, Exceptions, Middleware, MiddlewareHandler } from '@h3ravel/foundation' import { Application } from '..' @@ -24,4 +24,24 @@ export class Foundation { return this } + + /** + * Register and wire up the application's middleware handling layer. + * + * @param using + **/ + public withMiddleware (using: (middleware: Middleware) => void) { + // Register the middleware container/manager as a singleton + this.app.bind(MiddlewareHandler, () => new MiddlewareHandler()) + + // Default to no-op callback if none provided + using ??= () => true + + // After resolution, pass an instance of Middleware into the user callback + this.app.afterResolving(MiddlewareHandler, (handler) => { + using(new Middleware(handler)) + }) + + return this + } } \ No newline at end of file diff --git a/packages/core/tests/single-entry-point.test.ts b/packages/core/tests/single-entry-point.test.ts index a4663138..05e3b8fc 100644 --- a/packages/core/tests/single-entry-point.test.ts +++ b/packages/core/tests/single-entry-point.test.ts @@ -1,21 +1,16 @@ import { Application, ConfigException } from '@h3ravel/core' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { FileSystem } from '@h3ravel/shared' import { h3ravel } from '@h3ravel/core' let app: Application -let HttpProvider: any -let RouteProvider: any -const httpPath = FileSystem.findModulePkg('@h3ravel/http', process.cwd()) ?? '' -const routePath = FileSystem.findModulePkg('@h3ravel/router', process.cwd()) ?? '' console.log = vi.fn(() => 0) describe('Single Entry Point without @h3ravel/http installed', async () => { beforeEach(async () => { - RouteProvider = (await import(routePath)).RouteServiceProvider - app = await h3ravel([RouteProvider]) + const { RouteServiceProvider } = await import(('@h3ravel/router')) + app = await h3ravel([RouteServiceProvider]) }) it('returns the fully configured Application instance', async () => { @@ -29,9 +24,9 @@ describe('Single Entry Point without @h3ravel/http installed', async () => { describe('Single Entry Point with @h3ravel/http installed', async () => { beforeEach(async () => { - HttpProvider = (await import(httpPath)).HttpServiceProvider - RouteProvider = (await import(routePath)).RouteServiceProvider - app = await h3ravel([HttpProvider, RouteProvider]) + const { HttpServiceProvider } = await import(('@h3ravel/http')) + const { RouteServiceProvider } = await import(('@h3ravel/router')) + app = await h3ravel([HttpServiceProvider, RouteServiceProvider]) }) it('returns the fully configured Application instance', async () => { diff --git a/packages/foundation/src/Configuration/Middleware.ts b/packages/foundation/src/Configuration/Middleware.ts new file mode 100644 index 00000000..e194fde3 --- /dev/null +++ b/packages/foundation/src/Configuration/Middleware.ts @@ -0,0 +1,591 @@ +import { MiddlewareIdentifier, MiddlewareList, RedirectHandler } from '../Contracts/MiddlewareContract' + +import { Arr } from '@h3ravel/support' +import { MiddlewareHandler } from '../Http/MiddlewareHandler' + +/** + * Core Middleware configuration container. + * + * Use this class to programmatically build middleware lists and groups. + * + * - Middleware entries can either be strings (identifiers) or instances of the middleware class in this core version. + * - Callbacks/redirects accept string or () => string. + * - Group replace/remove/append/prepend behavior retained. + */ +export class Middleware { + /** + * The user defined global middleware stack. + */ + protected global: MiddlewareList = [] + /** + * The middleware that should be prepended to the global middleware stack + */ + protected prepends: MiddlewareList = [] + /** + * The middleware that should be appended to the global middleware stack. + */ + protected appends: MiddlewareList = [] + /** + * The middleware that should be removed from the global middleware stack. + */ + protected removals: MiddlewareList = [] + /** + * The middleware that should be replaced in the global middleware stack. + */ + protected replacements: Record = {} + + protected groups: Record = {} + protected groupPrepends: Record = {} + protected groupAppends: Record = {} + protected groupRemovals: Record = {} + protected groupReplacements: Record> = {} + + protected pageMiddleware: Record = {} + protected _priority: string[] = [] + protected _trustHosts = false + protected _statefulApi = false + protected _throttleWithRedis = false + protected apiLimiter: string | null = null + protected authenticatedSessions = false + protected customAliases: Record = {} + protected prependPriority: Record = {} + protected appendPriority: Record = {} + + constructor(public handler: MiddlewareHandler) { } + + /** + * Prepend middleware to the application's global middleware stack. + * + * @param middleware + * @returns + */ + public prepend (middleware: MiddlewareList | MiddlewareIdentifier): this { + this.prepends = [...Arr.wrap(middleware), ...this.prepends] + return this + } + + /** + * Append middleware to the application's global middleware stack. + * + * @param middleware + * @returns + */ + public append (middleware: MiddlewareList | MiddlewareIdentifier): this { + this.appends = [...this.appends, ...Arr.wrap(middleware)] + return this + } + + /** + * Remove middleware from the application's global middleware stack. + * + * @param middleware + * @returns + */ + public remove (middleware: MiddlewareList | MiddlewareIdentifier): this { + this.removals = [...this.removals, ...Arr.wrap(middleware)] + return this + } + + /** + * + * Specify a middleware that should be replaced with another middleware. + * + * @param search + * @param replaceWith + * @returns + */ + public replace (search: string, replaceWith: string): this { + this.replacements[search] = replaceWith + return this + } + + /** + * Define the global middleware for the application. + * + * @param middleware + * @returns + */ + public use (middleware: MiddlewareList): this { + this.global = [...middleware] + return this + } + + /** + * Define a middleware group. + * + * @param groupName + * @param middleware + * @returns + */ + public group (groupName: string, middleware: MiddlewareList): this { + this.groups[groupName] = [...middleware] + return this + } + + /** + * Prepend the given middleware to the specified group. + * + * @param group + * @param middleware + * @returns + */ + public prependToGroup (group: string, middleware: MiddlewareList | MiddlewareIdentifier): this { + this.groupPrepends[group] = [...Arr.wrap(middleware), ...(this.groupPrepends[group] ?? [])] + return this + } + + /** + * Append the given middleware to the specified group. + * + * @param group + * @param middleware + * @returns + */ + public appendToGroup (group: string, middleware: MiddlewareList | MiddlewareIdentifier): this { + this.groupAppends[group] = [...(this.groupAppends[group] ?? []), ...Arr.wrap(middleware)] + return this + } + + /** + * Remove the given middleware from the specified group. + * + * @param group + * @param middleware + * @returns + */ + public removeFromGroup (group: string, middleware: MiddlewareList | MiddlewareIdentifier): this { + this.groupRemovals[group] = [...Arr.wrap(middleware), ...(this.groupRemovals[group] ?? [])] + return this + } + + /** + * Replace the given middleware in the specified group with another middleware + * + * @param group + * @param search + * @param replaceWith + * @returns + */ + public replaceInGroup (group: string, search: string, replaceWith: string): this { + this.groupReplacements[group] = this.groupReplacements[group] ?? {} + this.groupReplacements[group][search] = replaceWith + return this + } + + /** + * Modify the middleware in the "web" group. + * + * @param append + * @param prepend + * @param remove + * @param replace + * @returns + */ + public web ( + append: MiddlewareList | MiddlewareIdentifier | [] = [], + prepend: MiddlewareList | MiddlewareIdentifier | [] = [], + remove: MiddlewareList | MiddlewareIdentifier | [] = [], + replace: Record = {} + ): this { + return this.modifyGroup('web', append, prepend, remove, replace) + } + + /** + * Modify the middleware in the "api" group. + * + * @param append + * @param prepend + * @param remove + * @param replace + * @returns + */ + public api ( + append: MiddlewareList | MiddlewareIdentifier | [] = [], + prepend: MiddlewareList | MiddlewareIdentifier | [] = [], + remove: MiddlewareList | MiddlewareIdentifier | [] = [], + replace: Record = {} + ): this { + return this.modifyGroup('api', append, prepend, remove, replace) + } + + /** + * Modify the middleware in the given group + * + * @param group + * @param append + * @param prepend + * @param remove + * @param replace + * @returns + */ + protected modifyGroup ( + group: string, + append: MiddlewareList | MiddlewareIdentifier | [], + prepend: MiddlewareList | MiddlewareIdentifier | [], + remove: MiddlewareList | MiddlewareIdentifier | [], + replace: Record + ): this { + if ((append as any) && (append as any).length !== 0) { + this.appendToGroup(group, append as any) + } + if ((prepend as any) && (prepend as any).length !== 0) { + this.prependToGroup(group, prepend as any) + } + if ((remove as any) && (remove as any).length !== 0) { + this.removeFromGroup(group, remove as any) + } + if (replace && Object.keys(replace).length) { + for (const [s, r] of Object.entries(replace)) { + this.replaceInGroup(group, s, r) + } + } + return this + } + /** + * Register the page middleware for the application. + * + * @param middleware + * @returns + */ + public pages (middleware: Record): this { + this.pageMiddleware = { ...middleware } + return this + } + + /** + * Register additional middleware aliases. + * + * @param aliases + * @returns + */ + public alias (aliases: Record): this { + this.customAliases = { ...aliases } + return this + } + + /** + * Define the middleware priority for the application. + * + * @param list + * @returns + */ + public priority (list: string[]): this { + this._priority = [...list] + return this + } + + /** + * Prepend middleware to the priority middleware. + * + * @param before + * @param prependKey + * @returns + */ + public prependToPriorityList (before: string, prependKey: string): this { + this.prependPriority[prependKey] = before + return this + } + + /** + * Append middleware to the priority middleware + * + * @param after + * @param appendKey + * @returns + */ + public appendToPriorityList (after: string, appendKey: string): this { + this.appendPriority[appendKey] = after + return this + } + + /** + * Get the global middleware list after applying prepends/appends/replacements/removals. + * + * @param defaults + * @returns + */ + public getGlobalMiddleware (defaults: MiddlewareList = []): MiddlewareList { + const middleware = this.global.length ? [...this.global] : Arr.whereNotNull(defaults) + + const replaced = middleware.map((m) => typeof m === 'string' ? (this.replacements[m] ?? m) : m) + + const merged = Arr.unique([...this.prepends, ...replaced, ...this.appends]) + + const result = merged.filter((m) => !this.removals.includes(m)) + + return result + } + + /** + * Build middleware groups with applied group-level replacements, removals, prepends, appends. + * + * @param defaultGroups + * @returns + */ + public getMiddlewareGroups (defaultGroups?: Record): Record { + const built: Record = {} + + // start with defaults if provided, else use current groups + const base = { ...(defaultGroups ?? {}), ...this.groups } + + for (const [group, list] of Object.entries(base)) { + // clone base list for mutations + let working = [...list] + + // apply group replacements + const groupRepl = this.groupReplacements[group] ?? {} + working = working.map((m) => typeof m === 'string' ? (groupRepl[m] ?? m) : m) + + // apply removals + const removals = this.groupRemovals[group] ?? [] + if (removals.length) { + working = working.filter((m) => !removals.includes(m)) + } + + // apply prepends / appends (unique) + const prepends = this.groupPrepends[group] ?? [] + const appends = this.groupAppends[group] ?? [] + working = Arr.unique([...prepends, ...working, ...appends]) + + built[group] = working + } + + return built + } + + /** + * Configure where guests are redirected by the "auth" middleware + * + * @param redirect + * @returns + */ + public redirectGuestsTo (redirect: RedirectHandler): this { + return this.redirectTo(redirect, undefined) + } + + /** + * Configure where users are redirected by the "guest" middleware + * + * @param redirect + * @returns + */ + public redirectUsersTo (redirect: RedirectHandler): this { + return this.redirectTo(undefined, redirect) + } + + /** + * Register redirect handlers; accepts string or () => string. + * In this core version, we only store them and do not wire into any concrete Authenticate classes. + */ + public redirectTo (guests?: RedirectHandler, users?: RedirectHandler): this { + // store as normalized lambdas on customAliases for demo purposes + if (guests) { + const guestKey = '__redirect_guests' + this.customAliases[guestKey] = typeof guests === 'string' ? guests : guests() + } + if (users) { + const userKey = '__redirect_users' + this.customAliases[userKey] = typeof users === 'string' ? users : users() + } + return this + } + + /** + * Configure the cookie encryption middleware. + * + * @param _ + * @returns + */ + public encryptCookies (_: MiddlewareList = []): this { + // placeholder for cookie encryption; + return this + } + + /** + * Configure the CSRF token validation middleware. + * + * @param _ + * @returns + */ + public validateCsrfTokens (_: MiddlewareList = []): this { + // placeholder + return this + } + + /** + * Configure the URL signature validation middleware + * + * @param _ + * @returns + */ + public validateSignatures (_: MiddlewareList = []): this { + // placeholder + return this + } + + /** + * Configure the empty string conversion middleware. + * + * @param _ + * @returns + */ + public convertEmptyStringsToNull (_: MiddlewareList = []): this { + // placeholder + return this + } + + /** + * Configure the string trimming middleware. + * + * @param _ + * @returns + */ + public trimStrings (_: MiddlewareList = []): this { + // placeholder + return this + } + + /** + * Indicate that the trusted host middleware should be enabled + * + * @param at + * @param subdomains + * @returns + */ + public trustHosts (at: any = null, subdomains = true): this { + this._trustHosts = true + return this + } + + /** + * Configure the trusted proxies for the application + * + * @param _ + * @param __ + * @returns + */ + public trustProxies (_: any = null, __: number | null = null): this { + return this + } + + /** + * Configure the middleware that prevents requests during maintenance mode + * + * @param _ + * @returns + */ + public preventRequestsDuringMaintenance (_: MiddlewareList = []): this { + return this + } + + /** + * Indicate that Sanctum's frontend state middleware should be enabled + * + * @returns + */ + public statefulApi (): this { + this._statefulApi = true + return this + } + + /** + * Indicate that the API middleware group's throttling middleware should be enabled + * + * @param limiter + * @param redis + * @returns + */ + public throttleApi (limiter: string = 'api', redis: boolean = false): this { + this.apiLimiter = limiter + if (redis) { + this._throttleWithRedis = true + } + return this + } + + /** + * Indicate that H3ravel's throttling middleware should use Redis + * + * @returns + */ + public throttleWithRedis (): this { + this._throttleWithRedis = true + return this + } + + /** + * Indicate that sessions should be authenticated for the "web" middleware group + * + * @returns + */ + public authenticateSessions (): this { + this.authenticatedSessions = true + return this + } + + /** + * Get the page middleware for the application + * + * @returns + */ + public getPageMiddleware (): Record { + return { ...this.pageMiddleware } + } + + /** + * Get the middleware aliases + * + * @returns + */ + public getMiddlewareAliases (): Record { + return { ...this.defaultAliases(), ...this.customAliases } + } + + /** + * Get the default middleware aliases + * + * @returns + */ + public defaultAliases (): Record { + const aliases: Record = { + auth: 'Authenticate', + 'auth.basic': 'AuthenticateWithBasicAuth', + 'auth.session': 'AuthenticateSession', + 'cache.headers': 'SetCacheHeaders', + can: 'Authorize', + guest: 'RedirectIfAuthenticated', + signed: 'ValidateSignature', + throttle: this._throttleWithRedis ? 'ThrottleRequestsWithRedis' : 'ThrottleRequests', + verified: 'EnsureEmailIsVerified', + } + + return aliases + } + + /** + * Get the middleware priority for the application + * + * @returns + */ + public getMiddlewarePriority (): string[] { + return [...this._priority] + } + + /** + * Get the middleware to prepend to the middleware priority definition + * + * @returns + */ + public getMiddlewarePriorityPrepends (): Record { + return { ...this.prependPriority } + } + + /** + * Get the middleware to append to the middleware priority definition + * + * @returns + */ + public getMiddlewarePriorityAppends (): Record { + return { ...this.appendPriority } + } +} diff --git a/packages/foundation/src/Contracts/MiddlewareContract.ts b/packages/foundation/src/Contracts/MiddlewareContract.ts new file mode 100644 index 00000000..9fed7222 --- /dev/null +++ b/packages/foundation/src/Contracts/MiddlewareContract.ts @@ -0,0 +1,5 @@ +import { IMiddleware } from '@h3ravel/shared' + +export type RedirectHandler = string | (() => string); +export type MiddlewareIdentifier = string | IMiddleware; +export type MiddlewareList = MiddlewareIdentifier[]; \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/Handler.ts b/packages/foundation/src/Exceptions/Handler.ts index 5e04d04c..214b29cc 100644 --- a/packages/foundation/src/Exceptions/Handler.ts +++ b/packages/foundation/src/Exceptions/Handler.ts @@ -1,30 +1,22 @@ /// -import { FileSystem, HttpContext, IRequest, IResponse } from '@h3ravel/shared' -import { LimitSpec, RateLimiterAdapter, Unlimited } from '../Contracts/RateLimiterAdapter' +import { ExceptionConditionCallback, ExceptionConstructor, FileSystem, HttpContext, IRequest, IResponse, RenderExceptionCallback, ReportExceptionCallback, ThrottleExceptionCallback } from '@h3ravel/shared' +import { LimitSpec, RateLimiterAdapter } from '../Contracts/RateLimiterAdapter' -import { HTTPResponse } from 'h3' import { InMemoryRateLimiter } from '../Adapters/InMemoryRateLimiter' import { readFileSync } from 'node:fs' -type Constructor = new (...args: any[]) => T -type ReportCallback = (error: any) => boolean | void | Promise -type RenderCallback = (error: any, ctx: HttpContext) => IResponse | Promise | undefined | null -type ConditionCallback = (error: any) => boolean -type ThrottleCallback = (error: any) => LimitSpec | Unlimited | null | undefined - /** * - * Notes: - * - This file purposely keeps the API surface familiar to Laravel-ish handlers, - * but trimmed to essentials for H3ravel. + * Base Exception Handler + * . * - We will use `RateLimiterAdapter` to plug in Redis / cache-backed limiters later. */ export abstract class Handler { /** * List of exception constructors that should not be reported. */ - protected dontReportList: Constructor[] = [] + protected dontReportList: ExceptionConstructor[] = [] /** * Log Level @@ -34,32 +26,32 @@ export abstract class Handler { /** * Internal exceptions that are not reported by default. Subclasses may expand. */ - protected internalDontReport: Constructor[] = [] + protected internalDontReport: ExceptionConstructor[] = [] /** * Callbacks that inspect exceptions to determine if they should NOT be reported. */ - protected dontReportCallbacks: ConditionCallback[] = [] + protected dontReportCallbacks: ExceptionConditionCallback[] = [] /** * Reportable callbacks (can cancel reporting by returning false). */ - protected reportCallbacks: ReportCallback[] = [] + protected reportCallbacks: ReportExceptionCallback[] = [] /** * Render callbacks (can return a Response for a specific exception type). */ - protected renderCallbacks: RenderCallback[] = [] + protected renderCallbacks: RenderExceptionCallback[] = [] /** * Exception mapping: from constructor -> mapper function (returns instance or new error). */ - protected exceptionMap = new Map any>() + protected exceptionMap = new Map any>() /** * Throttle callbacks: return limit spec or Unlimited or null */ - protected throttleCallbacks: ThrottleCallback[] = [] + protected throttleCallbacks: ThrottleExceptionCallback[] = [] /** * Context callbacks for building log context @@ -123,30 +115,30 @@ export abstract class Handler { * @param cb * @returns */ - public reportable (cb: ReportCallback) { + public reportable (cb: ReportExceptionCallback) { this.reportCallbacks.push(cb) return this } - public renderable (cb: RenderCallback) { + public renderable (cb: RenderExceptionCallback) { this.renderCallbacks.push(cb) return this } - public dontReport (exceptions: Constructor | Constructor[]) { + public dontReport (exceptions: ExceptionConstructor | ExceptionConstructor[]) { const arr = Array.isArray(exceptions) ? exceptions : [exceptions] this.dontReportList = Array.from(new Set([...this.dontReportList, ...arr])) return this } - public stopIgnoring (exceptions: Constructor | Constructor[]) { + public stopIgnoring (exceptions: ExceptionConstructor | ExceptionConstructor[]) { const arr = Array.isArray(exceptions) ? exceptions : [exceptions] this.dontReportList = this.dontReportList.filter((c) => !arr.includes(c)) this.internalDontReport = this.internalDontReport.filter((c) => !arr.includes(c)) return this } - public dontReportWhen (cb: ConditionCallback) { + public dontReportWhen (cb: ExceptionConditionCallback) { this.dontReportCallbacks.push(cb) return this } @@ -156,12 +148,12 @@ export abstract class Handler { return this } - public map (from: Constructor, mapper: (error: any) => any) { + public map (from: ExceptionConstructor, mapper: (error: any) => any) { this.exceptionMap.set(from, mapper) return this } - public throttleUsing (cb: ThrottleCallback) { + public throttleUsing (cb: ThrottleExceptionCallback) { this.throttleCallbacks.push(cb) return this } @@ -627,7 +619,7 @@ export abstract class Handler { * @param list * @returns */ - protected isInstanceOfAny (e: any, list: Constructor[]) { + protected isInstanceOfAny (e: any, list: ExceptionConstructor[]) { if (!e) return false for (const c of list) { try { diff --git a/packages/foundation/src/Http/MiddlewareHandler.ts b/packages/foundation/src/Http/MiddlewareHandler.ts new file mode 100644 index 00000000..466f9d70 --- /dev/null +++ b/packages/foundation/src/Http/MiddlewareHandler.ts @@ -0,0 +1,57 @@ +import { HttpContext, IMiddleware } from '@h3ravel/shared' + +import { Arr } from '@h3ravel/support' + +/* + * Handles registration and execution of middleware. + * Every middleware implements IMiddleware with a handle(context, next) method. + */ +export class MiddlewareHandler { + constructor(private middleware: IMiddleware[] = []) { } + + /** + * Registers a middleware instance. + * + * @param mw + */ + register (mw: IMiddleware | IMiddleware[]) { + this.middleware = Array.from(new Set([...this.middleware, ...Arr.wrap(mw)])) + + return this + } + + /** + * Runs the middleware chain for a given HttpContext. + * Each middleware must call next() to continue the chain. + * + * @param context - The current HttpContext. + * @param next - Callback to execute when middleware completes. + * @returns A promise resolving to the final handler's result. + */ + + async run ( + context: HttpContext, + next: (ctx: HttpContext) => Promise + ) { + let index = -1 + const dispatch = async (i: number): Promise => { + + if (i <= index) throw new Error('Middleware called next() multiple times') + + index = i + const current = this.middleware[i] + + /** + * If no more middleware, call the final handler + */ + if (!current) return next(context) + + /** + * Execute the current middleware and proceed to the next one + */ + return current.handle(context, () => dispatch(i + 1)) + } + + return dispatch(0) + } +} \ No newline at end of file diff --git a/packages/foundation/src/index.ts b/packages/foundation/src/index.ts index 526e9842..1ce3cab0 100644 --- a/packages/foundation/src/index.ts +++ b/packages/foundation/src/index.ts @@ -1,6 +1,8 @@ export * from './Exceptions/HttpException' export * from './Exceptions/HttpExceptionFactory' export * from './Adapters/InMemoryRateLimiter' +export * from './Configuration/Middleware' +export * from './Contracts/MiddlewareContract' export * from './Contracts/RateLimiterAdapter' export * from './Exceptions/AccessDeniedHttpException' export * from './Exceptions/BadRequestHttpException' @@ -19,4 +21,5 @@ export * from './Exceptions/ServiceUnavailableHttpException' export * from './Exceptions/TooManyRequestsHttpException' export * from './Exceptions/UnprocessableEntityHttpException' export * from './Exceptions/UnsupportedMediaTypeHttpException' +export * from './Http/MiddlewareHandler' export * from './Http/RequestException' diff --git a/packages/http/src/Middleware/FlashDataMiddleware.ts b/packages/http/src/Middleware/FlashDataMiddleware.ts new file mode 100644 index 00000000..b7b83b0d --- /dev/null +++ b/packages/http/src/Middleware/FlashDataMiddleware.ts @@ -0,0 +1,13 @@ +import { HttpContext } from '../HttpContext' +import { Middleware } from '../Middleware' + +export class FlashDataMiddleware extends Middleware { + async handle ({ request }: HttpContext, next: () => Promise): Promise { + + const _next = await next() + + request.session().ageFlashData() + + return _next + } +} diff --git a/packages/http/src/Middleware/LogRequests.ts b/packages/http/src/Middleware/LogRequests.ts index ed1f7d0c..b91c80f4 100644 --- a/packages/http/src/Middleware/LogRequests.ts +++ b/packages/http/src/Middleware/LogRequests.ts @@ -1,20 +1,19 @@ import { HttpContext } from '../HttpContext' import { Logger } from '@h3ravel/shared' import { Middleware } from '../Middleware' -import { toResponse } from 'h3' export class LogRequests extends Middleware { - async handle ({ request, event }: HttpContext, next: () => Promise): Promise { + async handle ({ request, response }: HttpContext, next: () => Promise): Promise { - await toResponse(await next(), event!) + const _next = await next() - // const code = Number(response.status) + const code = Number(response.getStatusCode()) const method = request.method().toLowerCase() - // let color = 'bgRed' + let color = 'bgRed' - // if (code < 200) color = 'bgWhite' - // else if (code >= 200 && code <= 300) color = 'bgBlue' - // else if (code >= 300 && code <= 400) color = 'bgOrange' + if (code < 200) color = 'bgWhite' + else if (code >= 200 && code <= 300) color = 'bgBlue' + else if (code >= 300 && code <= 400) color = 'bgOrange' let mColor = 'bgYellow' if (method == 'get') mColor = 'bgBlue' @@ -24,11 +23,10 @@ export class LogRequests extends Middleware { Logger.log([ [` ${method.toUpperCase()} `, mColor as never], [request.fullUrl(), 'white'], - // ['→', 'blue'], - // [` ${code} `, color as never] + ['→', 'blue'], + [` ${code} `, color as never] ], ' ') - // return next() - return + return _next } } diff --git a/packages/http/src/Request.ts b/packages/http/src/Request.ts index 0afe3201..35e025d4 100644 --- a/packages/http/src/Request.ts +++ b/packages/http/src/Request.ts @@ -349,7 +349,7 @@ export class Request< ? ISessionManager : K extends string ? any : void | Promise { - const session = new this.sessionManagerClass( + this.sessionManager ??= new this.sessionManagerClass( this.context, config('session.driver', 'file'), { @@ -363,15 +363,15 @@ export class Request< ) if (typeof key === 'string') { - return session.get(key, defaultValue) + return this.sessionManager.get(key, defaultValue) } else if (typeof key === 'object') { for (const [k, val] of Object.entries(key)) { - session.put(k, val) + this.sessionManager.put(k, val) } return undefined as any } - return session as any + return this.sessionManager as any } /** diff --git a/packages/http/src/Utilities/HttpRequest.ts b/packages/http/src/Utilities/HttpRequest.ts index 9aa10ab6..9fc915d8 100644 --- a/packages/http/src/Utilities/HttpRequest.ts +++ b/packages/http/src/Utilities/HttpRequest.ts @@ -137,6 +137,7 @@ export class HttpRequest { protected static httpMethodParameterOverride: boolean = false + protected sessionManager!: ISessionManager protected sessionManagerClass!: typeof ISessionManager /** diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index a67dcee7..e757d5d7 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -8,6 +8,7 @@ export * from './Exceptions/UnexpectedValueException' export * from './FormRequest' export * from './HttpContext' export * from './Middleware' +export * from './Middleware/FlashDataMiddleware' export * from './Middleware/LogRequests' export * from './Providers/HttpServiceProvider' export * from './Request' diff --git a/packages/router/src/Providers/RouteServiceProvider.ts b/packages/router/src/Providers/RouteServiceProvider.ts index 7e8d51dc..d7fde5bb 100644 --- a/packages/router/src/Providers/RouteServiceProvider.ts +++ b/packages/router/src/Providers/RouteServiceProvider.ts @@ -1,6 +1,6 @@ import { Logger } from '@h3ravel/shared' import { RouteListCommand } from '../Commands/RouteListCommand' -import { Router } from '../Route' +import { Router } from '../Router' import { ServiceProvider } from '@h3ravel/core' import path from 'node:path' import { readdir } from 'node:fs/promises' diff --git a/packages/router/src/Route.ts b/packages/router/src/Router.ts similarity index 96% rename from packages/router/src/Route.ts rename to packages/router/src/Router.ts index 08112219..f2d96cd7 100644 --- a/packages/router/src/Route.ts +++ b/packages/router/src/Router.ts @@ -44,22 +44,19 @@ export class Router implements IRouter { return ctx } + const globalMiddleware = this.app.has('app.globalMiddleware') + ? this.app.make('app.globalMiddleware') || [] + : [] + + const middlewareStack: IMiddleware[] = [ + ...globalMiddleware, + ...middleware, + ] + // Initialize the Application Kernel - const kernel = new Kernel(this.app.context, middleware) - - return kernel.handle(event, (ctx) => new Promise((resolve) => { - if (Resolver.isAsyncFunction(handler)) { - handler(ctx).then((response: any) => { - if (response instanceof Response) { - resolve(response.prepare(ctx.request as Request).send()) - } else { - resolve(response) - } - }) - } else { - resolve(handler(ctx)) - } - })) + const kernel = new Kernel(this.app, middlewareStack) + + return await kernel.resolve(event, middleware, handler) } } @@ -96,7 +93,7 @@ export class Router implements IRouter { const fullPath = `${this.groupPrefix}${path}`.replace(/\/+/g, '/') this.routes.push({ method, path: fullPath, name, handler, signature }) - this.h3App[method as 'get'](fullPath, this.resolveHandler(handler, middleware)) + this.h3App[method](fullPath, this.resolveHandler(handler, middleware)) this.app.singleton('app.routes', () => this.routes) } @@ -213,7 +210,6 @@ export class Router implements IRouter { */ private async handleResponse (handler: (ctx: HttpContext) => Promise, ctx: HttpContext): Promise { this.app.exceptionHandler ??= this.app.make(ExceptionHandler) - if (!this.app.exceptionHandler) { return await handler(ctx) } diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index ad0caf23..0eb4beca 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -3,4 +3,4 @@ export * from './Controller' export * from './Helpers' export * from './Providers/AssetsServiceProvider' export * from './Providers/RouteServiceProvider' -export * from './Route' +export * from './Router' diff --git a/packages/session/src/Contracts/SessionContract.ts b/packages/session/src/Contracts/SessionContract.ts index 1091beef..07eef2fc 100644 --- a/packages/session/src/Contracts/SessionContract.ts +++ b/packages/session/src/Contracts/SessionContract.ts @@ -1,3 +1,5 @@ +import { FlashBag } from '../FlashBag' + /** * SessionDriver Interface * @@ -5,13 +7,15 @@ * consistency across different storage mechanisms (memory, files, database, redis). */ export interface SessionDriver { + flashBag: FlashBag + /** * Retrieve a value from the session by key. * * @param key * @param defaultValue */ - get (key: string, defaultValue?: any): any | Promise + get (key: string, defaultValue?: any): T | Promise /** * Store multiple values in the session. @@ -21,6 +25,13 @@ export interface SessionDriver { */ set (value: Record): void | Promise + /** + * Retrieve all data from the session including flash + * + * @returns + */ + getAll> (): Promise | T + /** * Store a value in the session. * @@ -61,21 +72,21 @@ export interface SessionDriver { /** * Get all data from the session. */ - all (): Promise> | Record + all> (): Promise | T /** * Get only a subset of session keys. * * @param keys */ - only (keys: string[]): Promise> | Record + only> (keys: string[]): Promise | T /** * Get all session data except the specified keys. * * @param keys */ - except (keys: string[]): Promise> | Record + except> (keys: string[]): Promise | T /** * Get and remove an item from the session. @@ -83,7 +94,7 @@ export interface SessionDriver { * @param key * @param defaultValue */ - pull (key: string, defaultValue?: any): Promise | any + pull (key: string, defaultValue?: any): Promise | T /** * Increment a numeric session value. @@ -150,6 +161,11 @@ export interface SessionDriver { * Flush all session data */ flush (): Promise | void + + /** + * Age flash data at the end of the request lifecycle. + */ + ageFlashData (): Promise | void } export interface DriverOption { diff --git a/packages/session/src/FlashBag.ts b/packages/session/src/FlashBag.ts new file mode 100644 index 00000000..49b20944 --- /dev/null +++ b/packages/session/src/FlashBag.ts @@ -0,0 +1,151 @@ +/** + * FlashBag + * + * Manages flash data for session management, handling temporary data + * that persists for one request cycle. + */ +export class FlashBag { + /** + * Storage for flash data + * + * Structure: + * { + * new: { key1: value1, key2: value2 }, + * old: { key3: value3, key4: value4 } + * } + */ + private flashData: { + new: Record, + old: Record + } = { + new: {}, + old: {} + } + + /** + * Flash a value for the next request + * + * @param key Key to store in flash + * @param value Value to be flashed + */ + flash (key: string, value: any): void { + this.flashData.new[key] = value + } + + /** + * Store a temporary value for the current request only + * + * @param key Key to store + * @param value Value to store + */ + now (key: string, value: any): void { + // This is different from flash as it's not persisted to next request + this.flashData.new[key] = value + } + + /** + * Reflash all current flash data for another request cycle + */ + reflash (): void { + // Move current new flash data to old + this.flashData.old = { ...this.flashData.new } + } + + /** + * Keep only specific flash keys for the next request + * + * @param keys Keys to keep + */ + keep (keys: string[]): void { + const keptNew: Record = {} + const keptOld: Record = {} + + keys.forEach(key => { + if (this.flashData.new[key] !== undefined) { + keptNew[key] = this.flashData.new[key] + } + if (this.flashData.old[key] !== undefined) { + keptOld[key] = this.flashData.old[key] + } + }) + + this.flashData.new = keptNew + this.flashData.old = keptOld + } + + /** + * Age flash data at the end of the request + * + * - Removes old flash data + * - Moves new flash data to old + * - Clears new flash data + */ + ageFlashData (): void { + // Clear old flash data + this.flashData.old = {} + + // Move new flash data to old + this.flashData.old = { ...this.flashData.new } + + // Clear new flash data + this.flashData.new = {} + } + + /** + * Get a flash value + * + * @param key Key to retrieve + * @param defaultValue Default value if key doesn't exist + * @returns Flash value or default + */ + get (key: string, defaultValue?: any): any { + return this.flashData.new[key] + ?? this.flashData.old[key] + ?? defaultValue + } + + /** + * Check if a flash key exists + * + * @param key Key to check + * @returns Boolean indicating existence + */ + has (key: string): boolean { + return key in this.flashData.new || key in this.flashData.old + } + + /** + * Get all flash data + * + * @returns Combined flash data + */ + all (): Record { + return { ...this.flashData.old, ...this.flashData.new } + } + + /** + * Get all flash data keys + * + * @returns Combined flash data + */ + keys (): string[] { + return Object.keys({ ...this.flashData.old, ...this.flashData.new }) + } + + /** + * Get the raww flash data + * + * @returns raw flash data + */ + raw (): Record { + return this.flashData + } + + /** + * Clear all flash data + */ + clear (): void { + this.flashData.new = {} + this.flashData.old = {} + } +} diff --git a/packages/session/src/SessionManager.ts b/packages/session/src/SessionManager.ts index 8685640a..32a6bf0d 100644 --- a/packages/session/src/SessionManager.ts +++ b/packages/session/src/SessionManager.ts @@ -3,6 +3,7 @@ import { HttpContext, IRequest, ISessionManager } from '@h3ravel/shared' import { createHash, createHmac, randomBytes } from 'crypto' import { getCookie, setCookie } from 'h3' +import { FlashBag } from './FlashBag' import { SessionStore } from './SessionStore' /** @@ -16,6 +17,7 @@ export class SessionManager implements ISessionManager { private appKey: string private sessionId: string private request: IRequest + public flashBag: FlashBag /** * @param ctx - incoming request http context @@ -30,6 +32,7 @@ export class SessionManager implements ISessionManager { // Then instantiate the driver through the registry so different constructors are supported this.driver = SessionStore.make(driverName, driverOptions.sessionId ?? this.sessionId, driverOptions) + this.flashBag = this.driver.flashBag } /** @@ -274,4 +277,13 @@ export class SessionManager implements ISessionManager { invalidate () { return this.driver.invalidate() } + + /** + * Age flash data at the end of the request lifecycle. + * + * @returns + */ + ageFlashData () { + return this.driver.ageFlashData() + } } diff --git a/packages/session/src/drivers/DatabaseDriver.ts b/packages/session/src/drivers/DatabaseDriver.ts index d22e4905..6e0c8b4a 100644 --- a/packages/session/src/drivers/DatabaseDriver.ts +++ b/packages/session/src/drivers/DatabaseDriver.ts @@ -2,6 +2,7 @@ import { safeDot, setNested } from '@h3ravel/support' import { DB } from '@h3ravel/database' import { Driver } from './Driver' +import { FlashBag } from '../FlashBag' import { SessionDriver } from '../Contracts/SessionContract' /** @@ -22,48 +23,44 @@ export class DatabaseDriver extends Driver implements SessionDriver { } /** - * Helper: get the query builder for this table. + * Get the query builder for this table */ private query () { return DB.table(this.table).where('id', this.sessionId) } /** - * Fetch current payload - * - * @returns + * Fetch the session payload */ - protected async fetchPayload (): Promise> { + protected async fetchPayload> (): Promise { const row = await this.query().first() - - if (!row) return {} + if (!row) return {} as T try { - const encrypted = row.payload + const decrypted = this.encryptor.decrypt(row.payload) + const payload = typeof decrypted === 'string' ? JSON.parse(decrypted) : decrypted - if (!encrypted) return {} - const decrypted = this.encryptor.decrypt(encrypted) - return typeof decrypted === 'string' ? JSON.parse(decrypted) : decrypted + // Merge flash data with payload + return payload } catch { - return {} + return {} as T } } /** - * Save updated payload - * - * @param payload + * Save the session payload back to DB */ protected async savePayload (payload: Record) { + // Remove flash data before saving + // const { _flash, ...persistentPayload } = payload + const now = Math.floor(Date.now() / 1000) const exists = await this.query().exists() + const encrypted = this.encryptor.encrypt(JSON.stringify(payload)) if (exists) { - await this.query().update({ - payload: encrypted, - last_activity: now, - }) + await this.query().update({ payload: encrypted, last_activity: now }) } else { await DB.table(this.table).insert({ id: this.sessionId, @@ -74,33 +71,33 @@ export class DatabaseDriver extends Driver implements SessionDriver { } /** - * Retrieve a value from the session - * - * @param key - * @param defaultValue - * @returns + * Retrieve all data from the session including flash */ - async get (key: string, defaultValue?: any): Promise { + async getAll (): Promise { const payload = await this.fetchPayload() + const flash = payload._flash ?? { old: {}, new: {} } + return { ...payload, ...flash.old, ...flash.new } + } + + /** + * Get a value from the session + */ + async get (key: string, defaultValue?: any): Promise { + const payload = await this.getAll() return safeDot(payload, key) || defaultValue } - /** - * Store a value in the session - * - * @param key - * @param value + /** + * Set one or multiple session values */ - async set (value: Record): Promise { + async set (values: Record): Promise { const payload = await this.fetchPayload() - Object.assign(payload, value) + Object.assign(payload, values) await this.savePayload(payload) } - /** - * Store multiple key/value pairs - * - * @param values + /** + * Store a single key/value pair */ async put (key: string, value: any): Promise { const payload = await this.fetchPayload() @@ -108,11 +105,8 @@ export class DatabaseDriver extends Driver implements SessionDriver { await this.savePayload(payload) } - /** + /** * Append a value to an array key - * - * @param key - * @param value */ async push (key: string, value: any): Promise { const payload = await this.fetchPayload() @@ -121,10 +115,8 @@ export class DatabaseDriver extends Driver implements SessionDriver { await this.savePayload(payload) } - /** - * Remove a key from the session - * - * @param key + /** + * Forget a session key */ async forget (key: string): Promise { const payload = await this.fetchPayload() @@ -132,70 +124,52 @@ export class DatabaseDriver extends Driver implements SessionDriver { await this.savePayload(payload) } - /** - * Retrieve all session data - * - * @returns + /** + * Retrieve all session data (excluding flash) */ - async all (): Promise> { - return await this.fetchPayload() + async all> (): Promise { + return this.fetchPayload() } - /** - * Determine if a key exists (even if null). - * - * @param key - * @returns + /** + * Determine if a key exists (even if null) */ - async exists (key: string) { - const data = await this.fetchPayload() + async exists (key: string): Promise { + const data = await this.getAll() return Object.prototype.hasOwnProperty.call(data, key) } - /** - * Determine if a key has a non-null value. - * - * @param key - * @returns + /** + * Determine if a key has a non-null value */ - async has (key: string) { - const data = await this.fetchPayload() + async has (key: string): Promise { + const data = await this.getAll() return data[key] !== undefined && data[key] !== null } /** - * Get only specific keys. - * - * @param keys - * @returns + * Get only specific keys */ - async only (keys: string[]) { + async only> (keys: string[]): Promise { const data = await this.fetchPayload() const result: Record = {} keys.forEach(k => { if (k in data) result[k] = data[k] }) - return result + return result as T } /** - * Return all keys except the specified ones. - * - * @param keys - * @returns + * Return all except specific keys */ - async except (keys: string[]) { + async except> (keys: string[]): Promise { const data = await this.fetchPayload() keys.forEach(k => delete data[k]) - return data + return data as T } /** - * Return and delete a key from the session. - * - * @param key - * @param defaultValue - * @returns + * Retrieve and delete a value */ async pull (key: string, defaultValue: any = null) { const data = await this.fetchPayload() @@ -206,11 +180,7 @@ export class DatabaseDriver extends Driver implements SessionDriver { } /** - * Increment a numeric value by amount (default 1). - * - * @param key - * @param amount - * @returns + * Increment a numeric value */ async increment (key: string, amount = 1) { const data = await this.fetchPayload() @@ -221,72 +191,42 @@ export class DatabaseDriver extends Driver implements SessionDriver { } /** - * Decrement a numeric value by amount (default 1). - * - * @param key - * @param amount - * @returns + * Decrement a numeric value */ async decrement (key: string, amount = 1) { return this.increment(key, -amount) } /** - * Flash a value for next request only. - * - * @param key - * @param value + * Flash a value for next request only */ async flash (key: string, value: any) { - const data = await this.fetchPayload() - data._flash = data._flash || {} - data._flash[key] = value - await this.savePayload(data) + this.flashBag.flash(key, value) } /** - * Reflash all flash data for one more cycle. - * - * @returns + * Reflash all flash data for one more cycle */ async reflash () { - const data = await this.fetchPayload() - if (!data._flash) return - data._flash_keep = { ...data._flash } - await this.savePayload(data) + this.flashBag.reflash() } /** - * Keep only selected flash data. - * - * @param keys - * @returns + * Keep only specific flash keys */ async keep (keys: string[]) { - const data = await this.fetchPayload() - if (!data._flash) return - const kept: Record = {} - keys.forEach(k => { - if (data._flash[k]) kept[k] = data._flash[k] - }) - data._flash_keep = kept - await this.savePayload(data) + this.flashBag.keep(keys) } /** - * Store data only for current request cycle (not persisted). - * - * @param key - * @param value + * Store a temporary value (flash) for this request only (not persisted) */ async now (key: string, value: any) { - // Not persisted to DB — use in-memory only. - ; (global as any).__session_now = (global as any).__session_now || {} - ; (global as any).__session_now[key] = value + this.flashBag.now(key, value) } /** - * Regenerate session ID and persist data under new ID. + * Regenerate session ID with same data */ async regenerate () { const oldData = await this.fetchPayload() @@ -294,29 +234,34 @@ export class DatabaseDriver extends Driver implements SessionDriver { await this.savePayload(oldData) } - /** - * Determine if an item is not present in the session. - * - * @param key - * @returns + /** + * Check if a key is missing */ async missing (key: string): Promise { return !(await this.exists(key)) } - /** + /** * Flush all session data */ async flush (): Promise { await this.savePayload({}) } - /** - * Invalidate session completely and regenerate empty session. + /** + * Invalidate the session and regenerate */ async invalidate () { await DB.table(this.table).where('id', this.sessionId).delete() this.sessionId = crypto.randomUUID() + this.flashBag = new FlashBag() await this.savePayload({}) } -} \ No newline at end of file + + /** + * Age flash data at the end of the request lifecycle. + */ + async ageFlashData (): Promise { + this.flashBag.ageFlashData() + } +} diff --git a/packages/session/src/drivers/Driver.ts b/packages/session/src/drivers/Driver.ts index bc582836..0d7ad1b4 100644 --- a/packages/session/src/drivers/Driver.ts +++ b/packages/session/src/drivers/Driver.ts @@ -1,6 +1,7 @@ import { safeDot, setNested } from 'packages/support/dist' import { Encryption } from '../Encryption' +import { FlashBag } from '../FlashBag' import { SessionDriver } from '../Contracts/SessionContract' /** @@ -11,6 +12,10 @@ import { SessionDriver } from '../Contracts/SessionContract' export abstract class Driver implements SessionDriver { protected encryptor = new Encryption() protected sessionId!: string + public flashBag: FlashBag = new FlashBag() + + constructor() { + } /** * Invalidate session completely and regenerate empty session. @@ -22,14 +27,32 @@ export abstract class Driver implements SessionDriver { * * @returns */ - protected abstract fetchPayload (): Record + protected abstract fetchPayload> (loadFlash?: boolean): T | Promise /** * Save updated payload * * @param payload */ - protected abstract savePayload (payload: Record): void + protected abstract savePayload (payload: Record): void | Promise + + /** + * Save the raw session payload (session + flash) + */ + private saveRawPayload () { + this.savePayload(Object.assign({}, this.fetchPayload(), { _flash: this.flashBag.raw() })) + } + + /** + * Retrieve all data from the session including flash + * + * @returns + */ + getAll (): T | Promise { + const payload = this.fetchPayload() as Record + const flash = payload._flash ?? { old: {}, new: {} } + return { ...payload, ...flash.old, ...flash.new } + } /** * Retrieve a value from the session @@ -38,8 +61,8 @@ export abstract class Driver implements SessionDriver { * @param defaultValue * @returns */ - get (key: string, defaultValue?: any): Promise | any { - const payload = this.fetchPayload() as Record + get (key: string, defaultValue?: any): T | Promise { + const payload = this.getAll() as Record return safeDot(payload, key) || defaultValue } @@ -49,7 +72,7 @@ export abstract class Driver implements SessionDriver { * @param key * @param value */ - set (value: Record): Promise | void { + set (value: Record): void | Promise { const payload = this.fetchPayload() Object.assign(payload, value) return this.savePayload(payload) @@ -60,7 +83,7 @@ export abstract class Driver implements SessionDriver { * * @param values */ - put (key: string, value: any): void { + put (key: string, value: any): void | Promise { const payload = this.fetchPayload() setNested(payload, key, value) return this.savePayload(payload) @@ -72,8 +95,8 @@ export abstract class Driver implements SessionDriver { * @param key * @param value */ - push (key: string, value: any): void { - const payload = this.fetchPayload() + push (key: string, value: any): void | Promise { + const payload = this.fetchPayload() as Record if (!Array.isArray(payload[key])) payload[key] = [] payload[key].push(value) return this.savePayload(payload) @@ -84,8 +107,8 @@ export abstract class Driver implements SessionDriver { * * @param key */ - forget (key: string) { - const payload = this.fetchPayload() + forget (key: string): void | Promise { + const payload = this.fetchPayload() as Record delete payload[key] return this.savePayload(payload) } @@ -95,8 +118,8 @@ export abstract class Driver implements SessionDriver { * * @returns */ - all () { - return this.fetchPayload() + all> (): T | Promise { + return this.fetchPayload() as T } /** @@ -105,8 +128,8 @@ export abstract class Driver implements SessionDriver { * @param key * @returns */ - exists (key: string): Promise | boolean { - const data = this.fetchPayload() + exists (key: string): boolean | Promise { + const data = this.getAll() return Object.prototype.hasOwnProperty.call(data, key) } @@ -116,8 +139,8 @@ export abstract class Driver implements SessionDriver { * @param key * @returns */ - has (key: string): Promise | boolean { - const data = this.fetchPayload() + has (key: string): boolean | Promise { + const data = this.getAll() as Record return data[key] !== undefined && data[key] !== null } @@ -127,13 +150,13 @@ export abstract class Driver implements SessionDriver { * @param keys * @returns */ - only (keys: string[]) { - const data = this.fetchPayload() + only> (keys: string[]): T | Promise { + const data = this.fetchPayload() as Record const result: Record = {} keys.forEach(k => { if (k in data) result[k] = data[k] }) - return result + return result as T } /** @@ -142,10 +165,10 @@ export abstract class Driver implements SessionDriver { * @param keys * @returns */ - except (keys: string[]) { - const data = this.fetchPayload() + except> (keys: string[]): T | Promise { + const data = this.fetchPayload() as Record keys.forEach(k => delete data[k]) - return data + return data as T } /** @@ -155,8 +178,8 @@ export abstract class Driver implements SessionDriver { * @param defaultValue * @returns */ - pull (key: string, defaultValue: any = null) { - const data = this.fetchPayload() + pull (key: string, defaultValue: any = null): T | Promise { + const data = this.fetchPayload() as Record const value = data[key] ?? defaultValue delete data[key] this.savePayload(data) @@ -170,8 +193,8 @@ export abstract class Driver implements SessionDriver { * @param amount * @returns */ - increment (key: string, amount = 1): Promise | number { - const data = this.fetchPayload() + increment (key: string, amount = 1): number | Promise { + const data = this.fetchPayload() as Record const newVal = (parseFloat(data[key]) || 0) + amount data[key] = newVal this.savePayload(data) @@ -185,7 +208,7 @@ export abstract class Driver implements SessionDriver { * @param amount * @returns */ - decrement (key: string, amount = 1) { + decrement (key: string, amount = 1): number | Promise { return this.increment(key, -amount) } @@ -195,11 +218,9 @@ export abstract class Driver implements SessionDriver { * @param key * @param value */ - flash (key: string, value: any) { - const data = this.fetchPayload() - data._flash = data._flash || {} - data._flash[key] = value - this.savePayload(data) + flash (key: string, value: any): void | Promise { + this.flashBag.flash(key, value) + this.saveRawPayload() } /** @@ -207,11 +228,9 @@ export abstract class Driver implements SessionDriver { * * @returns */ - reflash () { - const data = this.fetchPayload() - if (!data._flash) return - data._flash_keep = { ...data._flash } - this.savePayload(data) + reflash (): void | Promise { + this.flashBag.reflash() + this.saveRawPayload() } /** @@ -220,45 +239,47 @@ export abstract class Driver implements SessionDriver { * @param keys * @returns */ - keep (keys: string[]) { - const data = this.fetchPayload() - if (!data._flash) return - const kept: Record = {} - keys.forEach(k => { - if (data._flash[k]) kept[k] = data._flash[k] - }) - data._flash_keep = kept - this.savePayload(data) + keep (keys: string[]): void | Promise { + this.flashBag.keep(keys) + this.saveRawPayload() } /** - * Store data only for current request cycle (not persisted). + * Store a temporary value (flash) for this request only (not persisted) * * @param key * @param value */ - now (key: string, value: any) { - // Not persisted to DB — use in-memory only. - ; (global as any).__session_now = (global as any).__session_now || {} - ; (global as any).__session_now[key] = value + now (key: string, value: any): void | Promise { + this.flashBag.now(key, value) + this.saveRawPayload() } /** * Regenerate session ID and persist data under new ID. */ - regenerate () { + regenerate (): void | Promise { const oldData = this.fetchPayload() this.sessionId = crypto.randomUUID() this.savePayload(oldData) } + /** + * Age flash data at the end of the request lifecycle. + */ + ageFlashData (): void | Promise { + const data = this.flashBag.ageFlashData() + this.saveRawPayload() + return data + } + /** * Determine if an item is not present in the session. * * @param key * @returns */ - missing (key: string): Promise | boolean { + missing (key: string): boolean | Promise { return !this.exists(key) } diff --git a/packages/session/src/drivers/FileDriver.ts b/packages/session/src/drivers/FileDriver.ts index b8ac3ee8..335d6bbf 100644 --- a/packages/session/src/drivers/FileDriver.ts +++ b/packages/session/src/drivers/FileDriver.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' import { Driver } from './Driver' +import { FlashBag } from '../FlashBag' import { SessionDriver } from '../Contracts/SessionContract' import path from 'path' @@ -46,31 +47,54 @@ export class FileDriver extends Driver implements SessionDriver { } /** - * Read and decrypt session data from file. + * Read raw decrypted payload (including _flash). */ - protected fetchPayload (): Record { + private readRawPayload (): Record { const file = this.sessionFilePath() if (!existsSync(file)) return {} const content = readFileSync(file, 'utf8') - return this.encryptor.decrypt(content) + try { + return this.encryptor.decrypt(content) + } catch { + return {} + } + } + + /** + * Fetch decrypted payload and strip out flash metadata. + */ + protected fetchPayload> (): T { + const payload = this.readRawPayload() + // Merge flash data with payload + return payload as T } /** * Write and encrypt session data to file. + * Always persists flash state. + * + * @param data */ - protected savePayload (data: Record): void { + protected savePayload (payload: Record): void { const file = this.sessionFilePath() - const encrypted = this.encryptor.encrypt(data) + + // Remove flash data before saving + // const { _flash, ...persistentPayload } = payload + + const encrypted = this.encryptor.encrypt(payload) writeFileSync(file, encrypted, 'utf8') } - /** - * Invalidate session completely and regenerate empty session. + /** + * Completely invalidate the current session and regenerate a new one. */ - invalidate () { + invalidate (): void { const file = this.sessionFilePath() - rmSync(file, { recursive: true }) + if (existsSync(file)) { + rmSync(file, { recursive: true }) + } this.sessionId = crypto.randomUUID() + this.flashBag = new FlashBag() this.savePayload({}) } -} +} \ No newline at end of file diff --git a/packages/session/src/drivers/MemoryDriver.ts b/packages/session/src/drivers/MemoryDriver.ts index 9d170861..f0d2e04e 100644 --- a/packages/session/src/drivers/MemoryDriver.ts +++ b/packages/session/src/drivers/MemoryDriver.ts @@ -1,5 +1,7 @@ import { Driver } from './Driver' +import { FlashBag } from '../FlashBag' import { SessionDriver } from '../Contracts/SessionContract' +import crypto from 'crypto' /** * MemoryDriver @@ -19,28 +21,36 @@ export class MemoryDriver extends Driver implements SessionDriver { } /** - * Read and decrypt session data from file. + * Fetch and return session payload. + * + * @returns Decrypted and usable payload */ protected fetchPayload (): Record { - return { ...MemoryDriver.store[this.sessionId] } + const payload = { ...MemoryDriver.store[this.sessionId] } + + // Merge flash data with payload + return payload } /** - * Write and encrypt session data to file. + * Persist session payload and flash bag state. + * + * @param data */ - protected savePayload (data: Record): void { - MemoryDriver.store[this.sessionId] = Object.entries(data).length < 1 ? {} : { - ...MemoryDriver.store[this.sessionId], - ...data, - } + protected savePayload (payload: Record): void { + // Remove flash data before saving + // const { _flash, ...persistentPayload } = payload + + MemoryDriver.store[this.sessionId] = { ...payload } } - /** - * Invalidate session completely and regenerate empty session. + /** + * Invalidate current session and regenerate new session ID. */ - invalidate () { + invalidate (): void { delete MemoryDriver.store[this.sessionId] this.sessionId = crypto.randomUUID() + this.flashBag = new FlashBag() this.savePayload({}) } } diff --git a/packages/session/src/drivers/RedisDriver.ts b/packages/session/src/drivers/RedisDriver.ts index 47aa3a9c..1a0a3acd 100644 --- a/packages/session/src/drivers/RedisDriver.ts +++ b/packages/session/src/drivers/RedisDriver.ts @@ -1,37 +1,46 @@ -import { Encryption } from '../Encryption' import { SessionDriver } from '../Contracts/SessionContract' +import { FlashBag } from '../FlashBag' +import { Driver } from './Driver' /** * RedisDriver (placeholder) */ -export class RedisDriver implements SessionDriver { - private store: Record> = {} - private encryptor = new Encryption() +export class RedisDriver extends Driver implements SessionDriver { + private static store: Record> = {} constructor( /** * The current session ID */ - private sessionId: string, - private redisClient?: 'RedisClient', - private prefix?: string - ) { } - - get (key: string, defaultValue: any = null) { - return defaultValue + protected sessionId: string, + protected redisClient?: 'RedisClient', + protected prefix?: string + ) { + super() } - set (key: string, value: any) { } - - all () { + /** + * Fetch and return session payload. + * + * @returns Decrypted and usable payload + */ + protected fetchPayload (): Record { return {} } - put (values: Record) { } - - push (key: string, value: any) { } - - forget (key: string) { } + /** + * Persist session payload and flash bag state. + * + * @param data + */ + protected savePayload (payload: Record): void { + } - flush () { } + /** + * Invalidate current session and regenerate new session ID. + */ + invalidate (): void { + this.flashBag = new FlashBag() + this.savePayload({}) + } } \ No newline at end of file diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 196c7cc1..d47e2cef 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -7,6 +7,7 @@ export * from './drivers/FileDriver' export * from './drivers/MemoryDriver' export * from './drivers/RedisDriver' export * from './Encryption' +export * from './FlashBag' export * from './Providers/SessionServiceProvider' export * from './SessionManager' export * from './SessionStore' diff --git a/packages/session/tests/database.spec.ts b/packages/session/tests/database.spec.ts index d86d653c..a28dd930 100644 --- a/packages/session/tests/database.spec.ts +++ b/packages/session/tests/database.spec.ts @@ -161,32 +161,26 @@ describe('@h3ravel/session Database Driver', () => { it('flashes data for the next request', async () => { await driver.flash('flashKey', 'flashVal') - const session = await DB.table(table).where('id', sessionId).first() - const payload = encryptor.decrypt(session.payload) - expect(payload._flash.flashKey).toBe('flashVal') + expect(driver.flashBag.get('flashKey')).toBe('flashVal') }) it('reflashes data', async () => { await driver.flash('f1', 'val') await driver.reflash() - const session = await DB.table(table).where('id', sessionId).first() - const payload = encryptor.decrypt(session.payload) - expect(payload._flash_keep.f1).toBe('val') + expect(driver.flashBag.get('f1')).toBe('val') }) it('keeps selected flash keys', async () => { await driver.flash('keep1', 'val1') await driver.flash('keep2', 'val2') await driver.keep(['keep1']) - const session = await DB.table(table).where('id', sessionId).first() - const payload = encryptor.decrypt(session.payload) - expect(payload._flash_keep).toHaveProperty('keep1') - expect(payload._flash_keep).not.toHaveProperty('keep2') + expect(driver.flashBag.all()).toHaveProperty('keep1') + expect(driver.flashBag.all()).not.toHaveProperty('keep2') }) it('stores temporary data with now()', async () => { await driver.now('tmp', 'one-time') - expect((global as any).__session_now.tmp).toBe('one-time') + expect(driver.flashBag.get('tmp')).toBe('one-time') }) it('regenerates session id while keeping data', async () => { diff --git a/packages/session/tests/file.spec.ts b/packages/session/tests/file.spec.ts index 883110f1..f9cae097 100644 --- a/packages/session/tests/file.spec.ts +++ b/packages/session/tests/file.spec.ts @@ -157,7 +157,7 @@ describe('@h3ravel/session FileDriver', () => { it('stores temporary data with now()', async () => { await session.now('tmp', 'one-time') - expect((global as any).__session_now.tmp).toBe('one-time') + expect(session.flashBag.get('tmp')).toBe('one-time') }) it('determine if an item is not present in the session', async () => { diff --git a/packages/session/tests/memory.spec.ts b/packages/session/tests/memory.spec.ts index 76257787..f76e0717 100644 --- a/packages/session/tests/memory.spec.ts +++ b/packages/session/tests/memory.spec.ts @@ -167,7 +167,7 @@ describe('@h3ravel/session MemoryDriver', () => { it('stores temporary data with now()', async () => { await session.now('tmp', 'one-time') - expect((global as any).__session_now.tmp).toBe('one-time') + expect(session.flashBag.get('tmp')).toBe('one-time') }) it('determine if an item is not present in the session', async () => { diff --git a/packages/session/tests/session.spec.ts b/packages/session/tests/session.spec.ts deleted file mode 100644 index 17cf6e91..00000000 --- a/packages/session/tests/session.spec.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { Application, h3ravel } from '@h3ravel/core' -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { existsSync, readFileSync } from 'node:fs' - -import { DB } from '@h3ravel/database' -import { DatabaseDriver } from '../src' -import { Encryption } from '../src/Encryption' -import { HttpContext } from '@h3ravel/shared' -import { SessionManager } from '../src/SessionManager' -import { SessionServiceProvider } from '../src/Providers/SessionServiceProvider' -import path from 'node:path' -import { rmdir } from 'node:fs/promises' - -let ctx: HttpContext -let app: Application -let event: any -const appKey = 'base64:dnZm+Ei7ExEHzhj/wO/3YKUckMQtpLjRVk1VLYiV/es=' - -function makeEvent (overides: Record = {}) { - return { - res: { headers: new Headers(), statusCode: 200, cookie: () => { } }, - req: { - headers: new Headers({ - 'user-agent': 'Vitest', - 'x-forwarded-for': '127.0.0.1' - }), - url: overides.url ?? 'http://localhost/test', method: 'get' - }, - } as any -} - -describe('@h3ravel/session MemoryDriver', () => { - let session: SessionManager - - beforeAll(async () => { - const { DatabaseServiceProvider } = (await import(('@h3ravel/database'))) - const { HttpServiceProvider } = (await import(('@h3ravel/http'))) - const { ConfigServiceProvider } = (await import(('@h3ravel/config'))) - const { RouteServiceProvider } = (await import(('@h3ravel/router'))) - app = await h3ravel( - [HttpServiceProvider, DatabaseServiceProvider, ConfigServiceProvider, RouteServiceProvider, SessionServiceProvider], - path.join(process.cwd(), 'packages/session/tests'), - { - autoload: false, - customPaths: { - config: 'config', - routes: 'routes', - } - }) - }) - - beforeEach(async () => { - event = makeEvent() - const { Request, Response, HttpContext } = (await import(('@h3ravel/http'))) - - ctx = HttpContext.init({ - app, - request: await Request.create(event, app), - response: new Response(event, app), - }, event) - - process.env.APP_KEY = appKey - - session = new SessionManager(ctx, 'memory') - }) - - it('can persist sessions', async () => { - const data = { name: 'string' } - const session = new SessionManager(ctx, 'memory') - session.put('app', data) - - expect(session.get('app')).toMatchObject(data) - }) - - it('can encrypt and decrypt using APP_KEY', async () => { - const str = 'Hello World' - const encryptor = new Encryption() - const enc = encryptor.encrypt(str) - const dec = encryptor.decrypt(enc) - - expect(typeof enc === 'string').toBeTruthy() - expect(typeof dec === 'string').toBeTruthy() - expect(dec).toBe(str) - }) - - it('should generate a session ID', () => { - expect(session.id()).toBeTypeOf('string') - expect(session.id().length).toBeGreaterThan(0) - }) - - it('should set and get a value', () => { - session.put('foo', 'bar') - expect(session.get('foo')).toBe('bar') - }) - - it('should push to an array', () => { - session.put('arr', []) - session.push('arr', 'x') - session.push('arr', 'y') - expect(session.get('arr')).toEqual(['x', 'y']) - }) - - it('should flush all data', () => { - session.put('foo', 'bar') - session.flush() - expect(session.all()).toEqual({}) - }) - - it('should forget a key', () => { - session.put('temp', 123) - session.forget('temp') - expect(session.get('temp')).toBeUndefined() - }) - - it('should set multiple values', () => { - session.set({ a: 1, b: 2 }) - expect(session.get('a')).toBe(1) - expect(session.get('b')).toBe(2) - }) - - it('returns default value when key not found', async () => { - const result = await session.get('missing', 'default') - expect(result).toBe('default') - }) - - it('checks if key exists and has', async () => { - await session.put('existsKey', null) - await session.put('hasKey', 'something') - expect(await session.exists('existsKey')).toBe(true) - expect(await session.has('existsKey')).toBe(false) - expect(await session.has('hasKey')).toBe(true) - }) - - it('forgets a key', async () => { - await session.put('temp', 'gone') - await session.forget('temp') - const val = await session.get('temp') - expect(val).toBeOneOf([null, undefined]) - }) - - it('returns only specific keys', async () => { - await session.put('a', 1) - await session.put('b', 2) - const result = await session.only(['a']) - expect(result).toEqual({ a: 1 }) - }) - - it('returns all except specified keys', async () => { - await session.put('a', 1) - await session.put('b', 2) - const result = await session.except(['b']) - expect(result).toEqual({ a: 1 }) - }) - - it('pulls and removes a key', async () => { - await session.put('pullable', 'data') - const val = await session.pull('pullable') - expect(val).toBe('data') - expect(await session.exists('pullable')).toBe(false) - }) - - - it('increments and decrements values', async () => { - await session.put('counter', 1) - await session.increment('counter', 2) - expect(await session.get('counter')).toBe(3) - await session.decrement('counter', 1) - expect(await session.get('counter')).toBe(2) - }) - - - it('stores temporary data with now()', async () => { - await session.now('tmp', 'one-time') - expect((global as any).__session_now.tmp).toBe('one-time') - }) - - it('determine if an item is not present in the session', async () => { - await session.put('present', 1) - const missing = await session.missing('absent') - expect(missing).toEqual(true) - }) -}) \ No newline at end of file diff --git a/packages/shared/src/Contracts/IContainer.ts b/packages/shared/src/Contracts/IContainer.ts index 1ea9f660..9ced7251 100644 --- a/packages/shared/src/Contracts/IContainer.ts +++ b/packages/shared/src/Contracts/IContainer.ts @@ -1,40 +1,70 @@ import type { Bindings, UseKey } from './BindingsContract' +import { IExceptionHandler } from './IExceptionHandler' +import { IMiddlewareHandler } from './IMiddlewareHandler' + +export type IContainerBinding = UseKey | (new (..._args: any[]) => unknown) /** * Interface for the Container contract, defining methods for dependency injection and service resolution. */ export interface IContainer { + bindings: Map unknown> + singletons: Map + exceptionHandler?: IExceptionHandler + middlewareHandler?: IMiddlewareHandler + /** * Binds a transient service to the container. + * * @param key - The key or constructor for the service. * @param factory - The factory function to create the service instance. */ bind (key: new (...args: any[]) => T, factory: () => T): void; bind (key: T, factory: () => Bindings[T]): void; + /** + * Remove one or more transient services from the container + * + * @param key + */ + unbind (key: T | T[]): void + /** * Binds a singleton service to the container. * @param key - The key or constructor for the service. * @param factory - The factory function to create the singleton instance. */ singleton ( - key: T | (new (...args: any[]) => Bindings[T]), - factory: () => Bindings[T] + key: T | (new (..._args: any[]) => Bindings[T]), + factory: (app: this) => Bindings[T] ): void; /** * Resolves a service from the container. + * * @param key - The key or constructor for the service. * @returns The resolved service instance. */ - make ( - key: T | (new (..._args: any[]) => Bindings[T]) - ): X extends undefined ? Bindings[T] : X + make (key: T): Bindings[T] + make any> (key: C): InstanceType + make any> (key: F): ReturnType/** + + * Register a callback to be executed after a service is resolved + * + * @param key + * @param callback + */ + afterResolving ( + key: T | (new (..._args: any[]) => Bindings[T]), + callback: (resolved: Bindings[T], app: this) => void + ): void /** * Checks if a service is registered in the container. * @param key - The key to check. * @returns True if the service is registered, false otherwise. */ - has (key: UseKey): boolean; + has any> (key: C): boolean + has any> (key: F): boolean + has (key: T): boolean } diff --git a/packages/shared/src/Contracts/IExceptionHandler.ts b/packages/shared/src/Contracts/IExceptionHandler.ts new file mode 100644 index 00000000..ff0242cf --- /dev/null +++ b/packages/shared/src/Contracts/IExceptionHandler.ts @@ -0,0 +1,100 @@ +import { HttpContext } from './IHttp' +import { IRequest } from './IRequest' +import { IResponse } from './IResponse' + +export type ExceptionLimitSpec = { + key?: string; + maxAttempts: number; + decaySeconds: number; +}; +export type ExceptionLUnlimited = { + unlimited: true; +}; +/** + * Rate Limiter Adapter Interface + */ +export interface RateLimiterAdapter { + /** + * Attempt a key with a maxAttempts and decaySeconds. + * + * Return true if this is allowed (i.e., *not* throttled), + * false if the limit is reached. + */ + attempt (key: string, maxAttempts: number, allowCallback: () => boolean | Promise, decaySeconds: number): Promise; +} + +export type ExceptionConstructor = new (...args: any[]) => T +export type ExceptionConditionCallback = (error: any) => boolean; +export type RenderExceptionCallback = (error: any, ctx: HttpContext) => IResponse | Promise | undefined | null; +export type ReportExceptionCallback = (error: any) => boolean | void | Promise; +export type ThrottleExceptionCallback = (error: any) => ExceptionLimitSpec | ExceptionLUnlimited | null | undefined; + +export declare abstract class IExceptionHandler { + /** + * The exception handler method + * + * @param error + * @param ctx + */ + handle?(error: Error, ctx: HttpContext): Promise; + /** + * Register a reportable callback handler + * + * @param cb + * @returns + */ + reportable (cb: ReportExceptionCallback): this; + renderable (cb: RenderExceptionCallback): this; + dontReport (exceptions: ExceptionConstructor | ExceptionConstructor[]): this; + stopIgnoring (exceptions: ExceptionConstructor | ExceptionConstructor[]): this; + dontReportWhen (cb: ExceptionConditionCallback): this; + dontReportDuplicates (): this; + map (from: ExceptionConstructor, mapper: (error: any) => any): this; + throttleUsing (cb: ThrottleExceptionCallback): this; + buildContextUsing (cb: (e: any, current?: Record) => Record): this; + setRateLimiter (adapter: RateLimiterAdapter): this; + respondUsing (cb: (response: IResponse, error: any, request: IRequest) => IResponse | Promise): this; + shouldRenderJsonWhen (cb: (request: IRequest, error: any) => boolean): this; + /** + * Entry point to reporting an exception. + * + * @param error + * @returns + */ + report (error: any): Promise; + /** + * Render an exception into an HTTP Response. + * + * @param ctx + * @param error + * @returns + */ + render (ctx: HttpContext, error: any): Promise; + /** + * getResponse + */ + getResponse ({ + request + }: HttpContext, payload: Record, e: any): IResponse | Promise; + /** + * Not implemented in core. Subclass can implement and call RequestException helpers. + * + * @param _length + */ + truncateRequestExceptionsAt (_length: number): this; + /** + * Set the log level + * + * @param _attributes + */ + level (type: string, level: string): { + level: string; + type: string; + }; + /** + * Not implemented here; applicable to validation pipeline/UI. + * + * @param _attributes + */ + dontFlash (_attributes: string | string[]): this; +} \ No newline at end of file diff --git a/packages/shared/src/Contracts/IMiddlewareHandler.ts b/packages/shared/src/Contracts/IMiddlewareHandler.ts new file mode 100644 index 00000000..afe5b5a4 --- /dev/null +++ b/packages/shared/src/Contracts/IMiddlewareHandler.ts @@ -0,0 +1,19 @@ +import { HttpContext, IMiddleware } from './IHttp' + +export declare class IMiddlewareHandler { + /** + * Registers a middleware instance. + * + * @param mw + */ + register (mw: IMiddleware | IMiddleware[]): this; + /** + * Runs the middleware chain for a given HttpContext. + * Each middleware must call next() to continue the chain. + * + * @param context - The standardized HttpContext. + * @param next - Callback to execute when middleware completes. + * @returns A promise resolving to the final handler's result. + */ + run (context: HttpContext, next: (ctx: HttpContext) => Promise): Promise; +} \ No newline at end of file diff --git a/packages/shared/src/Contracts/ISessionManager.ts b/packages/shared/src/Contracts/ISessionManager.ts index c43999a6..1c2cfbf2 100644 --- a/packages/shared/src/Contracts/ISessionManager.ts +++ b/packages/shared/src/Contracts/ISessionManager.ts @@ -150,4 +150,10 @@ export declare class ISessionManager { * Flush all session data */ flush (): void | Promise; + /** + * Age flash data at the end of the request lifecycle. + * + * @returns + */ + ageFlashData (): void | Promise; } \ No newline at end of file diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4f103fe3..0f1d096c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,8 +1,10 @@ export * from './Contracts/BindingsContract' export * from './Contracts/IApplication' export * from './Contracts/IContainer' +export * from './Contracts/IExceptionHandler' export * from './Contracts/IHttp' export * from './Contracts/IHttpResponse' +export * from './Contracts/IMiddlewareHandler' export * from './Contracts/IParamBag' export * from './Contracts/IRequest' export * from './Contracts/IResponse' diff --git a/packages/support/src/Helpers/Arr.ts b/packages/support/src/Helpers/Arr.ts index 225f6214..b449ef61 100644 --- a/packages/support/src/Helpers/Arr.ts +++ b/packages/support/src/Helpers/Arr.ts @@ -712,9 +712,14 @@ export class Arr { */ static whereNotNull ( array: T[], - key: keyof T + key?: keyof T ): T[] { - if (!Array.isArray(array)) return [] + if (!Array.isArray(array)) + return [] + + if (!key) + return array.filter((item) => item !== null && item !== undefined) + return array.filter(item => (item[key] !== null && item[key] !== undefined)) } diff --git a/packages/support/src/Helpers/Obj.ts b/packages/support/src/Helpers/Obj.ts index 7888a7c4..c7b12984 100644 --- a/packages/support/src/Helpers/Obj.ts +++ b/packages/support/src/Helpers/Obj.ts @@ -126,12 +126,16 @@ export const modObj = ( ) as Record } - -export function safeDot> (_data: T): T +/** + * Safely convert an object to dot notation + * + * @param data + */ +export function safeDot> (data: T): T export function safeDot< T extends Record, K extends DotNestedKeys -> (_data: T, _key?: K): DotNestedValue +> (data: T, key?: K): DotNestedValue export function safeDot< T extends Record, K extends DotNestedKeys @@ -227,6 +231,9 @@ export const slugifyKeys = ( * - Arrays: included if truthy * - Objects: keys included if value is truthy * - Strings: included as-is + * + * @param input + * @returns */ export function toCssClasses | Array> ( input: T @@ -256,6 +263,9 @@ export function toCssClasses | Array< * * Convert object input into CSS style string. * - Only includes truthy values (ignores null/undefined/false) + * + * @param styles + * @returns */ export function toCssStyles> (styles: T): string { const parts: string[] = [] @@ -273,6 +283,9 @@ export function toCssStyles { a: { b: 1 }, c: [2] } + * + * @param obj + * @returns */ export function undot (obj: Record): Record { const result: Record = {} @@ -306,6 +319,11 @@ export function undot (obj: Record): Record { * data_get * * Get a value from an object using dot notation. + * + * @param obj + * @param path + * @param defaultValue + * @returns */ export function data_get< T extends object, @@ -326,6 +344,10 @@ export function data_get< * data_set * * Set a value in an object using dot notation. Mutates the object. + * + * @param obj + * @param path + * @param value */ export function data_set< T extends Record, @@ -351,6 +373,10 @@ export function data_set< * data_fill * * Like data_set, but only sets the value if the key does NOT exist. + * + * @param obj + * @param path + * @param value */ export function data_fill ( obj: Record, @@ -366,6 +392,9 @@ export function data_fill ( * data_forget * * Remove a key from an object using dot notation. + * + * @param obj + * @param path */ export function data_forget ( obj: Record, @@ -388,13 +417,14 @@ export function data_forget ( * Checks if a value is a plain object (not array, function, etc.) * * @param value + * @param allowArray * @returns */ -export function isPlainObject (value: any): value is Record { +export function isPlainObject

(value: P, allowArray?: boolean): value is P { return ( value !== null && typeof value === 'object' && - !Array.isArray(value) && + (Array.isArray(value) === false || allowArray === true) && Object.prototype.toString.call(value) === '[object Object]' ) } @@ -402,6 +432,9 @@ export function isPlainObject (value: any): value is Record { export class Obj { /** * Check if the value is a non-null object (associative/accessible). + * + * @param value + * @returns */ static accessible (value: unknown): value is Record { return value !== null && typeof value === 'object' @@ -411,6 +444,11 @@ export class Obj { * Add a key-value pair to an object only if the key does not already exist. * * Returns a new object (does not mutate original). + * + * @param obj + * @param key + * @param value + * @returns */ static add, K extends string, V> ( obj: T, @@ -456,6 +494,9 @@ export class Obj { /** * Split object into [keys, values] + * + * @param obj + * @returns */ static divide> (obj: T): [string[], any[]] { const keys = Object.keys(obj) @@ -463,8 +504,37 @@ export class Obj { return [keys, values] } + /** + * Flattens a nested object into a single-level object + * with dot-separated keys. + * + * Example: + * dot({ + * user: { name: "John", address: { city: "NY" } }, + * active: true + * }) + * + * Output: + * { + * "user.name": "John", + * "user.address.city": "NY", + * "active": true + * } + * + * @template T - The type of the input object + * @param obj - The nested object to flatten + * @returns A flattened object with dotted keys and inferred types + */ + static dot> (obj: T): DotFlatten { + return dot(obj) + } + /** * Check if a key exists in the object. + * + * @param obj + * @param key + * @returns */ static exists> (obj: T, key: string | number): boolean { return Object.prototype.hasOwnProperty.call(obj, key) @@ -475,6 +545,11 @@ export class Obj { * * Example: * Obj.get({a:{b:1}}, 'a.b') -> 1 + * + * @param obj + * @param path + * @param defaultValue + * @returns */ static get< T extends object, @@ -497,6 +572,10 @@ export class Obj { /** * Check if the object has a given key or keys (dot notation supported). + * + * @param obj + * @param keys + * @returns */ static has> ( obj: T, @@ -518,14 +597,32 @@ export class Obj { /** * Check if an object is associative (has at least one non-numeric key). + * + * @param obj + * @returns */ static isAssoc (obj: unknown): obj is Record { if (!Obj.accessible(obj)) return false return Object.keys(obj).some(k => isNaN(Number(k))) } + /** + * Checks if a value is a plain object (not array, function, etc.) + * + * @param value + * @param allowArray + * @returns + */ + static isPlainObject

(value: P, allowArray?: boolean): value is P { + return isPlainObject(value, allowArray) + } + /** * Add a prefix to all keys of the object. + * + * @param obj + * @param prefix + * @returns */ static prependKeysWith> (obj: T, prefix: string): Record { if (!Obj.accessible(obj)) return {} @@ -540,6 +637,9 @@ export class Obj { * Convert an object into a URL query string. * * Nested objects/arrays are flattened using bracket notation. + * + * @param obj + * @returns */ static query (obj: Record): string { const encode = encodeURIComponent @@ -558,4 +658,19 @@ export class Obj { Object.entries(obj).forEach(([k, v]) => build(k, v)) return parts.join('&') } + + /** + * undot + * + * Convert a dot-notated object back into nested structure. + * + * Example: + * undot({ 'a.b': 1, 'c.0': 2 }) -> { a: { b: 1 }, c: [2] } + * + * @param obj + * @returns + */ + undot (obj: Record): Record { + return undot(obj) + } } From 91d0d6b809c901dc6355ec430e450238347d7e17 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Mon, 29 Dec 2025 20:33:07 +0100 Subject: [PATCH 11/28] feat(router): implement RouteAction, RouteCollection, RouteGroup, RouteParameterBinder, RouteUri, and RouteDependencyResolver classes - Added RouteAction class for handling route actions and parsing. - Introduced RouteCollection class to manage routes and their lookups. - Created RouteGroup class for merging route group attributes. - Implemented RouteParameterBinder for binding route parameters from requests. - Developed RouteUri class for parsing and managing route URIs. - Added RouteDependencyResolver for resolving controller method dependencies. test(router): add tests for router functionality - Implemented tests for route matching and action lookups. - Verified route registration and retrieval functionality. feat(support): enhance Collection and Helpers - Introduced Collection class extending BaseCollection for better type handling. - Added tap function for executing callbacks with values. - Implemented HigherOrderTapProxy for dynamic method calls. feat(validation): export validation contracts - Exported MessagesForRules and RulesForData types from validation contracts. --- .barrelize | 16 + examples/basic-app/package.json | 3 +- .../app/Console/Commands/ExampleCommand.ts | 2 +- .../app/Http/Controllers/HomeController.ts | 4 +- examples/basic-app/src/bootstrap/app.ts | 1 + .../src/resources/views/test.form.edge | 12 + examples/basic-app/src/routes/web.ts | 13 +- package.json | 2 +- packages/cache/package.json | 4 +- .../src/Providers/CacheServiceProvider.ts | 2 +- packages/config/package.json | 3 +- packages/config/src/ConfigRepository.ts | 7 +- packages/config/src/EnvLoader.ts | 4 +- .../src/Providers/ConfigServiceProvider.ts | 4 +- .../console/tests/console-command.test.ts | 2 - .../console/tests/console-prompts.test.ts | 2 - packages/console/tests/key.generate.test.ts | 2 - packages/contracts/CHANGELOG.md | 1 + packages/contracts/README.md | 43 + packages/contracts/package.json | 70 ++ packages/contracts/src/Core/IApplication.ts | 165 +++ packages/contracts/src/Core/IContainer.ts | 173 ++++ packages/contracts/src/Core/IController.ts | 21 + packages/contracts/src/Core/IRegisterer.ts | 3 + .../contracts/src/Core/IServiceProvider.ts | 77 ++ packages/contracts/src/Events/IDispatcher.ts | 94 ++ .../src/Exceptions/IExceptionHandler.ts | 75 ++ packages/contracts/src/Foundation/IKernel.ts | 171 ++++ .../src/Foundation/MiddlewareContract.ts | 5 + .../src/Foundation/RateLimiterAdapter.ts | 27 + packages/contracts/src/Http/IFileBag.ts | 26 + packages/contracts/src/Http/IHeaderBag.ts | 120 +++ packages/contracts/src/Http/IHttpContext.ts | 24 + packages/contracts/src/Http/IHttpRequest.ts | 254 +++++ .../src/Http}/IHttpResponse.ts | 108 +- packages/contracts/src/Http/IInputBag.ts | 103 ++ .../src/Http}/IParamBag.ts | 51 +- .../src/Http}/IRequest.ts | 192 ++-- packages/contracts/src/Http/IResponse.ts | 94 ++ packages/contracts/src/Http/IServerBag.ts | 24 + packages/contracts/src/Http/IUploadedFile.ts | 10 + packages/contracts/src/Http/Utils.ts | 3 + packages/contracts/src/Queue/IJob.ts | 141 +++ packages/contracts/src/Queue/Utils.ts | 12 + .../src/Routing/IAbstractRouteCollection.ts | 8 + .../src/Routing/ICallableDispatcher.ts | 2 + .../contracts/src/Routing/ICompiledRoute.ts | 18 + .../src/Routing/IControllerDispatcher.ts | 24 + packages/contracts/src/Routing/IMiddleware.ts | 10 + .../src/Routing}/IMiddlewareHandler.ts | 7 +- packages/contracts/src/Routing/IRoute.ts | 226 ++++ .../contracts/src/Routing/IRouteCollection.ts | 56 + packages/contracts/src/Routing/IRouter.ts | 230 +++++ packages/contracts/src/Session/FlashBag.ts | 71 ++ .../src/Session}/ISessionManager.ts | 6 +- .../contracts/src/Session/SessionContract.ts | 186 ++++ .../contracts/src/Url/IRequestAwareUrl.ts | 29 + packages/contracts/src/Url/IUrl.ts | 140 +++ packages/contracts/src/Url/IUrlHelpers.ts | 52 + packages/contracts/src/Url/Utils.ts | 1 + .../src/Utilities}/BindingsContract.ts | 21 +- .../contracts/src/Utilities/ObjContract.ts | 51 + .../contracts/src/Utilities/PathLoader.ts | 22 + packages/contracts/src/Utilities/Utilities.ts | 84 ++ .../contracts/src/Validation/IMessageBag.ts | 127 +++ .../src/Validation/IValidationRule.ts | 19 + .../contracts/src/Validation/IValidator.ts | 125 +++ .../contracts/src/Validation/RuleBuilder.ts | 11 + .../src/Validation}/ValidationRuleName.ts | 10 +- .../src/Validation}/ValidatorContracts.ts | 4 +- packages/contracts/src/index.ts | 50 + .../.gitkeep => contracts/tests/.gitignore} | 0 packages/contracts/tsconfig.json | 8 + packages/core/package.json | 1 + packages/core/src/Application.ts | 180 +++- packages/core/src/Container.ts | 344 ++++++- .../core/src/Contracts/H3ravelContract.ts | 6 +- .../Contracts/ServiceProviderConstructor.ts | 2 +- packages/core/src/Controller.ts | 11 +- packages/core/src/H3ravel.ts | 29 +- packages/core/src/Http/Kernel.ts | 24 +- .../core/src/Manager/ContainerResolver.ts | 15 +- packages/core/src/Manager/Foundation.ts | 47 - packages/core/src/Manager/Inject.ts | 36 +- packages/core/src/ProviderRegistry.ts | 29 +- .../core/src/Providers/CoreServiceProvider.ts | 4 + packages/core/src/Registerer.ts | 7 +- packages/core/src/ServiceProvider.ts | 75 +- packages/core/src/app.globals.d.ts | 4 +- packages/core/src/index.ts | 2 - .../core/tests/single-entry-point.test.ts | 5 +- packages/database/package.json | 2 +- .../src/Exceptions/ModelNotFoundException.ts | 50 + .../src/Exceptions/RecordNotFoundException.ts | 2 + .../Exceptions/RecordsNotFoundException.ts | 2 + packages/database/src/Model.ts | 2 +- .../src/Providers/DatabaseServiceProvider.ts | 2 + packages/database/src/index.ts | 3 + packages/events/CHANGELOG.md | 1 + packages/events/README.md | 43 + packages/events/package.json | 65 ++ .../Jobs => events/src/Contracts}/.gitkeep | 0 .../events/src/Contracts/EventsContract.ts | 6 + packages/events/src/Dispatcher.ts | 294 ++++++ .../src/Providers/EventsServiceProvider.ts | 27 + packages/events/src/QueuedListenerCalller.ts | 118 +++ packages/events/src/index.ts | 4 + .../src/Contracts => events/tests}/.gitkeep | 0 packages/events/tsconfig.json | 9 + packages/foundation/package.json | 5 +- .../src/Configuration/AppBuilder.ts | 93 ++ .../src/Configuration/Middleware.ts | 124 ++- packages/foundation/src/Container/Inject.ts | 37 + .../src/Contracts/MiddlewareContract.ts | 2 +- .../foundation/src/Core/ServiceProvider.ts | 95 ++ .../Exceptions/AccessDeniedHttpException.ts | 2 +- .../src/Exceptions/BadRequestHttpException.ts | 2 +- .../Exceptions/{ => Base}/ExceptionHandler.ts | 11 +- .../src/Exceptions/{ => Base}/Exceptions.ts | 2 +- .../src/Exceptions/{ => Base}/Handler.ts | 46 +- .../Exceptions/{ => Base}/HttpException.ts | 28 +- .../{ => Base}/HttpExceptionFactory.ts | 0 .../Base}/RequestException.ts | 2 +- .../src/Exceptions/ConflictHttpException.ts | 2 +- .../Core/BindingResolutionException.ts | 7 + .../src/Exceptions/Core}/ConfigException.ts | 2 +- .../src/Exceptions/Core/LogicException.ts | 6 + .../src/Exceptions/GoneHttpException.ts | 2 +- .../Exceptions/LengthRequiredHttpException.ts | 2 +- .../src/Exceptions/LockedHttpException.ts | 2 +- .../Exceptions/NotAcceptableHttpException.ts | 2 +- .../src/Exceptions/NotFoundHttpException.ts | 2 +- .../PreconditionFailedHttpException.ts | 2 +- .../PreconditionRequiredHttpException.ts | 2 +- .../ServiceUnavailableHttpException.ts | 2 +- .../TooManyRequestsHttpException.ts | 2 +- .../UnprocessableEntityHttpException.ts | 2 +- .../UnsupportedMediaTypeHttpException.ts | 2 +- .../src/Http/Events/RequestHandled.ts | 24 + packages/foundation/src/Http/Kernel.ts | 569 +++++++++++ .../foundation/src/Http/MiddlewareHandler.ts | 15 +- .../src/Testing/supertestAdapter.ts | 44 + packages/foundation/src/index.ts | 21 +- packages/foundation/tsconfig.json | 14 +- packages/hashing/package.json | 6 +- packages/hashing/src/Utils/Manager.ts | 4 +- packages/http/package.json | 6 +- packages/http/src/HttpContext.ts | 2 +- packages/http/src/Middleware.ts | 14 +- .../src/Middleware/FlashDataMiddleware.ts | 9 +- packages/http/src/Middleware/LogRequests.ts | 10 +- packages/http/src/Middleware/TrustHosts.ts | 106 ++ .../http/src/Providers/HttpServiceProvider.ts | 13 +- packages/http/src/Request.ts | 218 +++- packages/http/src/Response.ts | 85 +- packages/http/src/Utilities/HeaderBag.ts | 14 +- packages/http/src/Utilities/HttpRequest.ts | 518 ++++++++-- packages/http/src/Utilities/HttpResponse.ts | 7 +- packages/http/src/Utilities/ParamBag.ts | 6 +- packages/http/src/Utilities/Responsable.ts | 18 + packages/http/src/Utilities/ServerBag.ts | 67 +- packages/http/src/app.globals.d.ts | 13 +- packages/http/src/index.ts | 2 + packages/http/tests/Request.spec.ts | 5 +- packages/http/tests/Response.spec.ts | 6 +- packages/queue/package.json | 5 +- packages/queue/src/Contracts/JobContract.ts | 1 + packages/queue/src/Events/JobFailed.ts | 17 + .../src/Exceptions/ManuallyFailedException.ts | 3 + .../MaxAttemptsExceededException.ts | 21 + .../Exceptions/TimeoutExceededException.ts | 16 + packages/queue/src/Jobs/Job.ts | 335 ++++++ packages/queue/src/Jobs/JobName.ts | 41 + packages/queue/src/index.ts | 7 + packages/router/package.json | 8 +- .../router/src/AbstractRouteCollection.ts | 113 ++ packages/router/src/CallableDispatcher.ts | 41 + .../router/src/Commands/RouteListCommand.ts | 12 +- packages/router/src/CompiledRoute.ts | 61 ++ packages/router/src/Contracts/Pipeline.ts | 3 + packages/router/src/ControllerDispatcher.ts | 67 ++ .../router/src/Events/PreparingResponse.ts | 16 + .../router/src/Events/ResponsePrepared.ts | 16 + packages/router/src/Events/RouteMatched.ts | 16 + packages/router/src/Events/Routing.ts | 13 + packages/router/src/Helpers.ts | 4 +- packages/router/src/Matchers/HostValidator.ts | 19 + .../router/src/Matchers/MethodValidator.ts | 14 + .../router/src/Matchers/SchemeValidator.ts | 20 + packages/router/src/Matchers/UriValidator.ts | 17 + packages/router/src/Middleware/.gitkeep | 0 .../src/Middleware/SubstituteBindings.ts | 41 + packages/router/src/MiddlewareResolver.ts | 92 ++ packages/router/src/Pipeline.ts | 232 +++++ .../src/Providers/RouteServiceProvider.ts | 43 +- packages/router/src/Route.ts | 713 +++++++++++++ packages/router/src/RouteAction.ts | 149 +++ packages/router/src/RouteCollection.ts | 197 ++++ packages/router/src/RouteGroup.ts | 93 ++ packages/router/src/RouteParameterBinder.ts | 112 ++ packages/router/src/RouteUri.ts | 56 + packages/router/src/Router.ts | 610 ++++++++++- .../TraitLike/FiltersControllerMiddleware.ts | 14 + .../src/TraitLike/RouteDependencyResolver.ts | 72 ++ packages/router/src/index.ts | 24 + packages/router/tests/router.test.ts | 40 + packages/session/package.json | 3 +- packages/session/src/Encryption.ts | 2 +- .../src/Providers/SessionServiceProvider.ts | 9 +- packages/session/src/SessionManager.ts | 4 +- packages/session/tests/file.spec.ts | 2 +- packages/session/tests/memory.spec.ts | 2 +- packages/shared/package.json | 3 +- packages/shared/src/Contracts/IApplication.ts | 60 -- packages/shared/src/Contracts/IContainer.ts | 70 -- .../shared/src/Contracts/IExceptionHandler.ts | 100 -- packages/shared/src/Contracts/IHttp.ts | 193 ---- packages/shared/src/Contracts/IResponse.ts | 85 -- .../shared/src/Contracts/IServiceProvider.ts | 45 - .../shared/src/Contracts/IUploadedFile.ts | 12 - packages/shared/src/Contracts/Router.ts | 11 - packages/shared/src/Utils/Logger.ts | 4 +- packages/shared/src/Utils/PathLoader.ts | 2 +- packages/shared/src/index.ts | 14 - packages/support/package.json | 3 +- packages/support/src/Collection.ts | 20 + .../src/Exceptions/RuntimeException.ts | 4 +- packages/support/src/Helpers.ts | 36 + packages/support/src/Helpers/Arr.ts | 2 +- packages/support/src/Helpers/Str.ts | 191 +++- packages/support/src/HigherOrderTapProxy.ts | 25 + packages/support/src/index.ts | 3 + packages/support/tests/collection.test.ts | 12 + packages/url/package.json | 13 +- packages/url/src/Contracts/UrlContract.ts | 125 --- packages/url/src/Helpers.ts | 20 +- .../url/src/Providers/UrlServiceProvider.ts | 4 +- packages/url/src/RequestAwareHelpers.ts | 8 +- packages/url/src/Url.ts | 29 +- packages/url/src/app.globals.d.ts | 6 +- packages/url/src/index.ts | 1 - packages/url/tests/Url.spec.ts | 11 +- packages/validation/package.json | 5 +- packages/validation/src/Contracts/Exports.ts | 2 + .../validation/src/Contracts/RuleBuilder.ts | 11 - packages/validation/src/ImplicitRule.ts | 6 +- .../validation/src/Rules/ExtendedRules.ts | 6 +- .../validation/src/ValidationException.ts | 5 +- packages/validation/src/ValidationRule.ts | 14 +- packages/validation/src/Validator.ts | 19 +- packages/validation/src/index.ts | 4 +- .../validation/src/utilities/MessageBag.ts | 20 +- pnpm-lock.yaml | 961 +++++++++++------- pnpm-workspace.yaml | 7 +- tsconfig.base.json | 4 +- tsconfig.json | 19 +- 256 files changed, 11042 insertions(+), 2072 deletions(-) create mode 100644 examples/basic-app/src/resources/views/test.form.edge create mode 100644 packages/contracts/CHANGELOG.md create mode 100644 packages/contracts/README.md create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/src/Core/IApplication.ts create mode 100644 packages/contracts/src/Core/IContainer.ts create mode 100644 packages/contracts/src/Core/IController.ts create mode 100644 packages/contracts/src/Core/IRegisterer.ts create mode 100644 packages/contracts/src/Core/IServiceProvider.ts create mode 100644 packages/contracts/src/Events/IDispatcher.ts create mode 100644 packages/contracts/src/Exceptions/IExceptionHandler.ts create mode 100644 packages/contracts/src/Foundation/IKernel.ts create mode 100644 packages/contracts/src/Foundation/MiddlewareContract.ts create mode 100644 packages/contracts/src/Foundation/RateLimiterAdapter.ts create mode 100644 packages/contracts/src/Http/IFileBag.ts create mode 100644 packages/contracts/src/Http/IHeaderBag.ts create mode 100644 packages/contracts/src/Http/IHttpContext.ts create mode 100644 packages/contracts/src/Http/IHttpRequest.ts rename packages/{shared/src/Contracts => contracts/src/Http}/IHttpResponse.ts (75%) create mode 100644 packages/contracts/src/Http/IInputBag.ts rename packages/{shared/src/Contracts => contracts/src/Http}/IParamBag.ts (63%) rename packages/{shared/src/Contracts => contracts/src/Http}/IRequest.ts (58%) create mode 100644 packages/contracts/src/Http/IResponse.ts create mode 100644 packages/contracts/src/Http/IServerBag.ts create mode 100644 packages/contracts/src/Http/IUploadedFile.ts create mode 100644 packages/contracts/src/Http/Utils.ts create mode 100644 packages/contracts/src/Queue/IJob.ts create mode 100644 packages/contracts/src/Queue/Utils.ts create mode 100644 packages/contracts/src/Routing/IAbstractRouteCollection.ts create mode 100644 packages/contracts/src/Routing/ICallableDispatcher.ts create mode 100644 packages/contracts/src/Routing/ICompiledRoute.ts create mode 100644 packages/contracts/src/Routing/IControllerDispatcher.ts create mode 100644 packages/contracts/src/Routing/IMiddleware.ts rename packages/{shared/src/Contracts => contracts/src/Routing}/IMiddlewareHandler.ts (65%) create mode 100644 packages/contracts/src/Routing/IRoute.ts create mode 100644 packages/contracts/src/Routing/IRouteCollection.ts create mode 100644 packages/contracts/src/Routing/IRouter.ts create mode 100644 packages/contracts/src/Session/FlashBag.ts rename packages/{shared/src/Contracts => contracts/src/Session}/ISessionManager.ts (94%) create mode 100644 packages/contracts/src/Session/SessionContract.ts create mode 100644 packages/contracts/src/Url/IRequestAwareUrl.ts create mode 100644 packages/contracts/src/Url/IUrl.ts create mode 100644 packages/contracts/src/Url/IUrlHelpers.ts create mode 100644 packages/contracts/src/Url/Utils.ts rename packages/{shared/src/Contracts => contracts/src/Utilities}/BindingsContract.ts (63%) create mode 100644 packages/contracts/src/Utilities/ObjContract.ts create mode 100644 packages/contracts/src/Utilities/PathLoader.ts create mode 100644 packages/contracts/src/Utilities/Utilities.ts create mode 100644 packages/contracts/src/Validation/IMessageBag.ts create mode 100644 packages/contracts/src/Validation/IValidationRule.ts create mode 100644 packages/contracts/src/Validation/IValidator.ts create mode 100644 packages/contracts/src/Validation/RuleBuilder.ts rename packages/{validation/src/Contracts => contracts/src/Validation}/ValidationRuleName.ts (83%) rename packages/{validation/src/Contracts => contracts/src/Validation}/ValidatorContracts.ts (92%) create mode 100644 packages/contracts/src/index.ts rename packages/{config/src/Contracts/.gitkeep => contracts/tests/.gitignore} (100%) create mode 100644 packages/contracts/tsconfig.json delete mode 100644 packages/core/src/Manager/Foundation.ts create mode 100755 packages/database/src/Exceptions/ModelNotFoundException.ts create mode 100644 packages/database/src/Exceptions/RecordNotFoundException.ts create mode 100644 packages/database/src/Exceptions/RecordsNotFoundException.ts create mode 100644 packages/events/CHANGELOG.md create mode 100644 packages/events/README.md create mode 100644 packages/events/package.json rename packages/{queue/src/Jobs => events/src/Contracts}/.gitkeep (100%) create mode 100644 packages/events/src/Contracts/EventsContract.ts create mode 100644 packages/events/src/Dispatcher.ts create mode 100644 packages/events/src/Providers/EventsServiceProvider.ts create mode 100644 packages/events/src/QueuedListenerCalller.ts create mode 100644 packages/events/src/index.ts rename packages/{router/src/Contracts => events/tests}/.gitkeep (100%) create mode 100644 packages/events/tsconfig.json create mode 100644 packages/foundation/src/Configuration/AppBuilder.ts create mode 100644 packages/foundation/src/Container/Inject.ts create mode 100644 packages/foundation/src/Core/ServiceProvider.ts rename packages/foundation/src/Exceptions/{ => Base}/ExceptionHandler.ts (81%) rename packages/foundation/src/Exceptions/{ => Base}/Exceptions.ts (98%) rename packages/foundation/src/Exceptions/{ => Base}/Handler.ts (92%) rename packages/foundation/src/Exceptions/{ => Base}/HttpException.ts (69%) rename packages/foundation/src/Exceptions/{ => Base}/HttpExceptionFactory.ts (100%) rename packages/foundation/src/{Http => Exceptions/Base}/RequestException.ts (97%) create mode 100644 packages/foundation/src/Exceptions/Core/BindingResolutionException.ts rename packages/{core/src/Exceptions => foundation/src/Exceptions/Core}/ConfigException.ts (93%) create mode 100644 packages/foundation/src/Exceptions/Core/LogicException.ts create mode 100644 packages/foundation/src/Http/Events/RequestHandled.ts create mode 100644 packages/foundation/src/Http/Kernel.ts create mode 100644 packages/foundation/src/Testing/supertestAdapter.ts create mode 100644 packages/http/src/Middleware/TrustHosts.ts create mode 100644 packages/http/src/Utilities/Responsable.ts create mode 100644 packages/queue/src/Contracts/JobContract.ts create mode 100644 packages/queue/src/Events/JobFailed.ts create mode 100644 packages/queue/src/Exceptions/ManuallyFailedException.ts create mode 100644 packages/queue/src/Exceptions/MaxAttemptsExceededException.ts create mode 100644 packages/queue/src/Exceptions/TimeoutExceededException.ts create mode 100644 packages/queue/src/Jobs/Job.ts create mode 100644 packages/queue/src/Jobs/JobName.ts create mode 100644 packages/router/src/AbstractRouteCollection.ts create mode 100644 packages/router/src/CallableDispatcher.ts create mode 100644 packages/router/src/CompiledRoute.ts create mode 100644 packages/router/src/Contracts/Pipeline.ts create mode 100644 packages/router/src/ControllerDispatcher.ts create mode 100644 packages/router/src/Events/PreparingResponse.ts create mode 100644 packages/router/src/Events/ResponsePrepared.ts create mode 100644 packages/router/src/Events/RouteMatched.ts create mode 100644 packages/router/src/Events/Routing.ts create mode 100644 packages/router/src/Matchers/HostValidator.ts create mode 100644 packages/router/src/Matchers/MethodValidator.ts create mode 100644 packages/router/src/Matchers/SchemeValidator.ts create mode 100644 packages/router/src/Matchers/UriValidator.ts delete mode 100644 packages/router/src/Middleware/.gitkeep create mode 100644 packages/router/src/Middleware/SubstituteBindings.ts create mode 100644 packages/router/src/MiddlewareResolver.ts create mode 100644 packages/router/src/Pipeline.ts create mode 100644 packages/router/src/Route.ts create mode 100644 packages/router/src/RouteAction.ts create mode 100644 packages/router/src/RouteCollection.ts create mode 100644 packages/router/src/RouteGroup.ts create mode 100644 packages/router/src/RouteParameterBinder.ts create mode 100644 packages/router/src/RouteUri.ts create mode 100644 packages/router/src/TraitLike/FiltersControllerMiddleware.ts create mode 100644 packages/router/src/TraitLike/RouteDependencyResolver.ts create mode 100644 packages/router/tests/router.test.ts delete mode 100644 packages/shared/src/Contracts/IApplication.ts delete mode 100644 packages/shared/src/Contracts/IContainer.ts delete mode 100644 packages/shared/src/Contracts/IExceptionHandler.ts delete mode 100644 packages/shared/src/Contracts/IHttp.ts delete mode 100644 packages/shared/src/Contracts/IResponse.ts delete mode 100644 packages/shared/src/Contracts/IServiceProvider.ts delete mode 100644 packages/shared/src/Contracts/IUploadedFile.ts delete mode 100644 packages/shared/src/Contracts/Router.ts create mode 100644 packages/support/src/Collection.ts create mode 100644 packages/support/src/Helpers.ts create mode 100644 packages/support/src/HigherOrderTapProxy.ts create mode 100644 packages/support/tests/collection.test.ts delete mode 100644 packages/url/src/Contracts/UrlContract.ts create mode 100644 packages/validation/src/Contracts/Exports.ts delete mode 100644 packages/validation/src/Contracts/RuleBuilder.ts diff --git a/.barrelize b/.barrelize index d97a5edf..5caf0b0b 100644 --- a/.barrelize +++ b/.barrelize @@ -83,6 +83,14 @@ "**/*.d.ts" ] }, + { + "name": "index.ts", + "root": "packages/events/src", + "exclude": [ + "**/*.test.ts", + "**/*.d.ts" + ] + }, { "name": "index.ts", "root": "packages/router/src", @@ -188,6 +196,14 @@ "/^(?!default$)(.+)/ as $1" ] } + }, + { + "name": "index.ts", + "root": "packages/contracts/src", + "exclude": [ + "**/*.test.ts", + "**/*.d.ts" + ] } ] } \ No newline at end of file diff --git a/examples/basic-app/package.json b/examples/basic-app/package.json index 83d6f11e..685bbbad 100644 --- a/examples/basic-app/package.json +++ b/examples/basic-app/package.json @@ -34,6 +34,7 @@ "@h3ravel/validation": "workspace:^", "@h3ravel/foundation": "workspace:^", "@h3ravel/session": "workspace:^", + "@h3ravel/events": "workspace:^", "cross-env": "catalog:", "h3": "catalog:prod", "reflect-metadata": "catalog:", @@ -44,7 +45,7 @@ "@rollup/plugin-run": "catalog:", "@swc/core": "catalog:", "@types/node": "^24.9.2", - "tsdown": "^0.15.12", + "tsdown": "catalog:", "tsx": "catalog:", "typescript": "^5.9.3" } diff --git a/examples/basic-app/src/app/Console/Commands/ExampleCommand.ts b/examples/basic-app/src/app/Console/Commands/ExampleCommand.ts index b6b305cd..8d53c3cf 100644 --- a/examples/basic-app/src/app/Console/Commands/ExampleCommand.ts +++ b/examples/basic-app/src/app/Console/Commands/ExampleCommand.ts @@ -1,4 +1,4 @@ -import { Command } from '@h3ravel/console' +import { Command } from '@h3ravel/musket' import { Injectable } from '@h3ravel/core' import { User } from 'src/app/Models/user' diff --git a/examples/basic-app/src/app/Http/Controllers/HomeController.ts b/examples/basic-app/src/app/Http/Controllers/HomeController.ts index 504464ec..1c949113 100644 --- a/examples/basic-app/src/app/Http/Controllers/HomeController.ts +++ b/examples/basic-app/src/app/Http/Controllers/HomeController.ts @@ -1,6 +1,6 @@ -import { Controller } from '@h3ravel/core' +import { IController } from '@h3ravel/contracts' -export class HomeController extends Controller { +export class HomeController extends IController { public async index () { return await view('index', { links: { diff --git a/examples/basic-app/src/bootstrap/app.ts b/examples/basic-app/src/bootstrap/app.ts index 42416a2f..fbfdb670 100644 --- a/examples/basic-app/src/bootstrap/app.ts +++ b/examples/basic-app/src/bootstrap/app.ts @@ -27,6 +27,7 @@ export default class { .truncateRequestExceptionsAt(200) }) .withMiddleware(() => { + console.log('-=withMiddleware=-') }) return await app.fire() diff --git a/examples/basic-app/src/resources/views/test.form.edge b/examples/basic-app/src/resources/views/test.form.edge new file mode 100644 index 00000000..3c5b79b9 --- /dev/null +++ b/examples/basic-app/src/resources/views/test.form.edge @@ -0,0 +1,12 @@ + + + + + + + + + {{await request().old('name')}} + + + \ No newline at end of file diff --git a/examples/basic-app/src/routes/web.ts b/examples/basic-app/src/routes/web.ts index ef4be410..369a92f5 100644 --- a/examples/basic-app/src/routes/web.ts +++ b/examples/basic-app/src/routes/web.ts @@ -5,13 +5,19 @@ import { Router } from '@h3ravel/router' import { UrlExampleController } from 'src/app/Http/Controllers/UrlExampleController' export default (Route: Router) => { - Route.get('/', [HomeController, 'index']) + // Route.get('/', [HomeController, 'index']) Route.get('/mail', [MailController, 'send']) // URL examples Route.get('/url-examples', [UrlExampleController, 'index'], 'url.examples') Route.get('/url-signing', [UrlExampleController, 'signing'], 'url.signing') Route.get('/url-manipulation', [UrlExampleController, 'manipulation'], 'url.manipulation') + Route.match(['post', 'get'], 'path5/{user:name}/{name}', () => { }).name('path5') + Route.match(['get'], '/', [HomeController, 'index']).name('index').middleware('web') + Route.match(['get'], '/test/{user:name}', (request, free) => { + console.log(free) + return '{ Test Result }' + }).name('index') Route.get('/app', async function () { return await view('index', { @@ -24,6 +30,11 @@ export default (Route: Router) => { }) }) + Route.get('/form', async function () { + console.log(session('_errors')) + return await view('test.form') + }) + Route.put('/validation', async ({ request, response }: HttpContext) => { const data = await request.validate({ name: ['required', 'string'], diff --git a/package.json b/package.json index 774a8c69..4ba8a593 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "rimraf": "catalog:", "ts-node": "catalog:", "tsconfig-paths": "catalog:", - "tsdown": "^0.16.0", + "tsdown": "catalog:", "typescript": "^5.9.3", "typescript-eslint": "catalog:", "utility-types": "catalog:", diff --git a/packages/cache/package.json b/packages/cache/package.json index 4d0f6dc5..161cf99e 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -57,9 +57,9 @@ "version-patch": "pnpm version patch" }, "peerDependencies": { - "@h3ravel/core": "workspace:^" + "@h3ravel/foundation": "workspace:^" }, "devDependencies": { "typescript": "^5.4.0" } -} +} \ No newline at end of file diff --git a/packages/cache/src/Providers/CacheServiceProvider.ts b/packages/cache/src/Providers/CacheServiceProvider.ts index 2dc29ae7..2d2f6c5a 100644 --- a/packages/cache/src/Providers/CacheServiceProvider.ts +++ b/packages/cache/src/Providers/CacheServiceProvider.ts @@ -1,4 +1,4 @@ -import { ServiceProvider } from '@h3ravel/core' +import { ServiceProvider } from '@h3ravel/foundation' /** * Cache drivers and utilities. diff --git a/packages/config/package.json b/packages/config/package.json index c2ffa40f..d848f27b 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -64,6 +64,7 @@ "devDependencies": { "tsx": "catalog:", "@h3ravel/core": "workspace:^", + "@h3ravel/contracts": "workspace:^", "typescript": "^5.9.2" } -} +} \ No newline at end of file diff --git a/packages/config/src/ConfigRepository.ts b/packages/config/src/ConfigRepository.ts index 041dc650..e4f6aff6 100644 --- a/packages/config/src/ConfigRepository.ts +++ b/packages/config/src/ConfigRepository.ts @@ -1,7 +1,8 @@ -import { Application, Registerer } from '@h3ravel/core' import type { DotNestedKeys, DotNestedValue } from '@h3ravel/shared' import { safeDot, setNested } from '@h3ravel/support' +import { IApplication } from '@h3ravel/contracts' +import { Registerer } from '@h3ravel/core' import path from 'node:path' import { readdir } from 'node:fs/promises' @@ -9,7 +10,7 @@ export class ConfigRepository { private loaded: boolean = false private configs: Record> = {} - constructor(protected app: Application) { } + constructor(protected app: IApplication) { } // get> (): X // get, T extends Extract> (key: T): X[T] @@ -36,7 +37,7 @@ export class ConfigRepository { const configPath = this.app.getPath('config') globalThis.env = this.app.make('env') - Registerer.register(this.app) + Registerer.register(this.app as never) const files = (await readdir(configPath)).filter((e) => { return !e.includes('.d.ts') && !e.includes('.d.cts') && !e.includes('.map') diff --git a/packages/config/src/EnvLoader.ts b/packages/config/src/EnvLoader.ts index 3b1e7cd3..eb54ebf2 100644 --- a/packages/config/src/EnvLoader.ts +++ b/packages/config/src/EnvLoader.ts @@ -1,11 +1,11 @@ import type { DotNestedKeys, DotNestedValue } from '@h3ravel/shared' -import { Application } from '@h3ravel/core' import { EnvParser } from '@h3ravel/shared' +import { IApplication } from '@h3ravel/contracts' import { safeDot } from '@h3ravel/support' export class EnvLoader { - constructor(protected app?: Application) { } + constructor(protected app?: IApplication) { } /** * Get the defined environment vars diff --git a/packages/config/src/Providers/ConfigServiceProvider.ts b/packages/config/src/Providers/ConfigServiceProvider.ts index b4ba39fc..08e6c3b1 100644 --- a/packages/config/src/Providers/ConfigServiceProvider.ts +++ b/packages/config/src/Providers/ConfigServiceProvider.ts @@ -2,9 +2,9 @@ import { ConfigRepository, EnvLoader } from '..' -import { Bindings } from '@h3ravel/shared' +import { Bindings } from '@h3ravel/contracts' import { ConfigPublishCommand } from '../Commands/ConfigPublishCommand' -import { ServiceProvider } from '@h3ravel/core' +import { ServiceProvider } from '@h3ravel/foundation' /** * Loads configuration and environment files. diff --git a/packages/console/tests/console-command.test.ts b/packages/console/tests/console-command.test.ts index 6c9c947e..c48c4fbe 100644 --- a/packages/console/tests/console-command.test.ts +++ b/packages/console/tests/console-command.test.ts @@ -5,8 +5,6 @@ import { Application } from '@h3ravel/core' import { Command as ICommand } from 'commander' import { Logger } from '@h3ravel/shared' -console.log = vi.fn(() => 0) - // Mock the Logger to capture calls const originalInfo = Logger.info const originalSuccess = Logger.success diff --git a/packages/console/tests/console-prompts.test.ts b/packages/console/tests/console-prompts.test.ts index 998cec41..56aaa107 100644 --- a/packages/console/tests/console-prompts.test.ts +++ b/packages/console/tests/console-prompts.test.ts @@ -3,8 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Application } from '@h3ravel/core' -console.log = vi.fn(() => 0) - vi.mock('@h3ravel/shared', async (importOriginal) => { const actual = await importOriginal() diff --git a/packages/console/tests/key.generate.test.ts b/packages/console/tests/key.generate.test.ts index 61018bf7..d07c90c6 100644 --- a/packages/console/tests/key.generate.test.ts +++ b/packages/console/tests/key.generate.test.ts @@ -12,8 +12,6 @@ let tempPath: string let kernel: Kernel let app: Application -console.log = vi.fn(() => 0) - beforeAll(async () => { tempPath = await mkdtemp(path.join(tmpdir(), '@h3ravel-console')) globalThis.base_path = (file?: string) => path.join(tempPath, file ?? '') diff --git a/packages/contracts/CHANGELOG.md b/packages/contracts/CHANGELOG.md new file mode 100644 index 00000000..8b21dd18 --- /dev/null +++ b/packages/contracts/CHANGELOG.md @@ -0,0 +1 @@ +# @h3ravel/contracts diff --git a/packages/contracts/README.md b/packages/contracts/README.md new file mode 100644 index 00000000..c54782e4 --- /dev/null +++ b/packages/contracts/README.md @@ -0,0 +1,43 @@ +

+ + H3ravel Logo + +

H3ravel Contracts

+ +[![Framework][ix]][lx] +[![Contracts Package Version][i1]][l1] +[![Downloads][d1]][d1] +[![Tests][tei]][tel] +[![License][lini]][linl] + +
+ +# About H3ravel/contracts + +This package provides first class reusable interfaces and contracts for use across the [H3ravel](https://h3ravel.toneflix.net) ecosystem. + +## Contributing + +Thank you for considering contributing to the H3ravel framework! The [Contribution Guide](https://h3ravel.toneflix.net/contributing) can be found in the H3ravel documentation and will provide you with all the information you need to get started. + +## Code of Conduct + +In order to ensure that the H3ravel community is welcoming to all, please review and abide by the [Code of Conduct](#). + +## Security Vulnerabilities + +If you discover a security vulnerability within H3ravel, please send an e-mail to Legacy via hamzas.legacy@toneflix.ng. All security vulnerabilities will be promptly addressed. + +## License + +The H3ravel framework is open-sourced software licensed under the [MIT license](LICENSE). + +[ix]: https://img.shields.io/npm/v/%40h3ravel%2Fcore?style=flat-square&label=Framework&color=%230970ce +[lx]: https://www.npmjs.com/package/@h3ravel/core +[i1]: https://img.shields.io/npm/v/%40h3ravel%2Fcontracts?style=flat-square&label=@h3ravel/contracts&color=%230970ce +[l1]: https://www.npmjs.com/package/@h3ravel/contracts +[d1]: https://img.shields.io/npm/dt/%40h3ravel%2Fcontracts?style=flat-square&label=Downloads&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40h3ravel%2Fcontracts +[linl]: https://github.com/h3ravel/framework/blob/main/LICENSE +[lini]: https://img.shields.io/github/license/h3ravel/framework +[tel]: https://github.com/h3ravel/framework/actions/workflows/test.yml +[tei]: https://github.com/h3ravel/framework/actions/workflows/test.yml/badge.svg diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 00000000..2ab70973 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,70 @@ +{ + "name": "@h3ravel/contracts", + "version": "0.27.7", + "description": "H3ravel Contracts.", + "type": "module", + "main": "./dist/index.cjs", + "types": "./dist/index.d.ts", + "module": "./dist/index.js", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "*": [ + "dist/index.d.ts" + ] + } + }, + "files": [ + "dist", + "tsconfig.json" + ], + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./*": "./*" + } + }, + "homepage": "https://h3ravel.toneflix.net", + "repository": { + "type": "git", + "url": "git+https://github.com/h3ravel/framework.git", + "directory": "packages/contracts" + }, + "keywords": [ + "h3ravel", + "modern", + "web", + "H3", + "framework", + "nodejs", + "typescript", + "laravel", + "contracts" + ], + "scripts": { + "build": "tsdown --config-loader unconfig", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "lint": "eslint . --ext .ts", + "test": "jest --passWithNoTests", + "release:patch": "pnpm build && pnpm version patch && git add . && git commit -m \"version: bump contracts package and publish\" && pnpm publish --tag latest", + "version-patch": "pnpm version patch" + }, + "devDependencies": { + "edge.js": "catalog:", + "simple-body-validator": "catalog:" + }, + "dependencies": { + "h3": "catalog:prod" + } +} \ No newline at end of file diff --git a/packages/contracts/src/Core/IApplication.ts b/packages/contracts/src/Core/IApplication.ts new file mode 100644 index 00000000..7323f312 --- /dev/null +++ b/packages/contracts/src/Core/IApplication.ts @@ -0,0 +1,165 @@ +import type { ConcreteConstructor, IPathName } from '../Utilities/Utilities' +import type { H3, H3Event } from 'h3' + +import { IContainer } from './IContainer' +import type { IHttpContext } from '../Http/IHttpContext' +import type { IServiceProvider } from './IServiceProvider' +import { IUrl } from '../Url/IUrl' +import type { PathLoader } from '../Utilities/PathLoader' + +export abstract class IApplication extends IContainer { + abstract paths: PathLoader + context?: (event: H3Event) => Promise + h3Event?: H3Event + /** + * List of registered console commands + */ + abstract registeredCommands: (new (app: any, kernel: any) => any)[] + /** + * Get all registered providers + */ + abstract getRegisteredProviders (): IServiceProvider[]; + /** + * Configure and Dynamically register all configured service providers, then boot the app. + * + * @param providers All regitererable service providers + * @param filtered A list of service provider name strings we do not want to register at all cost + * @param autoRegisterProviders If set to false, service providers will not be auto discovered and registered. + * + * @returns + */ + abstract quickStartup (providers: Array>, filtered?: string[], autoRegisterProviders?: boolean): Promise; + /** + * Dynamically register all configured providers + * + * @param autoRegister If set to false, service providers will not be auto discovered and registered. + */ + abstract registerConfiguredProviders (autoRegister?: boolean): Promise; + /** + * Register service providers + * + * @param providers + * @param filtered + */ + abstract registerProviders (providers: Array>, filtered?: string[]): void; + /** + * Register a provider + */ + abstract register (provider: IServiceProvider): Promise; + /** + * Register the listed service providers. + * + * @param commands An array of console commands to register. + */ + abstract withCommands (commands: (new (app: any, kernel: any) => any)[]): this; + /** + * checks if the application is running in CLI + */ + abstract runningInConsole (): boolean; + /** + * checks if the application is running in Unit Test + */ + abstract runningUnitTests (): boolean; + + abstract getRuntimeEnv (): 'browser' | 'node' | 'unknown'; + /** + * Determine if the application has booted. + */ + abstract isBooted (): boolean + /** + * Boot all service providers after registration + */ + abstract boot (): Promise; + /** + * Register a new "booted" listener. + * + * @param callback + */ + abstract booted (callback: (...args: any[]) => void): void + /** + * Handle the incoming HTTP request and send the response to the browser. + * + * @param request + */ + abstract handleRequest (event: H3Event): Promise + /** + * Get the URI resolver callback. + */ + abstract getUriResolver (): () => typeof IUrl | undefined + /** + * Set the URI resolver callback. + * + * @param callback + */ + abstract setUriResolver (callback: () => typeof IUrl): this + /** + * Determine if middleware has been disabled for the application. + */ + abstract shouldSkipMiddleware (): boolean + + /** + * Provide safe overides for the app + */ + abstract configure (): any; + /** + * Check if the current application environment matches the one provided + * + * @param env + */ + abstract environment (env: E): E extends undefined ? string : boolean; + /** + * Fire up the developement server using the user provided arguments + * + * Port will be auto assigned if provided one is not available + * + * @param h3App The current H3 app instance + * @param preferedPort If provided, this will overide the port set in the evironment + * @alias serve + */ + abstract fire (): Promise; + abstract fire (h3App: H3, preferredPort?: number): Promise; + /** + * Fire up the developement server using the user provided arguments + * + * Port will be auto assigned if provided one is not available + * + * @param h3App The current H3 app instance + * @param preferedPort If provided, this will overide the port set in the evironment + */ + abstract serve (h3App?: H3, preferredPort?: number): Promise; + /** + * Save the curretn H3 instance for possible future use. + * + * @param h3App The current H3 app instance + * @returns + */ + abstract setH3App (h3App?: H3): this; + /** + * Get the base path of the app + * + * @returns + */ + abstract getBasePath (): string; + /** + * Dynamically retrieves a path property from the class. + * Any property ending with "Path" is accessible automatically. + * + * @param name - The base name of the path property + * @returns + */ + abstract getPath (name: IPathName, suffix?: string): string; + /** + * Programatically set the paths. + * + * @param name - The base name of the path property + * @param path - The new path + * @returns + */ + abstract setPath (name: IPathName, path: string): void; + /** + * Returns the installed version of the system core and typescript. + * + * @returns + */ + abstract getVersion (key: string): string; +} \ No newline at end of file diff --git a/packages/contracts/src/Core/IContainer.ts b/packages/contracts/src/Core/IContainer.ts new file mode 100644 index 00000000..851337da --- /dev/null +++ b/packages/contracts/src/Core/IContainer.ts @@ -0,0 +1,173 @@ +import type { Bindings, UseKey } from '../Utilities/BindingsContract' +import type { IMiddlewareHandler } from '../Routing/IMiddlewareHandler' +import { IExceptionHandler } from '../Exceptions/IExceptionHandler' +import { ClassConstructor, CallableConstructor, ExtractClassMethods, ConcreteConstructor } from '../Utilities/Utilities' +import { IMiddleware } from '../Routing/IMiddleware' + +/** + * Interface for the Container contract, defining methods for dependency injection and service resolution. + */ +export abstract class IContainer { + abstract exceptionHandler?: IExceptionHandler + abstract middlewareHandler?: IMiddlewareHandler + + /** + * Check if the target has any decorators + * + * @param target + * @returns + */ + static hasAnyDecorator any> (target: C): boolean + static hasAnyDecorator any> (target: F): boolean { + void target + return false + }; + + /** + * Bind a transient service to the container + * + * @param key + * @param factory + */ + abstract bind (key: new (...args: any[]) => T, factory: () => T): void; + abstract bind (key: T, factory: () => Bindings[T]): void; + + /** + * Bind unregistered middlewares to the service container so we can use them later + * + * @param key + * @param middleware + */ + abstract bindMiddleware (key: IMiddleware | string, middleware: ConcreteConstructor): void + + /** + * Get all bound and unregistered middlewares in the service container + * + * @param key + * @param middleware + */ + abstract boundMiddlewares (): MapIterator<[string | IMiddleware, IMiddleware]> + abstract boundMiddlewares (key: IMiddleware | string): IMiddleware + + /** + * Remove one or more transient services from the container + * + * @param key + */ + abstract unbind (key: T | T[]): void; + + /** + * Bind a singleton service to the container + * + * @param key + * @param factory + */ + abstract singleton (key: T | (new (...args: any[]) => Bindings[T]), factory: (app: this) => Bindings[T]): void + abstract singleton (key: T | (abstract new (...args: any[]) => Bindings[T]), factory: (app: this) => Bindings[T]): void + abstract singleton (key: T | (new (...args: any[]) => Bindings[T]), factory: abstract new (...args: any[]) => any): void + abstract singleton (key: T | (abstract new (...args: any[]) => Bindings[T]), factory: abstract new (...args: any[]) => any): void + + /** + * Read reflected param types, resolve dependencies from the container and + * optionally transform them, finally invoke the specified method on a class instance + * + * @param instance + * @param method + * @param defaultArgs + * @param handler + * @returns + */ + abstract invoke, M extends ExtractClassMethods> ( + instance: X, + method: M, + defaultArgs?: any[], + handler?: CallableConstructor + ): Promise + + /** + * Resolve a service from the container + * + * @param key + */ + abstract make (key: T): Bindings[T]; + abstract make any> (key: C): InstanceType; + abstract make any> (key: F): ReturnType; + + /** + * Register a callback to be executed after a service is resolved + * + * @param key + * @param callback + */ + abstract afterResolving (key: T, callback: (resolved: Bindings[T], app: this) => void): void; + abstract afterResolving any> (key: T, callback: (resolved: InstanceType, app: this) => void): void; + + /** + * Register a new before resolving callback for all types. + * + * @param key + * @param callback + */ + abstract beforeResolving (key: T, callback: (app: this) => void): void + abstract beforeResolving any> (key: T, callback: (app: this) => void): void + + /** + * Determine if a given string is an alias. + * + * @param name + */ + abstract isAlias (name: string): boolean + + /** + * Get the alias for an abstract if available. + * + * @param abstract + */ + abstract getAlias (abstract: any): any + + /** + * Set the alias for an abstract. + * + * @param token + * @param target + */ + abstract alias (key: [string | ClassConstructor, any][]): this + abstract alias (key: string | ClassConstructor, target: any): this + + /** + * Determine if the given abstract type has been bound. + * + * @param string $abstract + * @returns + */ + abstract bound (abstract: T): boolean + abstract bound any> (abstract: C): boolean + abstract bound any> (abstract: F): boolean + + /** + * Check if a service is registered + * + * @param key + * @returns + */ + abstract has (key: T): boolean; + abstract has any> (key: C): boolean; + abstract has any> (key: F): boolean; + + /** + * Register an existing instance as shared in the container. + * + * @param abstract + * @param instance + */ + abstract instance (key: string, instance: X): X + abstract instance any, X = any> (abstract: K, instance: X): X + + /** + * Call the given method and inject its dependencies. + * + * @param callback + */ + abstract call any> (callback: C): void | Promise + abstract call any> (callback: F): void | Promise +} \ No newline at end of file diff --git a/packages/contracts/src/Core/IController.ts b/packages/contracts/src/Core/IController.ts new file mode 100644 index 00000000..915d331f --- /dev/null +++ b/packages/contracts/src/Core/IController.ts @@ -0,0 +1,21 @@ +import { ControllerMethod } from '../Utilities/Utilities' +import { IMiddleware } from '../Routing/IMiddleware' + +/** + * Defines the contract for all controllers. + */ +export abstract class IController { + show?(...ctx: any[]): any + index?(...ctx: any[]): any + store?(...ctx: any[]): any + update?(...ctx: any[]): any + destroy?(...ctx: any[]): any + __invoke?(...ctx: any[]): any + callAction (method: ControllerMethod, parameters: any[]): any { + void parameters + void method + } + getMiddleware (): IMiddleware { + return {} as IMiddleware + } +} \ No newline at end of file diff --git a/packages/contracts/src/Core/IRegisterer.ts b/packages/contracts/src/Core/IRegisterer.ts new file mode 100644 index 00000000..a3d14764 --- /dev/null +++ b/packages/contracts/src/Core/IRegisterer.ts @@ -0,0 +1,3 @@ +export abstract class IRegisterer { + abstract bootRegister (): void; +} \ No newline at end of file diff --git a/packages/contracts/src/Core/IServiceProvider.ts b/packages/contracts/src/Core/IServiceProvider.ts new file mode 100644 index 00000000..f495e45c --- /dev/null +++ b/packages/contracts/src/Core/IServiceProvider.ts @@ -0,0 +1,77 @@ +export abstract class IServiceProvider { + /** + * Unique Identifier for service providers + */ + static uid?: number + + /** + * Sort order + */ + static order?: `before:${string}` | `after:${string}` | string | undefined + + /** + * Sort priority + */ + static priority?: number + + /** + * Indicate that this service provider only runs in console + */ + static runsInConsole?: boolean + + /** + * Indicate that this service provider only runs in console + */ + static console: boolean + + /** + * Indicate that this service provider only runs in console + */ + abstract console?: boolean + + /** + * Indicate that this service provider only runs in console + */ + abstract runsInConsole: boolean + + /** + * List of registered console commands + */ + abstract registeredCommands?: (new (app: any, kernel: any) => any)[] + + /** + * An array of console commands to register. + */ + abstract commands?(commands: (new (app: any, kernel: any) => any)[]): void + + /** + * Register bindings to the container. + * Runs before boot(). + */ + abstract register (...app: unknown[]): void | Promise + + /** + * Perform post-registration booting of services. + * Runs after all providers have been registered. + */ + boot?(...app: unknown[]): void | Promise + + /** + * Register a booted callback to be run after the "boot" method is called. + * + * @param callback + */ + abstract booted (callback: (...args: any[]) => void): void + + /** + * Call the registered booted callbacks. + */ + abstract callBootedCallbacks (): Promise + + /** + * Register the listed service providers. + * + * @param commands An array of console commands to register. + */ + abstract registerCommands (commands: (new (app: any, kernel: any) => any)[]): void; +} \ No newline at end of file diff --git a/packages/contracts/src/Events/IDispatcher.ts b/packages/contracts/src/Events/IDispatcher.ts new file mode 100644 index 00000000..1defbdb8 --- /dev/null +++ b/packages/contracts/src/Events/IDispatcher.ts @@ -0,0 +1,94 @@ +import { AppEvent, AppListener } from '../Utilities/Utilities' + +import { JobPayload } from '../Queue/Utils' + +export abstract class IDispatcher { + /** + * Register an event listener with the dispatcher. + * + * @param events + * @param listener + */ + abstract listen (events: AppEvent | AppEvent[] | string | string[], listener?: AppListener | AppListener[] | string | string[]): void; + /** + * Determine if a given event has listeners. + * + * @param eventName + * @return bool + */ + abstract hasListeners (eventName: string): any[]; + /** + * Determine if the given event has any wildcard listeners. + * + * @param eventName + */ + abstract hasWildcardListeners (eventName: string): boolean; + /** + * Register an event and payload to be fired later. + * + * @para event + * @param payload + * @return void + */ + abstract push (event: string, payload?: Record | any[]): void; + /** + * Flush a set of pushed events. + * + * @param event + */ + abstract flush (event: string): void; + /** + * Fire an event until the first non-null response is returned. + * + * @param event + * @param mixed payload + * @return mixed + */ + abstract until (event: AppEvent, payload?: JobPayload): void; + /** + * Fire an event and call the listeners. + * + * @param event + * @param payload + * @param halt + */ + abstract dispatch (event: Record | string, payload?: Record | any[], halt?: boolean): void; + /** + * Remove a set of listeners from the dispatcher. + * + * @param event + */ + abstract forget (event: string): void; + /** + * Forget all of the pushed listeners. + * + * @return void + */ + abstract forgetPushed (): void; + /** + * Set the queue resolver implementation. + * + * @param callable $resolver + * @return this + */ + abstract setQueueResolver (resolver: (...a: any[]) => any): this; + /** + * Set the database transaction manager resolver implementation. + * + * @param resolver + */ + abstract setTransactionManagerResolver (resolver: (...a: any[]) => any): this; + /** + * Execute the given callback while deferring events, then dispatch all deferred events. + * + * @param callback + * @param events + */ + abstract defer (callback: (...a: any[]) => any, events: AppEvent[]): any; + /** + * Gets the raw, unprepared listeners. + * + * @return array + */ + abstract getRawListeners (): Record; +} \ No newline at end of file diff --git a/packages/contracts/src/Exceptions/IExceptionHandler.ts b/packages/contracts/src/Exceptions/IExceptionHandler.ts new file mode 100644 index 00000000..04355e3e --- /dev/null +++ b/packages/contracts/src/Exceptions/IExceptionHandler.ts @@ -0,0 +1,75 @@ +import { IHttpContext, IRequest, IResponse, LimitSpec, RateLimiterAdapter, Unlimited } from '..' + +export type ExceptionConstructor = new (...args: any[]) => T +export type ExceptionConditionCallback = (error: any) => boolean; +export type RenderExceptionCallback = (error: any, request: IRequest) => IResponse | Promise | undefined | null; +export type ReportExceptionCallback = (error: any) => boolean | void | Promise; +export type ThrottleExceptionCallback = (error: any) => LimitSpec | Unlimited | null | undefined; + +export abstract class IExceptionHandler { + /** + * The exception handler method + * + * @param error + * @param ctx + */ + abstract handle?(error: Error, ctx: IHttpContext): Promise; + /** + * Register a reportable callback handler + * + * @param cb + * @returns + */ + abstract reportable (cb: ReportExceptionCallback): this; + abstract renderable (cb: RenderExceptionCallback): this; + abstract dontReport (exceptions: ExceptionConstructor | ExceptionConstructor[]): this; + abstract stopIgnoring (exceptions: ExceptionConstructor | ExceptionConstructor[]): this; + abstract dontReportWhen (cb: ExceptionConditionCallback): this; + abstract dontReportDuplicates (): this; + abstract map (from: ExceptionConstructor, mapper: (error: any) => any): this; + abstract throttleUsing (cb: ThrottleExceptionCallback): this; + abstract buildContextUsing (cb: (e: any, current?: Record) => Record): this; + abstract setRateLimiter (adapter: RateLimiterAdapter): this; + abstract respondUsing (cb: (response: IResponse, error: any, request: IRequest) => IResponse | Promise): this; + abstract shouldRenderJsonWhen (cb: (request: IRequest, error: any) => boolean): this; + /** + * Entry point to reporting an exception. + * + * @param error + * @returns + */ + abstract report (error: any): Promise; + /** + * Render an exception into an HTTP Response. + * + * @param ctx + * @param error + * @returns + */ + abstract render (request: IRequest, error: any): Promise; + /** + * getResponse + */ + abstract getResponse (request: IRequest, payload: Record, e: any): IResponse | Promise; + /** + * Not implemented in core. Subclass can implement and call RequestException helpers. + * + * @param _length + */ + abstract truncateRequestExceptionsAt (_length: number): this; + /** + * Set the log level + * + * @param _attributes + */ + abstract level (type: string, level: string): { + level: string; + type: string; + }; + /** + * Not implemented here; applicable to validation pipeline/UI. + * + * @param _attributes + */ + abstract dontFlash (_attributes: string | string[]): this; +} \ No newline at end of file diff --git a/packages/contracts/src/Foundation/IKernel.ts b/packages/contracts/src/Foundation/IKernel.ts new file mode 100644 index 00000000..ce52015a --- /dev/null +++ b/packages/contracts/src/Foundation/IKernel.ts @@ -0,0 +1,171 @@ +import { IApplication } from '../Core/IApplication' +import { IMiddleware } from '../Routing/IMiddleware' +import { IRequest } from '../Http/IRequest' +import { IResponse } from '../Http/IResponse' +import { MiddlewareList } from './MiddlewareContract' + +export abstract class IKernel { + /** + * Handle an incoming HTTP request. + * + * @param request + */ + abstract handle (request: IRequest): Promise; + + /** + * Bootstrap the application for HTTP requests. + * + * @return void + */ + abstract bootstrap (): void; + + /** + * Call the terminate method on any terminable middleware. + * + * @param request + * @param response + */ + abstract terminate (request: IRequest, response: IResponse): void; + + /** + * Register a callback to be invoked when the requests lifecycle duration exceeds a given amount of time. + * + * @param {number | DateTime} threshold + * @param handler + */ + abstract whenRequestLifecycleIsLongerThan (threshold: any, handler: (...args: any[]) => any): void; + + /** + * When the request being handled started. + * + * @returns {DateTime} + */ + abstract requestStartedAt (): any; + + /** + * Determine if the kernel has a given middleware. + * + * @param middleware + */ + abstract hasMiddleware (middleware: IMiddleware): boolean; + /** + * Add a new middleware to the beginning of the stack if it does not already exist. + * + * @param string middleware + */ + abstract prependMiddleware (middleware: IMiddleware): this; + /** + * Add a new middleware to end of the stack if it does not already exist. + * + * @param middleware + */ + abstract pushMiddleware (middleware: IMiddleware): this; + /** + * Prepend the given middleware to the given middleware group. + * + * @param group + * @param middleware + * + * @throws {InvalidArgumentException} + */ + abstract prependMiddlewareToGroup (group: string, middleware: IMiddleware): this; + /** + * Append the given middleware to the given middleware group. + * + * @param group + * @param middleware + * + * @throws {InvalidArgumentException} + */ + abstract appendMiddlewareToGroup (group: string, middleware: IMiddleware): this; + /** + * Prepend the given middleware to the middleware priority list. + * + * @param middleware + */ + abstract prependToMiddlewarePriority (middleware: IMiddleware): this; + /** + * Append the given middleware to the middleware priority list. + * + * @param string $middleware + * @return $this + */ + abstract appendToMiddlewarePriority (middleware: IMiddleware): this; + /** + * Add the given middleware to the middleware priority list before other middleware. + * + * @param before + * @param string $middleware + * @return $this + */ + abstract addToMiddlewarePriorityBefore (before: IMiddleware | IMiddleware[], middleware: IMiddleware): this; + /** + * Add the given middleware to the middleware priority list after other middleware. + * + * @param after + * @param middleware + */ + abstract addToMiddlewarePriorityAfter (after: IMiddleware | IMiddleware[], middleware: IMiddleware): this; + + /** + * Get the priority-sorted list of middleware. + * + * @return array + */ + abstract getMiddlewarePriority (): MiddlewareList; + + /** + * Get the application's global middleware. + * + * @return array + */ + abstract getGlobalMiddleware (): MiddlewareList; + /** + * Set the application's global middleware. + * + * @param middleware + * @returns + */ + abstract setGlobalMiddleware (middleware: MiddlewareList): this; + /** + * Get the application's route middleware groups. + * + * @return array + */ + abstract getMiddlewareGroups (): Record; + /** + * Set the application's middleware groups. + * + * @param groups + * @returns + */ + abstract setMiddlewareGroups (groups: Record): this; + /** + * Get the application's route middleware aliases. + * + * @return array + */ + abstract getMiddlewareAliases (): Record; + /** + * Set the application's route middleware aliases. + * + * @param aliases + */ + abstract setMiddlewareAliases (aliases: Record): this; + /** + * Set the application's middleware priority. + * + * @param priority + */ + abstract setMiddlewarePriority (priority: MiddlewareList): this; + /** + * Get the Laravel application instance. + */ + abstract getApplication (): IApplication; + /** + * Set the Laravel application instance. + * + * @param app + */ + abstract setApplication (app: IApplication): this; +} \ No newline at end of file diff --git a/packages/contracts/src/Foundation/MiddlewareContract.ts b/packages/contracts/src/Foundation/MiddlewareContract.ts new file mode 100644 index 00000000..1bd190bf --- /dev/null +++ b/packages/contracts/src/Foundation/MiddlewareContract.ts @@ -0,0 +1,5 @@ +import { IMiddleware } from '..' + +export type RedirectHandler = string | (() => string); +export type MiddlewareIdentifier = string | IMiddleware; +export type MiddlewareList = MiddlewareIdentifier[]; \ No newline at end of file diff --git a/packages/contracts/src/Foundation/RateLimiterAdapter.ts b/packages/contracts/src/Foundation/RateLimiterAdapter.ts new file mode 100644 index 00000000..f1f08066 --- /dev/null +++ b/packages/contracts/src/Foundation/RateLimiterAdapter.ts @@ -0,0 +1,27 @@ +export type LimitSpec = { + key?: string + maxAttempts: number + decaySeconds: number +} + +export type Unlimited = { + unlimited: true +} + +/** + * Rate Limiter Adapter Interface + */ +export interface RateLimiterAdapter { + /** + * Attempt a key with a maxAttempts and decaySeconds. + * + * Return true if this is allowed (i.e., *not* throttled), + * false if the limit is reached. + */ + attempt ( + key: string, + maxAttempts: number, + allowCallback: () => boolean | Promise, + decaySeconds: number + ): Promise +} \ No newline at end of file diff --git a/packages/contracts/src/Http/IFileBag.ts b/packages/contracts/src/Http/IFileBag.ts new file mode 100644 index 00000000..d7085e7a --- /dev/null +++ b/packages/contracts/src/Http/IFileBag.ts @@ -0,0 +1,26 @@ +import { IFileInput } from './Utils' +import { IParamBag } from './IParamBag' +import { IUploadedFile } from './IUploadedFile' + +/** + * FileBag is a container for uploaded files + * for H3ravel App. + */ +export abstract class IFileBag extends IParamBag { + /** + * Replace all stored files. + */ + abstract replace (files?: Record): void; + /** + * Set a file or array of files. + */ + abstract set (key: string, value: IFileInput | IFileInput[]): void; + /** + * Add multiple files. + */ + abstract add (files?: Record): void; + /** + * Get all stored files. + */ + abstract all (): Record; +} \ No newline at end of file diff --git a/packages/contracts/src/Http/IHeaderBag.ts b/packages/contracts/src/Http/IHeaderBag.ts new file mode 100644 index 00000000..16f709f7 --- /dev/null +++ b/packages/contracts/src/Http/IHeaderBag.ts @@ -0,0 +1,120 @@ +/** + * HeaderBag — A container for HTTP headers + * for H3ravel App. + */ +export abstract class IHeaderBag implements Iterable<[string, (string | null)[]]> { + /** + * Returns all headers as string (for debugging / toString) + * + * @returns + */ + abstract toString (): string; + /** + * Returns all headers or specific header list + * + * @param key + * @returns + */ + abstract all (key?: K): K extends string ? (string | null)[] : Record; + /** + * Returns header keys + * + * @returns + */ + abstract keys (): string[]; + /** + * Replace all headers with new set + * + * @param headers + */ + abstract replace (headers?: Record): void; + /** + * Add multiple headers + * + * @param headers + */ + abstract add (headers: Record): void; + /** + * Returns first header value by name or default + * + * @param key + * @param defaultValue + * @returns + */ + abstract get (key: string, defaultValue?: string | null | undefined): R extends undefined ? string | null | undefined : R; + /** + * Sets a header by name. + * + * @param replace Whether to replace existing values (default true) + */ + abstract set (key: string, values: string | string[] | null, replace?: boolean): void; + /** + * Returns true if header exists + * + * @param key + * @returns + */ + abstract has (key: string): boolean; + /** + * Returns true if header contains value + * + * @param key + * @param value + * @returns + */ + abstract contains (key: string, value: string): boolean; + /** + * Removes a header + * + * @param key + */ + abstract remove (key: string): void; + /** + * Returns parsed date from header + * + * @param key + * @param defaultValue + * @returns + */ + abstract getDate (key: string, defaultValue?: Date | null): any; + /** + * Adds a Cache-Control directive + * + * @param key + * @param value + */ + abstract addCacheControlDirective (key: string, value?: string | boolean): void; + /** + * Returns true if Cache-Control directive is defined + * + * @param key + * @returns + */ + abstract hasCacheControlDirective (key: string): boolean; + /** + * Returns a Cache-Control directive value by name + * + * @param key + * @returns + */ + abstract getCacheControlDirective (key: string): string | boolean | null; + /** + * Removes a Cache-Control directive + * + * @param key + * @returns + */ + abstract removeCacheControlDirective (key: string): void; + /** + * Number of headers + * + * @param key + * @returns + */ + abstract count (): number; + /** + * Iterator support + * @returns + */ + abstract [Symbol.iterator] (): Iterator<[string, (string | null)[]]>; +} \ No newline at end of file diff --git a/packages/contracts/src/Http/IHttpContext.ts b/packages/contracts/src/Http/IHttpContext.ts new file mode 100644 index 00000000..43350c1f --- /dev/null +++ b/packages/contracts/src/Http/IHttpContext.ts @@ -0,0 +1,24 @@ +import type { H3Event } from 'h3' +import type { IApplication } from '../Core/IApplication' +import type { IRequest } from './IRequest' +import type { IResponse } from './IResponse' + +export abstract class IHttpContext { + abstract app: IApplication + abstract event: H3Event + abstract request: IRequest + abstract response: IResponse + /** + * Retrieve an existing HttpContext instance for an event, if any. + */ + static get (event: unknown): IHttpContext | undefined { + void event + return + }; + /** + * Delete the cached context for a given event (optional cleanup). + */ + static forget (event: unknown): void { + void event + }; +} \ No newline at end of file diff --git a/packages/contracts/src/Http/IHttpRequest.ts b/packages/contracts/src/Http/IHttpRequest.ts new file mode 100644 index 00000000..f76721ec --- /dev/null +++ b/packages/contracts/src/Http/IHttpRequest.ts @@ -0,0 +1,254 @@ +import { H3Event } from 'h3' +import { IApplication } from '../Core/IApplication' +import { IFileBag } from './IFileBag' +import { IHeaderBag } from './IHeaderBag' +import { IHttpContext } from './IHttpContext' +import { IParamBag } from './IParamBag' +import { IServerBag } from './IServerBag' +import { IUrl } from '../Url/IUrl' +import { InputBag } from './IInputBag' +import { RequestMethod } from '../Utilities/Utilities' + +export abstract class IHttpRequest { + /** + * The current app instance + */ + abstract app: IApplication + /** + * Parsed request body + */ + abstract body: unknown + /** + * Gets route parameters. + * @returns An object containing route parameters. + */ + abstract params: NonNullable + /** + * Uploaded files (FILES). + */ + abstract files: IFileBag + /** + * Query string parameters (GET). + */ + abstract query: InputBag + /** + * Server and execution environment parameters + */ + abstract server: IServerBag + /** + * Cookies + */ + abstract cookies: InputBag + /** + * The current Http Context + */ + abstract context: IHttpContext + /** + * The request attributes (parameters parsed from the PATH_INFO, ...). + */ + abstract attributes: IParamBag + /** + * Gets the request headers. + * @returns An object containing request headers. + */ + abstract headers: IHeaderBag + /** + * Sets the parameters for this request. + * + * This method also re-initializes all properties. + * + * @param attributes + * @param cookies The COOKIE parameters + * @param files The FILES parameters + * @param server The SERVER parameters + * @param content The raw body data + */ + abstract initialize (): Promise; + /** + * Gets a list of content types acceptable by the client browser in preferable order. + * @returns {string[]} + */ + abstract getAcceptableContentTypes (): string[]; + /** + * Get a URI instance for the request. + */ + abstract getUriInstance (): IUrl; + /** + * Returns the requested URI (path and query string). + * + * @return {string} The raw URI (i.e. not URI decoded) + */ + abstract getRequestUri (): string; + /** + * Gets the scheme and HTTP host. + * + * If the URL was called with basic authentication, the user + * and the password are not added to the generated string. + */ + abstract getSchemeAndHttpHost (): string; + /** + * Returns the HTTP host being requested. + * + * The port name will be appended to the host if it's non-standard. + */ + abstract getHttpHost (): string; + /** + * Returns the root path from which this request is executed. + * + * @returns {string} The raw path (i.e. not urldecoded) + */ + abstract getBasePath (): string; + /** + * Returns the root URL from which this request is executed. + * + * The base URL never ends with a /. + * + * This is similar to getBasePath(), except that it also includes the + * script filename (e.g. index.php) if one exists. + * + * @return string The raw URL (i.e. not urldecoded) + */ + abstract getBaseUrl (): string; + /** + * Gets the request's scheme. + */ + abstract getScheme (): string; + /** + * Returns the port on which the request is made. + * + * This method can read the client port from the "X-Forwarded-Port" header + * when trusted proxies were set via "setTrustedProxies()". + * + * The "X-Forwarded-Port" header must contain the client port. + * + * @return int|string|null Can be a string if fetched from the server bag + */ + abstract getPort (): number | string | undefined; + abstract getHost (): string; + /** + * Checks whether the request is secure or not. + * + * This method can read the client protocol from the "X-Forwarded-Proto" header + * when trusted proxies were set via "setTrustedProxies()". + * + * The "X-Forwarded-Proto" header must contain the protocol: "https" or "http". + */ + abstract isSecure (): boolean; + /** + * Returns the value of the requested header. + */ + abstract getHeader (name: string): string | undefined | null; + /** + * Checks if the request method is of specified type. + * + * @param method Uppercase request method (GET, POST etc) + */ + abstract isMethod (method: string): boolean; + /** + * Checks whether or not the method is safe. + * + * @see https://tools.ietf.org/html/rfc7231#section-4.2.1 + */ + abstract isMethodSafe (): boolean; + /** + * Checks whether or not the method is idempotent. + */ + abstract isMethodIdempotent (): boolean; + /** + * Checks whether the method is cacheable or not. + * + * @see https://tools.ietf.org/html/rfc7231#section-4.2.3 + */ + abstract isMethodCacheable (): boolean; + /** + * Returns true if the request is an XMLHttpRequest (AJAX). + */ + abstract isXmlHttpRequest (): boolean; + /** + * Gets the request "intended" method. + * + * If the X-HTTP-Method-Override header is set, and if the method is a POST, + * then it is used to determine the "real" intended HTTP method. + * + * The _method request parameter can also be used to determine the HTTP method, + * but only if enableHttpMethodParameterOverride() has been called. + * + * The method is always an uppercased string. + * + * @see getRealMethod() + */ + abstract getMethod (): RequestMethod; + /** + * Gets the preferred format for the response by inspecting, in the following order: + * * the request format set using setRequestFormat; + * * the values of the Accept HTTP header. + * + * Note that if you use this method, you should send the "Vary: Accept" header + * in the response to prevent any issues with intermediary HTTP caches. + */ + abstract getPreferredFormat (defaultValue?: string): string | undefined; + /** + * Gets the format associated with the mime type. + */ + abstract getFormat (mimeType: string): string | undefined; + /** + * Gets the request format. + * + * Here is the process to determine the format: + * + * * format defined by the user (with setRequestFormat()) + * * _format request attribute + * * $default + * + * @see getPreferredFormat + */ + abstract getRequestFormat (defaultValue?: string): string | undefined; + /** + * Sets the request format. + */ + abstract setRequestFormat (format: string): void; + /** + * Gets the "real" request method. + * + * @see getMethod() + */ + abstract getRealMethod (): RequestMethod; + /** + * Gets the mime type associated with the format. + */ + abstract getMimeType (format: string): string | undefined; + /** + * Returns the request body content. + * + * @param asStream If true, returns a ReadableStream instead of the parsed string + * @return {string | ReadableStream | Promise} + */ + abstract getContent (asStream?: boolean): string | ReadableStream; + /** + * Gets a "parameter" value from any bag. + * + * This method is mainly useful for libraries that want to provide some flexibility. If you don't need the + * flexibility in controllers, it is better to explicitly get request parameters from the appropriate + * public property instead (attributes, query, request). + * + * Order of precedence: PATH (routing placeholders or custom attributes), GET, POST + * + * @internal use explicit input sources instead + */ + abstract get (key: string, defaultValue?: any): any; + /** + * Indicates whether this request originated from a trusted proxy. + * + * This can be useful to determine whether or not to trust the + * contents of a proxy-specific header. + */ + abstract isFromTrustedProxy (): boolean; + /** + * Returns the path being requested relative to the executed script. + * + * The path info always starts with a /. + * + * @return {string} The raw path (i.e. not urldecoded) + */ + abstract getPathInfo (): string; +} \ No newline at end of file diff --git a/packages/shared/src/Contracts/IHttpResponse.ts b/packages/contracts/src/Http/IHttpResponse.ts similarity index 75% rename from packages/shared/src/Contracts/IHttpResponse.ts rename to packages/contracts/src/Http/IHttpResponse.ts index 85027a08..029bc179 100644 --- a/packages/shared/src/Contracts/IHttpResponse.ts +++ b/packages/contracts/src/Http/IHttpResponse.ts @@ -1,25 +1,25 @@ -import { IRequest } from './IRequest' +import type { IRequest } from './IRequest' /** * Interface for the Response contract, defining methods for handling HTTP responses. */ -export interface IHttpResponse { +export abstract class IHttpResponse { /** * Set HTTP status code. */ - setStatusCode (code: number, text?: string): this; + abstract setStatusCode (code: number, text?: string): this; /** * Retrieves the status code for the current web response. */ - getStatusCode (): number; + abstract getStatusCode (): number; /** * Sets the response charset. */ - setCharset (charset: string): this; + abstract setCharset (charset: string): this; /** * Retrieves the response charset. */ - getCharset (): string | undefined; + abstract getCharset (): string | undefined; /** * Returns true if the response may safely be kept in a shared (surrogate) cache. * @@ -37,7 +37,7 @@ export interface IHttpResponse { * * @final */ - isCacheable (): boolean; + abstract isCacheable (): boolean; /** * Returns true if the response is "fresh". * @@ -45,65 +45,65 @@ export interface IHttpResponse { * origin. A response is considered fresh when it includes a Cache-Control/max-age * indicator or Expires header and the calculated age is less than the freshness lifetime. */ - isFresh (): boolean; + abstract isFresh (): boolean; /** * Returns true if the response includes headers that can be used to validate * the response with the origin server using a conditional GET request. */ - isValidateable (): boolean; + abstract isValidateable (): boolean; /** * Sets the response content. */ - setContent (content?: any): this; + abstract setContent (content?: any): this; /** * Gets the current response content. */ - getContent (): any; + abstract getContent (): any; /** * Set a header. */ - setHeader (name: string, value: string): this; + abstract setHeader (name: string, value: string): this; /** * Sets the HTTP protocol version (1.0 or 1.1). */ - setProtocolVersion (version: string): this; + abstract setProtocolVersion (version: string): this; /** * Gets the HTTP protocol version. */ - getProtocolVersion (): string; + abstract getProtocolVersion (): string; /** * Marks the response as "private". * * It makes the response ineligible for serving other clients. */ - setPrivate (): this; + abstract setPrivate (): this; /** * Marks the response as "public". * * It makes the response eligible for serving other clients. */ - setPublic (): this; + abstract setPublic (): this; /** * Returns the Date header as a DateTime instance. * @throws {RuntimeException} When the header is not parseable */ - getDate (): any; + abstract getDate (): any; /** - * Returns the age of the response in seconds. - * - * @final - */ - getAge (): number; + * Returns the age of the response in seconds. + * + * @final + */ + abstract getAge (): number; /** * Marks the response stale by setting the Age header to be equal to the maximum age of the response. */ - expire (): this; + abstract expire (): this; /** * Returns the value of the Expires header as a DateTime instance. * * @final */ - getExpires (): any; + abstract getExpires (): any; /** * Returns the number of seconds after the time specified in the response's Date * header when the response should no longer be considered fresh. @@ -111,25 +111,25 @@ export interface IHttpResponse { * First, it checks for a s-maxage directive, then a max-age directive, and then it falls * back on an expires header. It returns null when no maximum age can be established. */ - getMaxAge (): number | undefined; + abstract getMaxAge (): number | undefined; /** * Sets the number of seconds after which the response should no longer be considered fresh. * * This method sets the Cache-Control max-age directive. */ - setMaxAge (value: number): this; + abstract setMaxAge (value: number): this; /** * Sets the number of seconds after which the response should no longer be returned by shared caches when backend is down. * * This method sets the Cache-Control stale-if-error directive. */ - setStaleIfError (value: number): this; + abstract setStaleIfError (value: number): this; /** * Sets the number of seconds after which the response should no longer return stale content by shared caches. * * This method sets the Cache-Control stale-while-revalidate directive. */ - setStaleWhileRevalidate (value: number): this; + abstract setStaleWhileRevalidate (value: number): this; /** * Returns the response's time-to-live in seconds. * @@ -140,25 +140,25 @@ export interface IHttpResponse { * * @final */ - getTtl (): number | undefined; + abstract getTtl (): number | undefined; /** * Sets the response's time-to-live for shared caches in seconds. * * This method adjusts the Cache-Control/s-maxage directive. */ - setTtl (seconds: number): this; + abstract setTtl (seconds: number): this; /** * Sets the response's time-to-live for private/client caches in seconds. * * This method adjusts the Cache-Control/max-age directive. */ - setClientTtl (seconds: number): this; + abstract setClientTtl (seconds: number): this; /** * Sets the number of seconds after which the response should no longer be considered fresh by shared caches. * * This method sets the Cache-Control s-maxage directive. */ - setSharedMaxAge (value: number): this; + abstract setSharedMaxAge (value: number): this; /** * Returns the Last-Modified HTTP header as a DateTime instance. * @@ -166,7 +166,7 @@ export interface IHttpResponse { * * @final */ - getLastModified (): any; + abstract getLastModified (): any; /** * Sets the Last-Modified HTTP header with a DateTime instance. * @@ -176,18 +176,18 @@ export interface IHttpResponse { * * @final */ - setLastModified (date?: any): this; + abstract setLastModified (date?: any): this; /** * Returns the literal value of the ETag HTTP header. */ - getEtag (): string | null; + abstract getEtag (): string | null; /** * Sets the ETag value. * * @param etag The ETag unique identifier or null to remove the header * @param weak Whether you want a weak ETag or not */ - setEtag (etag?: string, weak?: boolean): this; + abstract setEtag (etag?: string, weak?: boolean): this; /** * Sets the response's cache headers (validation and/or expiration). * @@ -195,7 +195,7 @@ export interface IHttpResponse { * * @throws {InvalidArgumentException} */ - setCache (options: any): this; + abstract setCache (options: any): this; /** * Modifies the response so that it conforms to the rules defined for a 304 status code. * @@ -203,72 +203,72 @@ export interface IHttpResponse { * that MUST NOT be included in 304 responses. * @see https://tools.ietf.org/html/rfc2616#section-10.3.5 */ - setNotModified (): this; + abstract setNotModified (): this; /** * Add an array of headers to the response. * */ - withHeaders (headers: any): this; + abstract withHeaders (headers: any): this; /** * Set the exception to attach to the response. */ - withException (e: Error): this; + abstract withException (e: Error): this; /** * Throws the response in a HttpResponseException instance. * * @throws {HttpResponseException} */ - throwResponse (): void; + abstract throwResponse (): void; /** * Is response invalid? * * @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html */ - isInvalid (): boolean; + abstract isInvalid (): boolean; /** * Is response informative? */ - isInformational (): boolean; + abstract isInformational (): boolean; /** * Is response successful? */ - isSuccessful (): boolean; + abstract isSuccessful (): boolean; /** * Is the response a redirect? */ - isRedirection (): boolean; + abstract isRedirection (): boolean; /** * Is there a client error? */ - isClientError (): boolean; + abstract isClientError (): boolean; /** * Was there a server side error? */ - isServerError (): boolean; + abstract isServerError (): boolean; /** * Is the response OK? */ - isOk (): boolean; + abstract isOk (): boolean; /** * Is the response forbidden? */ - isForbidden (): boolean; + abstract isForbidden (): boolean; /** * Is the response a not found error? */ - isNotFound (): boolean; + abstract isNotFound (): boolean; /** * Is the response a redirect of some form? */ - isRedirect (location?: string | null): boolean; + abstract isRedirect (location?: string | null): boolean; /** * Is the response empty? */ - isEmpty (): boolean; + abstract isEmpty (): boolean; /** * Apply headers before sending response. */ - sendHeaders (statusCode?: number): this; + abstract sendHeaders (statusCode?: number): this; /** * Prepares the Response before it is sent to the client. * @@ -276,5 +276,5 @@ export interface IHttpResponse { * compliant with RFC 2616. Most of the changes are based on * the Request that is "associated" with this Response. **/ - prepare (request: IRequest): this; + abstract prepare (request: IRequest): this; } diff --git a/packages/contracts/src/Http/IInputBag.ts b/packages/contracts/src/Http/IInputBag.ts new file mode 100644 index 00000000..4ad8de8c --- /dev/null +++ b/packages/contracts/src/Http/IInputBag.ts @@ -0,0 +1,103 @@ +import { IParamBag } from './IParamBag' +import { RequestObject } from '../Utilities/Utilities' + +/** + * InputBag is a container for user input values + * (e.g., query params, body, cookies) + * for H3ravel App. + */ +export abstract class InputBag extends IParamBag { + /** + * Returns a scalar input value by name. + * + * @param key + * @param defaultValue + * @throws BadRequestException if the input contains a non-scalar value + * @returns + */ + abstract get (key: string, defaultValue?: T | null): T | string | number | boolean | null; + /** + * Replaces all current input values. + * + * @param inputs + * @returns + */ + abstract replace (inputs?: RequestObject): void; + /** + * Adds multiple input values. + * + * @param inputs + * @returns + */ + abstract add (inputs?: RequestObject): void; + /** + * Sets an input by name. + * + * @param key + * @param value + * @throws TypeError if value is not scalar or array + * @returns + */ + abstract set (key: string, value: any): void; + /** + * Returns true if a key exists. + * + * @param key + * @returns + */ + abstract has (key: string): boolean; + /** + * Returns all parameters. + * + * @returns + */ + abstract all (): RequestObject; + /** + * Converts a parameter value to string. + * + * @param key + * @param defaultValue + * @throws BadRequestException if input contains a non-scalar value + * @returns + */ + abstract getString (key: string, defaultValue?: string): string; + /** + * Filters input value with a predicate. + * Mimics PHP’s filter_var() in spirit, but simpler. + * + * @param key + * @param defaultValue + * @param filterFn + * @throws BadRequestException if validation fails + * @returns + */ + abstract filter (key: string, defaultValue?: T | null, filterFn?: (value: any) => boolean): T | null; + /** + * Returns an enum value by key. + * + * @param key + * @param EnumClass + * @param defaultValue + * @throws BadRequestException if conversion fails + * @returns + */ + abstract getEnum> (key: string, EnumClass: T, defaultValue?: T[keyof T] | null): T[keyof T] | null; + /** + * Removes a key. + * + * @param key + */ + abstract remove (key: string): void; + /** + * Returns all keys. + * + * @returns + */ + abstract keys (): string[]; + /** + * Returns number of parameters. + * + * @returns + */ + abstract count (): number; +} \ No newline at end of file diff --git a/packages/shared/src/Contracts/IParamBag.ts b/packages/contracts/src/Http/IParamBag.ts similarity index 63% rename from packages/shared/src/Contracts/IParamBag.ts rename to packages/contracts/src/Http/IParamBag.ts index 91129b81..21399da8 100644 --- a/packages/shared/src/Contracts/IParamBag.ts +++ b/packages/contracts/src/Http/IParamBag.ts @@ -1,18 +1,15 @@ -import { H3Event } from 'h3' -import { RequestObject } from './IHttp' +import type { H3Event } from 'h3' +import type { RequestObject } from '../Utilities/Utilities' -export declare class IParamBag implements Iterable<[string, any]> { +/** + * ParamBag is a container for key/value pairs + * for H3ravel App. + */ +export abstract class IParamBag implements Iterable<[string, any]> { /** * The current H3 H3Event instance */ - readonly event: H3Event - constructor( - parameters: RequestObject | undefined, - /** - * The current H3 H3Event instance - */ - event: H3Event - ); + abstract readonly event: H3Event /** * Returns the parameters. * @ @@ -20,21 +17,21 @@ export declare class IParamBag implements Iterable<[string, any]> { * * @throws BadRequestException if the value is not an array */ - all (key?: string): any; - get (key: string, defaultValue?: any): any; - set (key: string, value: any): void; + abstract all (key?: string): any; + abstract get (key: string, defaultValue?: any): any; + abstract set (key: string, value: any): void; /** * Returns true if the parameter is defined. * * @param key */ - has (key: string): boolean; + abstract has (key: string): boolean; /** * Removes a parameter. * * @param key */ - remove (key: string): void; + abstract remove (key: string): void; /** * * Returns the parameter as string. @@ -44,7 +41,7 @@ export declare class IParamBag implements Iterable<[string, any]> { * @throws UnexpectedValueException if the value cannot be converted to string * @returns */ - getString (key: string, defaultValue?: string): string; + abstract getString (key: string, defaultValue?: string): string; /** * Returns the parameter value converted to integer. * @@ -52,7 +49,7 @@ export declare class IParamBag implements Iterable<[string, any]> { * @param defaultValue * @throws UnexpectedValueException if the value cannot be converted to integer */ - getInt (key: string, defaultValue?: number): number; + abstract getInt (key: string, defaultValue?: number): number; /** * Returns the parameter value converted to boolean. * @@ -60,7 +57,7 @@ export declare class IParamBag implements Iterable<[string, any]> { * @param defaultValue * @throws UnexpectedValueException if the value cannot be converted to a boolean */ - getBoolean (key: string, defaultValue?: boolean): boolean; + abstract getBoolean (key: string, defaultValue?: boolean): boolean; /** * Returns the alphabetic characters of the parameter value. * @@ -68,7 +65,7 @@ export declare class IParamBag implements Iterable<[string, any]> { * @param defaultValue * @throws UnexpectedValueException if the value cannot be converted to string */ - getAlpha (key: string, defaultValue?: string): string; + abstract getAlpha (key: string, defaultValue?: string): string; /** * Returns the alphabetic characters and digits of the parameter value. * @@ -76,7 +73,7 @@ export declare class IParamBag implements Iterable<[string, any]> { * @param defaultValue * @throws UnexpectedValueException if the value cannot be converted to string */ - getAlnum (key: string, defaultValue?: string): string; + abstract getAlnum (key: string, defaultValue?: string): string; /** * Returns the digits of the parameter value. * @@ -85,27 +82,27 @@ export declare class IParamBag implements Iterable<[string, any]> { * @throws UnexpectedValueException if the value cannot be converted to string * @returns **/ - getDigits (key: string, defaultValue?: string): string; + abstract getDigits (key: string, defaultValue?: string): string; /** * Returns the parameter keys. */ - keys (): string[]; + abstract keys (): string[]; /** * Replaces the current parameters by a new set. */ - replace (parameters?: RequestObject): void; + abstract replace (parameters?: RequestObject): void; /** * Adds parameters. */ - add (parameters?: RequestObject): void; + abstract add (parameters?: RequestObject): void; /** * Returns the number of parameters. */ - count (): number; + abstract count (): number; /** * Returns an iterator for parameters. * * @returns */ - [Symbol.iterator] (): ArrayIterator<[string, any]>; + abstract [Symbol.iterator] (): ArrayIterator<[string, any]>; } \ No newline at end of file diff --git a/packages/shared/src/Contracts/IRequest.ts b/packages/contracts/src/Http/IRequest.ts similarity index 58% rename from packages/shared/src/Contracts/IRequest.ts rename to packages/contracts/src/Http/IRequest.ts index d4d58965..10608ab7 100644 --- a/packages/shared/src/Contracts/IRequest.ts +++ b/packages/contracts/src/Http/IRequest.ts @@ -1,50 +1,55 @@ -import type { DotNestedKeys, DotNestedValue } from './ObjContract' -import { HttpContext, RequestMethod } from './IHttp' +import type { DotNestedKeys, DotNestedValue } from '../Utilities/ObjContract' import type { H3Event } from 'h3' -import type { IApplication } from './IApplication' -import { IParamBag } from './IParamBag' -import { ISessionManager } from './ISessionManager' -import { IUploadedFile } from './IUploadedFile' +import type { IApplication } from '../Core/IApplication' +import type { IHeaderBag } from './IHeaderBag' +import type { IHttpContext } from './IHttpContext' +import { IHttpRequest } from './IHttpRequest' +import type { IParamBag } from './IParamBag' +import { IRoute } from '../Routing/IRoute' +import type { ISessionManager } from '../Session/ISessionManager' +import type { IUploadedFile } from './IUploadedFile' +import { IUrl } from '../Url/IUrl' +import type { RequestMethod } from '../Utilities/Utilities' type RequestObject = Record; /** * Interface for the Request contract, defining methods for handling HTTP request data. */ -export declare class IRequest< +export abstract class IRequest< D extends Record = Record, - R extends Record = Record -> { + R extends Record = Record, + U extends Record = Record +> extends IHttpRequest { /** * The current app instance */ - app: IApplication + abstract app: IApplication /** * Parsed request body */ - body: unknown + abstract body: unknown /** * The current Http Context */ - context: HttpContext + abstract context: IHttpContext /** * Gets route parameters. * @returns An object containing route parameters. */ - params: NonNullable + abstract params: NonNullable + /** - * Uploaded files (FILES). + * The request attributes (parameters parsed from the PATH_INFO, ...). */ - constructor( - /** - * The current H3 H3Event instance - */ - event: H3Event, - /** - * The current app instance - */ - app: IApplication); + public abstract attributes: IParamBag + + /** + * Gets the request headers. + * @returns An object containing request headers. + */ + public abstract headers: IHeaderBag /** * Factory method to create a Request instance from an H3Event. */ @@ -56,7 +61,12 @@ export declare class IRequest< /** * The current app instance */ - app: IApplication): Promise; + app: IApplication + ): Promise { + void event + void app + return Promise.resolve({} as IRequest) + } /** * Sets the parameters for this request. * @@ -68,11 +78,11 @@ export declare class IRequest< * @param server The SERVER parameters * @param content The raw body data */ - initialize (): Promise; + abstract initialize (): Promise; /** * Retrieve all data from the instance (query + body). */ - all> (keys?: string | string[]): T; + abstract all> (keys?: string | string[]): T; /** * Retrieve an input item from the request. * @@ -80,7 +90,7 @@ export declare class IRequest< * @param defaultValue * @returns */ - input (key?: K, defaultValue?: any): K extends undefined ? RequestObject : any; + abstract input (key?: K, defaultValue?: any): K extends undefined ? RequestObject : any; /** * Retrieve a file from the request. * @@ -94,64 +104,81 @@ export declare class IRequest< * @param expectArray set to true to return an `UploadedFile[]` array. * @returns */ - file (key?: K, defaultValue?: any, expectArray?: E): K extends undefined ? Record : E extends true ? IUploadedFile[] : IUploadedFile; + abstract file (): Record; + abstract file (key?: undefined, defaultValue?: any, expectArray?: true): Record; + abstract file (key: string, defaultValue?: any, expectArray?: false | undefined): IUploadedFile; + abstract file (key: string, defaultValue?: any, expectArray?: true): IUploadedFile[]; + /** + * Get the user making the request. + * + * @param guard + */ + abstract user (guard?: string): U | undefined + /** + * Get the route handling the request. + * + * @param param + * @param defaultRoute + */ + abstract route (): IRoute + abstract route (param?: string, defaultParam?: any): any /** * Determine if the uploaded data contains a file. * * @param key * @return boolean */ - hasFile (key: string): boolean; + abstract hasFile (key: string): boolean; /** * Get an object with all the files on the request. */ - allFiles (): Record; + abstract allFiles (): Record; /** * Extract and convert uploaded files from FormData. */ - convertUploadedFiles (files: Record): Record; + abstract convertUploadedFiles (files: Record): Record; /** * Determine if the data contains a given key. * * @param keys * @returns */ - has (keys: string[] | string): boolean; + abstract has (keys: string[] | string): boolean; /** * Determine if the instance is missing a given key. */ - missing (key: string | string[]): boolean; + abstract missing (key: string | string[]): boolean; /** * Get a subset containing the provided keys with values from the instance data. * * @param keys * @returns */ - only> (keys: string[]): T; + abstract only> (keys: string[]): T; /** * Get all of the data except for a specified array of items. * * @param keys * @returns */ - except> (keys: string[]): T; + abstract except> (keys: string[]): T; /** * Merges new input data into the current request's input source. * * @param input - An object containing key-value pairs to merge. * @returns this - For fluent chaining. */ - merge (input: Record): this; + abstract merge (input: Record): this; /** * Merge new input into the request's input, but only when that key is missing from the request. * * @param input */ - mergeIfMissing (input: Record): this; + abstract mergeIfMissing (input: Record): this; /** * Get the keys for all of the input and files. */ - keys (): string[]; + abstract keys (): string[]; /** * Get an instance of the current session manager * @@ -159,7 +186,7 @@ export declare class IRequest< * @param defaultValue * @returns an instance of the current session manager. */ - public session | undefined = undefined> (key?: K, defaultValue?: any): K extends undefined + public abstract session | undefined = undefined> (key?: K, defaultValue?: any): K extends undefined ? ISessionManager : K extends string ? any : void | Promise @@ -168,67 +195,67 @@ export declare class IRequest< * * @return bool */ - isJson (): boolean; + abstract isJson (): boolean; /** * Determine if the current request probably expects a JSON response. * * @returns */ - expectsJson (): boolean; + abstract expectsJson (): boolean; /** * Determine if the current request is asking for JSON. * * @returns */ - wantsJson (): boolean; + abstract wantsJson (): boolean; /** * Gets a list of content types acceptable by the client browser in preferable order. * @returns {string[]} */ - getAcceptableContentTypes (): string[]; + abstract getAcceptableContentTypes (): string[]; /** * Determine if the request is the result of a PJAX call. * * @return bool */ - pjax (): boolean; + abstract pjax (): boolean; /** * Returns true if the request is an XMLHttpRequest (AJAX). * * @alias isXmlHttpRequest() * @returns {boolean} */ - ajax (): boolean; + abstract ajax (): boolean; /** * Returns true if the request is an XMLHttpRequest (AJAX). */ - isXmlHttpRequest (): boolean; + abstract isXmlHttpRequest (): boolean; /** * Returns the value of the requested header. */ - getHeader (name: string): string | undefined | null; + abstract getHeader (name: string): string | undefined | null; /** * Checks if the request method is of specified type. * * @param method Uppercase request method (GET, POST etc) */ - isMethod (method: string): boolean; + abstract isMethod (method: string): boolean; /** * Checks whether or not the method is safe. * * @see https://tools.ietf.org/html/rfc7231#section-4.2.1 */ - isMethodSafe (): boolean; + abstract isMethodSafe (): boolean; /** * Checks whether or not the method is idempotent. */ - isMethodIdempotent (): boolean; + abstract isMethodIdempotent (): boolean; /** * Checks whether the method is cacheable or not. * * @see https://tools.ietf.org/html/rfc7231#section-4.2.3 */ - isMethodCacheable (): boolean; + abstract isMethodCacheable (): boolean; /** * Gets the request "intended" method. * @@ -242,33 +269,42 @@ export declare class IRequest< * * @see getRealMethod() */ - getMethod (): RequestMethod; + abstract getMethod (): RequestMethod; /** * Gets the "real" request method. * * @see getMethod() */ - getRealMethod (): RequestMethod; + abstract getRealMethod (): RequestMethod; /** * Get the client IP address. */ - ip (): string | undefined; + abstract ip (): string | undefined; + /** + * Get the flashed input from previous request + * + * @param key + * @param defaultValue + * @returns + */ + abstract old (): Promise> + abstract old (key: string, defaultValue?: any): Promise /** * Get a URI instance for the request. */ - uri (): unknown; + abstract uri (): unknown; /** * Get the full URL for the request. */ - fullUrl (): string; + abstract fullUrl (): string; /** * Return the Request instance. */ - instance (): this; + abstract instance (): this; /** * Get the request method. */ - method (): RequestMethod; + abstract method (): RequestMethod; /** * Get the JSON payload for the request. * @@ -276,14 +312,34 @@ export declare class IRequest< * @param defaultValue * @return {InputBag} */ - json (key?: string, defaultValue?: any): K extends undefined ? IParamBag : any; + abstract json (key?: string, defaultValue?: any): K extends undefined ? IParamBag : any; + /** + * Get the user resolver callback. + */ + abstract getUserResolver (): (gaurd?: string) => U | undefined + /** + * Set the user resolver callback. + * + * @param callback + */ + abstract setUserResolver (callback: (gaurd?: string) => U): this + /** + * Get the route resolver callback. + */ + abstract getRouteResolver (): () => IRoute | undefined + /** + * Set the route resolver callback. + * + * @param callback + */ + abstract setRouteResolver (callback: () => IRoute): this /** * Returns the request body content. * * @param asStream If true, returns a ReadableStream instead of the parsed string * @return {string | ReadableStream | Promise} */ - getContent (asStream?: boolean): string | ReadableStream; + abstract getContent (asStream?: boolean): string | ReadableStream; /** * Gets a "parameter" value from any bag. * @@ -295,7 +351,7 @@ export declare class IRequest< * * @internal use explicit input sources instead */ - get (key: string, defaultValue?: any): any; + abstract get (key: string, defaultValue?: any): any; /** * Validate the incoming request data * @@ -303,7 +359,7 @@ export declare class IRequest< * @param rules * @param messages */ - validate ( + abstract validate ( rules: R, messages?: Partial> ): Promise; @@ -318,21 +374,23 @@ export declare class IRequest< * * The HTTP method can only be overridden when the real HTTP method is POST. */ - static enableHttpMethodParameterOverride (): void; + static enableHttpMethodParameterOverride (): void { } /** * Checks whether support for the _method request parameter is enabled. */ - static getHttpMethodParameterOverride (): boolean; + static getHttpMethodParameterOverride (): boolean { + return false + } /** * Dump the items. * * @param keys * @return this */ - dump (...keys: any[]): this; + abstract dump (...keys: any[]): this; /** * Get the base event */ - getEvent (): H3Event; - getEvent> (key: K): DotNestedValue; + abstract getEvent (): H3Event; + abstract getEvent> (key: K): DotNestedValue; } diff --git a/packages/contracts/src/Http/IResponse.ts b/packages/contracts/src/Http/IResponse.ts new file mode 100644 index 00000000..509d3b02 --- /dev/null +++ b/packages/contracts/src/Http/IResponse.ts @@ -0,0 +1,94 @@ +import { ClassConstructor, ConcreteConstructor } from '../Utilities/Utilities' +import type { DotNestedKeys, DotNestedValue } from '@h3ravel/shared' +import { type H3Event, HTTPResponse } from 'h3' + +import { IApplication } from '../Core/IApplication' +import { IHttpContext } from './IHttpContext' +import { IHttpResponse } from './IHttpResponse' +import { IRequest } from './IRequest' + +/** + * Interface for the Response contract, defining methods for handling HTTP responses. + */ +export abstract class IResponse extends IHttpResponse { + /** + * The current app instance + */ + abstract app: IApplication + /** + * The current Http Context + */ + abstract context: IHttpContext + /** + * Sends content for the current web response. + */ + abstract sendContent (type?: 'html' | 'json' | 'text' | 'xml', parse?: boolean): unknown; + /** + * Sends content for the current web response. + */ + abstract send (type?: 'html' | 'json' | 'text' | 'xml'): unknown; + + /** + * Use an edge view as content + * + * @param viewPath The path to the view file + * @param send if set to true, the content will be returned, instead of the Response instance + * @returns + */ + abstract view (viewPath: string, data?: Record | undefined): Promise + abstract view (viewPath: string, data: Record | undefined, parse: boolean): Promise + + /** + * + * Parse content as edge view + * + * @param content The content to serve + * @param send if set to true, the content will be returned, instead of the Response instance + * @returns + */ + abstract viewTemplate (content: string, data?: Record | undefined): Promise + abstract viewTemplate (content: string, data: Record | undefined, parse: boolean): Promise + /** + * + * @param content The content to serve + * @param send if set to true, the content will be returned, instead of the Response instance + * @returns + */ + abstract html (content?: string): this; + abstract html (content: string, parse: boolean): IResponsable; + /** + * Send a JSON response. + */ + abstract json (data?: T): this; + abstract json (data: T, parse: boolean): T; + /** + * Send plain text. + */ + abstract text (content?: string): this; + abstract text (content: string, parse: boolean): IResponsable; + /** + * Send plain xml. + */ + abstract xml (data?: string): this; + abstract xml (data: string, parse: boolean): IResponsable; + /** + * Redirect to another URL. + */ + abstract redirect (location: string, status?: number, statusText?: string | undefined): this; + /** + * Dump the response. + */ + abstract dump (): this; + /** + * Get the base event + */ + abstract getEvent (): H3Event; + abstract getEvent> (key: K): DotNestedValue; +} + +export abstract class IResponsable extends HTTPResponse { + abstract toResponse (request: IRequest): IResponse + abstract HTTPResponse (): HTTPResponse +} + +export type ResponsableType = IResponse | IResponsable | ConcreteConstructor | string | X \ No newline at end of file diff --git a/packages/contracts/src/Http/IServerBag.ts b/packages/contracts/src/Http/IServerBag.ts new file mode 100644 index 00000000..6949545f --- /dev/null +++ b/packages/contracts/src/Http/IServerBag.ts @@ -0,0 +1,24 @@ +import { IParamBag } from './IParamBag' + +/** + * ServerBag — a simplified version of Symfony's ServerBag + * for H3ravel App. + * + * Responsible for extracting and normalizing HTTP headers + * from the incoming request. + */ +export abstract class IServerBag extends IParamBag { + /** + * Returns all request headers, normalized to uppercase with underscores. + * Example: content-type → CONTENT_TYPE + */ + abstract getHeaders (): Record; + /** + * Returns a specific header by name, case-insensitive. + */ + abstract get (name: string): string | undefined; + /** + * Returns true if a header exists. + */ + abstract has (name: string): boolean; +} \ No newline at end of file diff --git a/packages/contracts/src/Http/IUploadedFile.ts b/packages/contracts/src/Http/IUploadedFile.ts new file mode 100644 index 00000000..c6f86411 --- /dev/null +++ b/packages/contracts/src/Http/IUploadedFile.ts @@ -0,0 +1,10 @@ +export abstract class IUploadedFile { + abstract originalName: string + abstract mimeType: string + abstract size: number + abstract content: File + /** + * Save to disk (Node environment only) + */ + abstract moveTo (destination: string): Promise; +} \ No newline at end of file diff --git a/packages/contracts/src/Http/Utils.ts b/packages/contracts/src/Http/Utils.ts new file mode 100644 index 00000000..f43a74db --- /dev/null +++ b/packages/contracts/src/Http/Utils.ts @@ -0,0 +1,3 @@ +import { IUploadedFile } from './IUploadedFile' + +export type IFileInput = IUploadedFile | File | null | undefined; \ No newline at end of file diff --git a/packages/contracts/src/Queue/IJob.ts b/packages/contracts/src/Queue/IJob.ts new file mode 100644 index 00000000..1a12f7da --- /dev/null +++ b/packages/contracts/src/Queue/IJob.ts @@ -0,0 +1,141 @@ +import { IContainer } from '../Core/IContainer' +import { JobPayload } from './Utils' + +export abstract class IJob { + /** + * Get the job identifier. + */ + abstract getJobId (): string | number | undefined; + /** + * Get the raw body of the job. + */ + abstract getRawBody (): string; + /** + * Get the UUID of the job. + * + * @return string|null + */ + abstract uuid (): string | null; + /** + * Fire the job. + * + * @return void + */ + abstract fire (): void; + /** + * Delete the job from the queue. + */ + abstract delete (): void; + /** + * Determine if the job has been deleted. + */ + abstract isDeleted (): boolean; + /** + * Release the job back into the queue after (n) seconds. + * + * @param delay + */ + abstract release (delay?: number): void; + /** + * Determine if the job was released back into the queue. + * + * @return bool + */ + abstract isReleased (): boolean; + /** + * Determine if the job has been deleted or released. + */ + abstract isDeletedOrReleased (): boolean; + /** + * Determine if the job has been marked as a failure. + */ + abstract hasFailed (): boolean; + /** + * Mark the job as "failed". + */ + abstract markAsFailed (): void; + /** + * Delete the job, call the "failed" method, and raise the failed job event. + * + * @param e + */ + abstract fail (e: Error): void; + /** + * Get the resolved job handler instance. + * + * @return mixed + */ + abstract getResolvedJob (): IJob; + /** + * Get the decoded body of the job. + */ + abstract payload (): JobPayload; + /** + * Get the number of times to attempt a job. + * + * @return int|null + */ + abstract maxTries (): number | null; + /** + * Get the number of times to attempt a job after an exception. + * + * @return int|null + */ + abstract maxExceptions (): number | null; + /** + * Determine if the job should fail when it timeouts. + * + * @return bool + */ + abstract shouldFailOnTimeout (): boolean; + /** + * The number of seconds to wait before retrying a job that encountered an uncaught exception. + * + * @return int|int[]|null + */ + abstract backoff (): number | null; + /** + * Get the number of seconds the job can run. + * + * @return int|null + */ + abstract timeout (): number | null; + /** + * Get the timestamp indicating when the job should timeout. + * + * @return int|null + */ + abstract retryUntil (): number | null; + /** + * Get the name of the queued job class. + * + * @return string + */ + abstract getName (): string; + /** + * Get the resolved display name of the queued job class. + * + * Resolves the name of "wrapped" jobs such as class-based handlers. + */ + abstract resolveName (): any; + /** + * Get the class of the queued job. + * + * Resolves the class of "wrapped" jobs such as class-based handlers. + * + * @return string + */ + abstract resolveQueuedJobClass (): any; + /** + * Get the name of the connection the job belongs to. + */ + abstract getConnectionName (): string; + /** + * Get the name of the queue the job belongs to. + */ + abstract getQueue (): string | undefined; + /** + * Get the service container instance. + */ + abstract getContainer (): IContainer; +} \ No newline at end of file diff --git a/packages/contracts/src/Queue/Utils.ts b/packages/contracts/src/Queue/Utils.ts new file mode 100644 index 00000000..10388bce --- /dev/null +++ b/packages/contracts/src/Queue/Utils.ts @@ -0,0 +1,12 @@ +export interface JobPayload { + maxTries?: number; + maxExceptions?: number; + failOnTimeout?: boolean; + timeout?: number; + retryUntil?: number; + job: string; + backoff?: number; + delay?: number; + data?: any; + uuid?: string; +} \ No newline at end of file diff --git a/packages/contracts/src/Routing/IAbstractRouteCollection.ts b/packages/contracts/src/Routing/IAbstractRouteCollection.ts new file mode 100644 index 00000000..d82625a8 --- /dev/null +++ b/packages/contracts/src/Routing/IAbstractRouteCollection.ts @@ -0,0 +1,8 @@ +import type { IRoute } from './IRoute' +import type { RouteMethod } from '../Utilities/Utilities' + +export declare abstract class IAbstractRouteCollection { + static verbs: RouteMethod[] + abstract get (method?: string): Record | IRoute[]; + abstract getRoutes (): IRoute[]; +} \ No newline at end of file diff --git a/packages/contracts/src/Routing/ICallableDispatcher.ts b/packages/contracts/src/Routing/ICallableDispatcher.ts new file mode 100644 index 00000000..cbb0a074 --- /dev/null +++ b/packages/contracts/src/Routing/ICallableDispatcher.ts @@ -0,0 +1,2 @@ +export abstract class ICallableDispatcher { +} \ No newline at end of file diff --git a/packages/contracts/src/Routing/ICompiledRoute.ts b/packages/contracts/src/Routing/ICompiledRoute.ts new file mode 100644 index 00000000..4692836a --- /dev/null +++ b/packages/contracts/src/Routing/ICompiledRoute.ts @@ -0,0 +1,18 @@ +export declare class ICompiledRoute { + /** + * Get the compiled path regex + */ + getRegex (): RegExp; + /** + * Get the compiled host regex (if any) + */ + getHostRegex (): RegExp | undefined; + /** + * Returns list of all param names (including optional) + */ + getParamNames (): string[]; + /** + * Returns optional params record + */ + getOptionalParams (): Record; +} \ No newline at end of file diff --git a/packages/contracts/src/Routing/IControllerDispatcher.ts b/packages/contracts/src/Routing/IControllerDispatcher.ts new file mode 100644 index 00000000..dc8e1e83 --- /dev/null +++ b/packages/contracts/src/Routing/IControllerDispatcher.ts @@ -0,0 +1,24 @@ +import { ControllerMethod, RouteMethod } from '../Utilities/Utilities' + +import { IController } from '../Core/IController' +import { IMiddleware } from './IMiddleware' +import { IRoute } from './IRoute' + +export abstract class IControllerDispatcher { + /** + * Dispatch a request to a given controller and method. + * + * @param route + * @param controller + * @param method + */ + abstract dispatch (route: IRoute, controller: IController, method: ControllerMethod): Promise; + + /** + * Get the middleware for the controller instance. + * + * @param controller + * @param method + */ + abstract getMiddleware (controller: IController, method: RouteMethod): IMiddleware[]; +} \ No newline at end of file diff --git a/packages/contracts/src/Routing/IMiddleware.ts b/packages/contracts/src/Routing/IMiddleware.ts new file mode 100644 index 00000000..b680ee0a --- /dev/null +++ b/packages/contracts/src/Routing/IMiddleware.ts @@ -0,0 +1,10 @@ +import { RouteMethod } from '../Utilities/Utilities' + +/** + * Defines the contract for all middlewares. + * Any middleware implementing this must define these methods. + */ +export abstract class IMiddleware { + options: { only?: RouteMethod[], except?: RouteMethod[] } = {} + abstract handle (...args: any[]): Promise +} \ No newline at end of file diff --git a/packages/shared/src/Contracts/IMiddlewareHandler.ts b/packages/contracts/src/Routing/IMiddlewareHandler.ts similarity index 65% rename from packages/shared/src/Contracts/IMiddlewareHandler.ts rename to packages/contracts/src/Routing/IMiddlewareHandler.ts index afe5b5a4..b4fee7bf 100644 --- a/packages/shared/src/Contracts/IMiddlewareHandler.ts +++ b/packages/contracts/src/Routing/IMiddlewareHandler.ts @@ -1,4 +1,5 @@ -import { HttpContext, IMiddleware } from './IHttp' +import type { IHttpContext } from '../Http/IHttpContext' +import type { IMiddleware } from './IMiddleware' export declare class IMiddlewareHandler { /** @@ -11,9 +12,9 @@ export declare class IMiddlewareHandler { * Runs the middleware chain for a given HttpContext. * Each middleware must call next() to continue the chain. * - * @param context - The standardized HttpContext. + * @param context - The current HttpContext. * @param next - Callback to execute when middleware completes. * @returns A promise resolving to the final handler's result. */ - run (context: HttpContext, next: (ctx: HttpContext) => Promise): Promise; + run (context: IHttpContext, next: (ctx: IHttpContext) => Promise): Promise; } \ No newline at end of file diff --git a/packages/contracts/src/Routing/IRoute.ts b/packages/contracts/src/Routing/IRoute.ts new file mode 100644 index 00000000..42730dcf --- /dev/null +++ b/packages/contracts/src/Routing/IRoute.ts @@ -0,0 +1,226 @@ +import type { CallableConstructor, RouteActions, RouteMethod } from '../Utilities/Utilities' + +import type { ICompiledRoute } from './ICompiledRoute' +import type { IContainer } from '../Core/IContainer' +import { IController } from '../Core/IController' +import { IRequest } from '../Http/IRequest' + +export abstract class IRoute { + /** + * The default values for the route. + */ + public abstract _defaults: Record + /** + * The compiled version of the route. + */ + public abstract compiled?: ICompiledRoute + /** + * The array of matched parameters. + */ + public abstract parameters?: Record + /** + * The route action array. + */ + public abstract action: RouteActions + /** + * The HTTP methods the route responds to. + */ + public abstract methods: RouteMethod[] + /** + * The route path that can be handled by H3. + */ + public abstract path: string + /** + * The computed gathered middleware. + */ + public abstract computedMiddleware?: Record + /** + * The controller instance. + */ + public abstract controller?: Required + /** + * Set the router instance on the route. + * + * @param router + */ + abstract setRouter (router: any): this; + /** + * Set the container instance on the route. + * + * @param container + */ + abstract setContainer (container: IContainer): this; + /** + * Set the URI that the route responds to. + * + * @param uri + */ + abstract setUri (uri: string): this; + /** + * Get the URI associated with the route. + */ + abstract uri (): string; + /** + * Add a prefix to the route URI. + * + * @param prefix + */ + abstract prefix (prefix: string): this; + /** + * Get the name of the route instance. + */ + abstract getName (): string | undefined; + /** + * Add or change the route name. + * + * @param name + * + * @throws {InvalidArgumentException} + */ + abstract name (name: string): this; + /** + * Determine whether the route's name matches the given patterns. + * + * @param patterns + */ + abstract named (...patterns: string[]): boolean; + /** + * Get the action name for the route. + */ + abstract getActionName (): any; + /** + * Get the method name of the route action. + * + * @return string + */ + abstract getActionMethod (): any; + /** + * Get the action array or one of its properties for the route. + * @param key + */ + abstract getAction (key?: string): any; + /** + * Determine if the route only responds to HTTP requests. + */ + abstract httpOnly (): boolean; + /** + * Get or set the middlewares attached to the route. + * + * @param array|string|null $middleware + * @return $this|array + */ + abstract middleware (middleware?: string | string[]): any[] | this; + /** + * Specify that the "Authorize" / "can" middleware should be applied to the route with the given options. + * + * @param ability + * @param models + */ + abstract can (ability: string, models?: string | string[]): any[] | this; + /** + * Set the action array for the route. + * + * @param action + */ + abstract setAction (action: RouteActions): this; + /** + * Determine if the route only responds to HTTPS requests. + */ + abstract secure (): boolean; + /** + * Bind the route to a given request for execution. + * + * @param request + */ + abstract bind (request: IRequest): this; + /** + * Get or set the domain for the route. + * + * @param domain + * + * @throws {InvalidArgumentException} + */ + abstract domain (domain?: D): D extends undefined ? string : this; + /** + * Get the key / value list of original parameters for the route. + * + * @throws {LogicException} + */ + abstract originalParameters (): Record; + /** + * Get the matched parameters object. + */ + abstract getParameters (): Record + /** + * Get a given parameter from the route. + * + * @param name + * @param defaultParam + */ + abstract parameter (name: string, defaultParam?: any): any + /** + * Get the domain defined for the route. + */ + abstract getDomain (): string | undefined; + /** + * Get the compiled version of the route. + */ + abstract getCompiled (): ICompiledRoute | undefined; + /** + * Set a default value for the route. + * + * @param key + * @param value + */ + abstract defaults (key: string, value: any): this; + /** + * Set the default values for the route. + * + * @param defaults + */ + abstract setDefaults (defaults: Record): this; + /** + * Get the optional parameter names for the route. + */ + abstract getOptionalParameterNames (): Record; + /** + * Get all of the parameter names for the route. + */ + abstract parameterNames (): string[]; + /** + * Flush the cached container instance on the route. + */ + abstract flushController (): void + /** + * Compile the route once, cache the result, return compiled data + */ + abstract compileRoute (): ICompiledRoute; + /** + * Get the value of the action that should be taken on a missing model exception. + */ + abstract getMissing (): CallableConstructor | undefined + /** + * The route path that can be handled by H3. + */ + abstract getPath (): string + /** + * Define the callable that should be invoked on a missing model exception. + * + * @param missing + */ + abstract missing (missing: CallableConstructor): this + /** + * Specify middleware that should be removed from the given route. + * + * @param middleware + */ + abstract withoutMiddleware (middleware: any): this + /** + * Get the middleware that should be removed from the route. + */ + abstract excludedMiddleware (): any + /** + * Get all middleware, including the ones from the controller. + */ + abstract gatherMiddleware (): Record +} \ No newline at end of file diff --git a/packages/contracts/src/Routing/IRouteCollection.ts b/packages/contracts/src/Routing/IRouteCollection.ts new file mode 100644 index 00000000..48a4914e --- /dev/null +++ b/packages/contracts/src/Routing/IRouteCollection.ts @@ -0,0 +1,56 @@ +import type { IAbstractRouteCollection } from './IAbstractRouteCollection' +import { IRequest } from '../Http/IRequest' +import type { IRoute } from './IRoute' + +export declare class IRouteCollection extends IAbstractRouteCollection { + /** + * Add a IRoute instance to the collection. + */ + add (route: IRoute): IRoute; + /** + * Refresh the name look-up table. + * + * This is done in case any names are fluently defined or if routes are overwritten. + */ + refreshNameLookups (): void; + /** + * Refresh the action look-up table. + * + * This is done in case any actions are overwritten with new controllers. + */ + refreshActionLookups (): void; + /** + * Find the first route matching a given request. + * + * May throw framework-specific exceptions (MethodNotAllowed / NotFound). + */ + match (request: IRequest): IRoute; + /** + * Get routes from the collection by method. + */ + get (method?: string): Record | IRoute[]; + /** + * Determine if the route collection contains a given named route. + */ + hasNamedRoute (name: string): boolean; + /** + * Get a route instance by its name. + */ + getByName (name: string): IRoute | null; + /** + * Get a route instance by its controller action. + */ + getByAction (action: string): IRoute | null; + /** + * Get all of the routes in the collection. + */ + getRoutes (): IRoute[]; + /** + * Get all of the routes keyed by their HTTP verb / method. + */ + getRoutesByMethod (): Record>; + /** + * Get all of the routes keyed by their name. + */ + getRoutesByName (): Record; +} \ No newline at end of file diff --git a/packages/contracts/src/Routing/IRouter.ts b/packages/contracts/src/Routing/IRouter.ts new file mode 100644 index 00000000..26bc9481 --- /dev/null +++ b/packages/contracts/src/Routing/IRouter.ts @@ -0,0 +1,230 @@ +import type { Middleware, MiddlewareOptions } from 'h3' +import type { IRoute } from './IRoute' +import type { IRouteCollection } from './IRouteCollection' +import type { IController } from '../Core/IController' +import type { IMiddleware } from './IMiddleware' +import type { ActionInput, RouteEventHandler, RouteActions, RouteMethod, ExtractClassMethods, RouterEnd } from '../Utilities/Utilities' +import { IRequest } from '../Http/IRequest' +import { MiddlewareList } from '../Foundation/MiddlewareContract' +import { IResponse } from '../Http/IResponse' + +/** + * Interface for the Router contract, defining methods for HTTP routing. + */ +export abstract class IRouter { + /** + * All of the verbs supported by the router. + */ + static verbs: RouteMethod[] + /** + * Get the currently dispatched route instance. + */ + abstract getCurrentRoute (): IRoute | undefined; + /** + * Check if a route with the given name exists. + * + * @param name + */ + abstract has (...name: string[]): boolean; + /** + * Get the current route name. + */ + abstract currentRouteName (): string | undefined; + /** + * Alias for the "currentRouteNamed" method. + * + * @param patterns + */ + abstract is (...patterns: string[]): boolean; + /** + * Determine if the current route matches a pattern. + * + * @param patterns + */ + abstract currentRouteNamed (...patterns: string[]): boolean; + /** + * Get the underlying route collection. + */ + abstract getRoutes (): IRouteCollection; + /** + * Create a new IRoute object. + * + * @param methods + * @param uri + * @param action + */ + abstract newRoute (methods: RouteMethod | RouteMethod[], uri: string, action: ActionInput): IRoute; + /** + * Dispatch the request to the application. + * + * @param request + */ + abstract dispatch (request: IRequest): Promise; + /** + * Dispatch the request to a route and return the response. + * + * @param request + */ + abstract dispatchToRoute (request: IRequest): Promise; + /** + * Registers a route that responds to HTTP GET requests. + * + * @param path The URL pattern to match (can include parameters, e.g., '/users/:id'). + * @param definition Either: + * - An EventHandler function + * - A tuple: [ControllerClass, methodName] + * @param name Optional route name (for URL generation or referencing). + * @param middleware Optional array of middleware functions to execute before the handler. + */ + abstract get any> ( + path: string, + definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], + name?: string, + middleware?: IMiddleware[] + ): Omit; + /** + * Registers a route that responds to HTTP POST requests. + * + * @param path The URL pattern to match (can include parameters, e.g., '/users'). + * @param definition Either: + * - An EventHandler function + * - A tuple: [ControllerClass, methodName] + * @param name Optional route name (for URL generation or referencing). + * @param middleware Optional array of middleware functions to execute before the handler. + */ + abstract post any> ( + path: string, + definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], + name?: string, middleware?: IMiddleware[] + ): Omit; + /** + * Registers a route that responds to HTTP PUT requests. + * + * @param path The URL pattern to match (can include parameters, e.g., '/users/:id'). + * @param definition Either: + * - An EventHandler function + * - A tuple: [ControllerClass, methodName] + * @param name Optional route name (for URL generation or referencing). + * @param middleware Optional array of middleware functions to execute before the handler. + */ + abstract put any> ( + path: string, + definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], + name?: string, + middleware?: IMiddleware[] + ): Omit; + /** + * Registers a route that responds to HTTP PATCH requests. + * + * @param path The URL pattern to match (can include parameters, e.g., '/users/:id'). + * @param definition Either: + * - An EventHandler function + * - A tuple: [ControllerClass, methodName] + * @param name Optional route name (for URL generation or referencing). + * @param middleware Optional array of middleware functions to execute before the handler. + */ + abstract patch any> ( + path: string, + definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], + name?: string, + middleware?: IMiddleware[] + ): Omit; + /** + * Registers a route that responds to HTTP DELETE requests. + * + * @param path The URL pattern to match (can include parameters, e.g., '/users/:id'). + * @param definition Either: + * - An EventHandler function + * - A tuple: [ControllerClass, methodName] + * @param name Optional route name (for URL generation or referencing). + * @param middleware Optional array of middleware functions to execute before the handler. + */ + abstract delete any> ( + path: string, + definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], + name?: string, + middleware?: IMiddleware[] + ): Omit; + /** + * API Resource support + * + * @param path + * @param controller + */ + abstract apiResource any> ( + path: string, + Controller: C, middleware?: IMiddleware[] + ): Omit; + /** + * Registers a route the matches the provided methods. + * @param methods - The route methods to match. + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + */ + abstract match ( + methods: Lowercase[], + uri: string, + action: ActionInput + ): IRoute; + /** + * Named route URL generator + * + * @param name + * @param params + * @returns + */ + abstract route (name: string, params?: Record): string | undefined; + /** + * Grouping + * + * @param options + * @param callback + */ + /** + * Create a route group with shared attributes. + * + * @param attributes + * @param routes + */ + abstract group void) | string> (attributes: RouteActions, routes: C | C[]): this; + /** + * Merge the given array with the last group stack. + * + * @param newItems + * @param prependExistingPrefix + */ + abstract mergeWithLastGroup (newItems: RouteActions, prependExistingPrefix?: boolean): RouteActions; + /** + * Get the prefix from the last group on the stack. + */ + abstract getLastGroupPrefix (): any; + /** + * Determine if the router currently has a group stack. + */ + abstract hasGroupStack (): boolean; + /** + * Set the name of the current route + * + * @param name + */ + abstract name (name: string): this; + /** + * Registers middleware for a specific path. + * @param path - The path to apply the middleware. + * @param handler - The middleware handler. + * @param opts - Optional middleware options. + */ + abstract middleware ( + path: string | IMiddleware[] | Middleware, + handler: Middleware | MiddlewareOptions, + opts?: MiddlewareOptions + ): this; + + /** + * Register a group of middleware. + * + * @param name + * @param middleware + */ + abstract middlewareGroup (name: string, middleware: MiddlewareList): this +} \ No newline at end of file diff --git a/packages/contracts/src/Session/FlashBag.ts b/packages/contracts/src/Session/FlashBag.ts new file mode 100644 index 00000000..7575b374 --- /dev/null +++ b/packages/contracts/src/Session/FlashBag.ts @@ -0,0 +1,71 @@ +export declare class FlashBag { + /** + * Flash a value for the next request + * + * @param key Key to store in flash + * @param value Value to be flashed + */ + flash (key: string, value: any): void; + /** + * Store a temporary value for the current request only + * + * @param key Key to store + * @param value Value to store + */ + now (key: string, value: any): void; + /** + * Reflash all current flash data for another request cycle + */ + reflash (): void; + /** + * Keep only specific flash keys for the next request + * + * @param keys Keys to keep + */ + keep (keys: string[]): void; + /** + * Age flash data at the end of the request + * + * - Removes old flash data + * - Moves new flash data to old + * - Clears new flash data + */ + ageFlashData (): void; + /** + * Get a flash value + * + * @param key Key to retrieve + * @param defaultValue Default value if key doesn't exist + * @returns Flash value or default + */ + get (key: string, defaultValue?: any): any; + /** + * Check if a flash key exists + * + * @param key Key to check + * @returns Boolean indicating existence + */ + has (key: string): boolean; + /** + * Get all flash data + * + * @returns Combined flash data + */ + all (): Record; + /** + * Get all flash data keys + * + * @returns Combined flash data + */ + keys (): string[]; + /** + * Get the raww flash data + * + * @returns raw flash data + */ + raw (): Record; + /** + * Clear all flash data + */ + clear (): void; +} \ No newline at end of file diff --git a/packages/shared/src/Contracts/ISessionManager.ts b/packages/contracts/src/Session/ISessionManager.ts similarity index 94% rename from packages/shared/src/Contracts/ISessionManager.ts rename to packages/contracts/src/Session/ISessionManager.ts index 1c2cfbf2..b13404ea 100644 --- a/packages/shared/src/Contracts/ISessionManager.ts +++ b/packages/contracts/src/Session/ISessionManager.ts @@ -1,4 +1,5 @@ -import { HttpContext } from './IHttp' +import type { DriverOption } from './SessionContract' +import type { IHttpContext } from '../Http/IHttpContext' /** * SessionManager @@ -12,7 +13,8 @@ export declare class ISessionManager { * @param driverName - registered driver key ('file' | 'database' | 'memory' | 'redis') * @param driverOptions - optional bag for driver-specific options */ - constructor(ctx: HttpContext, driverName?: 'file' | 'memory' | 'database' | 'redis', driverOptions?: any); + constructor(ctx: IHttpContext, driverName: 'file' | 'memory' | 'database' | 'redis', driverOptions: DriverOption) + /** * Access the current session ID. */ diff --git a/packages/contracts/src/Session/SessionContract.ts b/packages/contracts/src/Session/SessionContract.ts new file mode 100644 index 00000000..c2ed8b8b --- /dev/null +++ b/packages/contracts/src/Session/SessionContract.ts @@ -0,0 +1,186 @@ +import { FlashBag } from './FlashBag' + +/** + * SessionDriver Interface + * + * All session drivers must implement these methods to ensure + * consistency across different storage mechanisms (memory, files, database, redis). + */ +export interface SessionDriver { + flashBag: FlashBag + + /** + * Retrieve a value from the session by key. + * + * @param key + * @param defaultValue + */ + get (key: string, defaultValue?: any): T | Promise + + /** + * Store multiple values in the session. + * + * @param key + * @param defaultValue + */ + set (value: Record): void | Promise + + /** + * Retrieve all data from the session including flash + * + * @returns + */ + getAll> (): Promise | T + + /** + * Store a value in the session. + * + * @param key + * @param value + */ + put (key: string, value: any): void | Promise + + /** + * Append a value to an array key + * + * @param key + * @param value + */ + push (key: string, value: any): Promise | void + + /** + * Remove a key from the session. + * + * @param key + */ + forget (key: string): Promise | void + + /** + * Determine if a key is present in the session. + * + * @param key + */ + has (key: string): Promise | boolean + + /** + * Determine if a key exists in the session (even if null). + * + * @param key + */ + exists (key: string): Promise | boolean + + /** + * Get all data from the session. + */ + all> (): Promise | T + + /** + * Get only a subset of session keys. + * + * @param keys + */ + only> (keys: string[]): Promise | T + + /** + * Get all session data except the specified keys. + * + * @param keys + */ + except> (keys: string[]): Promise | T + + /** + * Get and remove an item from the session. + * + * @param key + * @param defaultValue + */ + pull (key: string, defaultValue?: any): Promise | T + + /** + * Increment a numeric session value. + * + * @param key + * @param amount + */ + increment (key: string, amount?: number): Promise | number + + /** + * Decrement a numeric session value. + * + * @param key + * @param amount + */ + decrement (key: string, amount?: number): Promise | number + + /** + * Flash a key/value pair for the next request only. + * + * @param key + * @param value + */ + flash (key: string, value: any): Promise | void + + /** + * Reflash all current flash data for another request cycle. + */ + reflash (): Promise | void + + /** + * Keep only specific flash data for another request. + * + * @param keys + */ + keep (keys: string[]): Promise | void + + /** + * Store data for the current request only (not persisted). + * + * @param key + * @param value + */ + now (key: string, value: any): Promise | void + + /** + * Regenerate the session ID and optionally persist the data. + */ + regenerate (): Promise | void + + /** + * Invalidate the session completely and regenerate ID. + */ + invalidate (): Promise | void + + /** + * Determine if an item is not present in the session. + * + * @param key + */ + missing (key: string): Promise | boolean + + /** + * Flush all session data + */ + flush (): Promise | void + + /** + * Age flash data at the end of the request lifecycle. + */ + ageFlashData (): Promise | void +} + +export interface DriverOption { + cwd?: string + dir?: string + table?: string + prefix?: string + client?: any + sessionId?: string + sessionDir?: string +} + +/** + * A builder function that returns a SessionDriver for a given sessionId. + * + * The builder receives the sessionId and a driver-specific options bag. + */ +export type DriverBuilder = (sessionId: string, options?: DriverOption) => SessionDriver \ No newline at end of file diff --git a/packages/contracts/src/Url/IRequestAwareUrl.ts b/packages/contracts/src/Url/IRequestAwareUrl.ts new file mode 100644 index 00000000..5edaeba6 --- /dev/null +++ b/packages/contracts/src/Url/IRequestAwareUrl.ts @@ -0,0 +1,29 @@ +/** + * Contract for request-aware URL helpers + */ +export abstract class IRequestAwareUrl { + /** + * Get the current request URL + */ + abstract current (): string + + /** + * Get the full current URL with query string + */ + abstract full (): string + + /** + * Get the previous request URL + */ + abstract previous (): string + + /** + * Get the previous request path (without query string) + */ + abstract previousPath (): string + + /** + * Get the current query parameters + */ + abstract query (): Record +} \ No newline at end of file diff --git a/packages/contracts/src/Url/IUrl.ts b/packages/contracts/src/Url/IUrl.ts new file mode 100644 index 00000000..ec34ea4b --- /dev/null +++ b/packages/contracts/src/Url/IUrl.ts @@ -0,0 +1,140 @@ +import { IApplication } from '../Core/IApplication' +import { ExtractClassMethods } from '../Utilities/Utilities' +import { RouteParams } from './Utils' + +export abstract class IUrl { + /** + * Create a URL from a full URL string + */ + static of (url: string, app?: IApplication): IUrl { + void url + void app + return {} as IUrl + }; + /** + * Create a URL from a path relative to the app URL + */ + static to (path: string, app?: IApplication): IUrl { + void path + void app + return {} as IUrl + }; + /** + * Create a URL from a named route + */ + static route ( + name: TName, + params?: TParams, + app?: IApplication + ): IUrl { + void name + void params + void app + return {} as IUrl + }; + /** + * Create a signed URL from a named route + */ + static signedRoute ( + name: TName, + params?: TParams, + app?: IApplication + ): IUrl { + void name + void params + void app + return {} as IUrl + }; + /** + * Create a temporary signed URL from a named route + */ + static temporarySignedRoute ( + name: TName, + params: TParams | undefined, + expiration: number, + app?: IApplication + ): IUrl { + void name + void params + void app + void expiration + return {} as IUrl + }; + /** + * Create a URL from a controller action + */ + static action any> ( + controller: string | [C, methodName: ExtractClassMethods>], + params?: Record, + app?: IApplication + ): IUrl { + void controller + void params + void app + return {} as IUrl + }; + /** + * Set the scheme (protocol) of the URL + */ + abstract withScheme (scheme: string): IUrl; + /** + * Set the host of the URL + */ + abstract withHost (host: string): IUrl; + /** + * Set the port of the URL + */ + abstract withPort (port: number): IUrl; + /** + * Set the path of the URL + */ + abstract withPath (path: string): IUrl; + /** + * Set the query parameters of the URL + */ + abstract withQuery (query: Record): IUrl; + /** + * Merge additional query parameters + */ + abstract withQueryParams (params: Record): IUrl; + /** + * Set the fragment (hash) of the URL + */ + abstract withFragment (fragment: string): IUrl; + /** + * Add a signature to the URL for security + */ + abstract withSignature (app?: IApplication, expiration?: number): IUrl; + /** + * Verify if a URL signature is valid + */ + abstract hasValidSignature (app?: IApplication): boolean; + /** + * Convert the URL to its string representation + */ + abstract toString (): string; + /** + * Get the scheme + */ + abstract getScheme (): string | undefined; + /** + * Get the host + */ + abstract getHost (): string | undefined; + /** + * Get the port + */ + abstract getPort (): number | undefined; + /** + * Get the path + */ + abstract getPath (): string; + /** + * Get the query parameters + */ + abstract getQuery (): Record; + /** + * Get the fragment + */ + abstract getFragment (): string | undefined; +} \ No newline at end of file diff --git a/packages/contracts/src/Url/IUrlHelpers.ts b/packages/contracts/src/Url/IUrlHelpers.ts new file mode 100644 index 00000000..cb9a8935 --- /dev/null +++ b/packages/contracts/src/Url/IUrlHelpers.ts @@ -0,0 +1,52 @@ +import { ExtractClassMethods } from '../Utilities/Utilities' +import { IUrl } from './IUrl' + +/** + * The Url Helper Contract + */ +export abstract class IUrlHelpers { + /** + * Create a URL from a path relative to the app URL + */ + abstract to: (path: string) => IUrl + + /** + * Create a URL from a named route + */ + abstract route: (name: string, params?: Record) => string + + /** + * Create a signed URL from a named route + * + * @param name + * @param params + * @returns + */ + abstract signedRoute: (name: string, params?: Record) => IUrl + + /** + * Create a temporary signed URL from a named route + * + * @param name + * @param params + * @param expiration + * @returns + */ + abstract temporarySignedRoute: (name: string, params: Record | undefined, expiration: number) => IUrl + + /** + * Create a URL from a controller action + */ + abstract action: any>( + controller: string | [C, methodName: ExtractClassMethods>], + params?: Record + ) => string + + /** + * Get request-aware URL helpers + */ + abstract url: { + (): IUrlHelpers + (path: string): string + } +} \ No newline at end of file diff --git a/packages/contracts/src/Url/Utils.ts b/packages/contracts/src/Url/Utils.ts new file mode 100644 index 00000000..c380ee36 --- /dev/null +++ b/packages/contracts/src/Url/Utils.ts @@ -0,0 +1 @@ +export type RouteParams = Record \ No newline at end of file diff --git a/packages/shared/src/Contracts/BindingsContract.ts b/packages/contracts/src/Utilities/BindingsContract.ts similarity index 63% rename from packages/shared/src/Contracts/BindingsContract.ts rename to packages/contracts/src/Utilities/BindingsContract.ts index 27c897be..fc9621af 100644 --- a/packages/shared/src/Contracts/BindingsContract.ts +++ b/packages/contracts/src/Utilities/BindingsContract.ts @@ -1,10 +1,11 @@ -import type { H3, HTTPResponse, serve } from 'h3' -import type { HttpContext, IRouter } from './IHttp' +import type { H3, serve } from 'h3' +import { IResponsable, IResponse } from '../Http/IResponse' import type { Edge } from 'edge.js' -import type { IRequest } from './IRequest' -import type { IResponse } from './IResponse' -import type { PathLoader } from '../Utils/PathLoader' +import { IHttpContext } from '../Http/IHttpContext' +import { IRequest } from '../Http/IRequest' +import { IRouter } from '../Routing/IRouter' +import { PathLoader } from './PathLoader' type RemoveIndexSignature = { [K in keyof T as string extends K @@ -17,27 +18,27 @@ type RemoveIndexSignature = { export type Bindings = { [key: string]: any; [key: `app.${string}`]: any; + [key: `middleware.${string}`]: any; env (): NodeJS.ProcessEnv env (key: T, def?: any): any - view (viewPath: string, params?: Record): Promise + view (viewPath: string, params?: Record): Promise edge: Edge; asset (key: string, def?: string): string router: IRouter config: { - // get> (): X - // get, K extends DotNestedKeys> (key: K, def?: any): DotNestedValue get> (): X get, T extends Extract> (key: T, def?: any): X[T] set (key: T, value: any): void load?(): any } + 'db': any 'http.app': H3 'path.base': string 'load.paths': PathLoader 'http.serve': typeof serve - 'http.context': HttpContext + 'http.context': IHttpContext 'http.request': IRequest 'http.response': IResponse } -export type UseKey = keyof RemoveIndexSignature +export type UseKey = Record> = keyof RemoveIndexSignature diff --git a/packages/contracts/src/Utilities/ObjContract.ts b/packages/contracts/src/Utilities/ObjContract.ts new file mode 100644 index 00000000..b1bbc91e --- /dev/null +++ b/packages/contracts/src/Utilities/ObjContract.ts @@ -0,0 +1,51 @@ +/** + * Adds a dot prefix to nested keys + */ +type DotPrefix = + T extends '' ? U : `${T}.${U}` + +/** + * Converts a union of objects into a single merged object + */ +type MergeUnion = + (T extends any ? (k: T) => void : never) extends + (k: infer I) => void ? { [K in keyof I]: I[K] } : never + +/** + * Flattens nested objects into dotted keys + */ +export type DotFlatten = MergeUnion<{ + [K in keyof T & string]: + T[K] extends Record + ? DotFlatten> + : { [P in DotPrefix]: T[K] } +}[keyof T & string]> + +/** + * Builds "nested.key" paths for autocompletion + */ +export type DotNestedKeys = { + [K in keyof T & string]: + T[K] extends object + ? `${K}` | `${K}.${DotNestedKeys}` + : `${K}` +}[keyof T & string] + +/** + * Retrieves type at a given dot-path + */ +export type DotNestedValue = + Path extends `${infer Key}.${infer Rest}` + ? Key extends keyof T + ? DotNestedValue + : never + : Path extends keyof T + ? T[Path] + : never + +/** + * A generic object type that supports nullable string values + */ +export interface GenericWithNullableStringValues { + [name: string]: string | undefined; +} diff --git a/packages/contracts/src/Utilities/PathLoader.ts b/packages/contracts/src/Utilities/PathLoader.ts new file mode 100644 index 00000000..e6915521 --- /dev/null +++ b/packages/contracts/src/Utilities/PathLoader.ts @@ -0,0 +1,22 @@ +import { IPathName } from './Utilities' + +export declare class PathLoader { + /** + * Dynamically retrieves a path property from the class. + * Any property ending with "Path" is accessible automatically. + * + * @param name - The base name of the path property + * @param prefix - The base path to prefix to the path + * @returns + */ + getPath (name: IPathName, prefix?: string): string + + /** + * Programatically set the paths. + * + * @param name - The base name of the path property + * @param path - The new path + * @param base - The base path to include to the path + */ + setPath (name: IPathName, path: string, base?: string): void +} diff --git a/packages/contracts/src/Utilities/Utilities.ts b/packages/contracts/src/Utilities/Utilities.ts new file mode 100644 index 00000000..d189fdfd --- /dev/null +++ b/packages/contracts/src/Utilities/Utilities.ts @@ -0,0 +1,84 @@ +import type { IController } from '../Core/IController' +import type { IServiceProvider } from '../Core/IServiceProvider' +import { MiddlewareList } from '../Foundation/MiddlewareContract' +import type { IHttpContext } from '../Http/IHttpContext' + + +export type IPathName = 'views' | 'routes' | 'assets' | 'base' | 'public' | 'storage' | 'config' | 'database' +export type RouterEnd = 'get' | 'delete' | 'put' | 'post' | 'patch' | 'apiResource' | 'group' | 'route'; +export type RouteMethod = 'GET' | 'HEAD' | 'PUT' | 'PATCH' | 'POST' | 'DELETE' | 'OPTIONS'; +export type RequestMethod = 'HEAD' | 'GET' | 'PUT' | 'DELETE' | 'TRACE' | 'OPTIONS' | 'PURGE' | 'POST' | 'CONNECT' | 'PATCH'; +export type ControllerMethod = 'index' | 'show' | 'update' | 'destroy'; +export type RequestObject = Record; +export type ResponseObject = Record; + +export type ExtractClassMethods = { + [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never +}[keyof T]; + +/** + * Type for EventHandler, representing a function that handles an H3 event. + */ +export type EventHandler = (ctx: IHttpContext) => any + +export type ClassConstructor = abstract new (...args: any[]) => T +export type MergedConstructor = (new (...args: any[]) => T) & Record +export type AbstractConstructor = (abstract new (...args: any[]) => T) & Record +export type CallableConstructor = (...args: Y[]) => X +export type AppEvent = CallableConstructor +export type AppListener = CallableConstructor +export type RouteEventHandler = CallableConstructor +export type ConcreteConstructor = new (...args: any[]) => Required + +export interface RouteActions { + [key: string]: any + can?: [string, string][] + where?: Record + domain?: string + path?: string + prefix?: string + as?: string + name?: string + controller?: RouteEventHandler | IController | string + missing?: CallableConstructor + uses?: any + https?: boolean + middleware?: MiddlewareList + namespace?: string + excluded_middleware?: any +} + +export interface ClassicRouteDefinition { + method: Lowercase; + path: string; + name?: string | undefined; + handler: EventHandler; + signature: [string, string | undefined] +} + +export interface RouteAttributes { + action: RouteActions +} + +export type ActionInput = + | null + | undefined + | RouteEventHandler + | IController + | [C, methodName: ExtractClassMethods>] + | RouteActions + + +export interface NormalizedAction { + uses: RouteEventHandler | IController | string + controller?: RouteEventHandler | IController + methodName?: string +} + +export type ServiceProviderConstructor = (new (app: any) => IServiceProvider) & IServiceProvider; + +export type AServiceProvider = (new (app: any) => IServiceProvider) & Partial +export type OServiceProvider = (new (app: any) => Partial) & Partial +export type ListenerClassConstructor = (new (...args: any) => any) & { + subscribe?(...args: any[]): any +}; \ No newline at end of file diff --git a/packages/contracts/src/Validation/IMessageBag.ts b/packages/contracts/src/Validation/IMessageBag.ts new file mode 100644 index 00000000..52b59967 --- /dev/null +++ b/packages/contracts/src/Validation/IMessageBag.ts @@ -0,0 +1,127 @@ +export declare class ValidationMessageProvider { + getMessageBag (): IMessageBag; +} + +export declare class IMessageBag implements ValidationMessageProvider { + /** + * Create a new message bag instance. + */ + constructor(messages: Record) + + getMessageBag (): IMessageBag; + + /** + * Get all message keys. + */ + keys (): string[] + + /** + * Add a message. + */ + add (key: string, message: string): this + + /** + * Add a message conditionally. + */ + addIf (condition: boolean, key: string, message: string): this + + /** + * Merge another message source into this one. + */ + merge (messages: Record | ValidationMessageProvider): this + + /** + * Determine if messages exist for all given keys. + */ + has (key?: string | string[] | null): boolean + + /** + * Determine if messages exist for any given key. + */ + hasAny (keys: string | string[]): boolean + + /** + * Determine if messages don't exist for given keys. + */ + missing (key: string | string[]): boolean + + /** + * Get the first message for a given key. + */ + first (key?: string | null, format?: string | null): string + + /** + * Get all messages for a given key. + */ + get (key: string, format?: string | null): string[] | Record + + /** + * Get all messages. + */ + all (format?: string): string[] + + /** + * Get unique messages. + */ + unique (format?: string | null): string[] + + /** + * Remove messages for a key. + */ + forget (key: string): this + + /** + * Get raw messages. + */ + messagesRaw (): Record + + /** + * Alias for messagesRaw(). + */ + getMessages (): Record + + /** + * Return message bag instance. + */ + getMessageBag (): IMessageBag + + /** + * Get format string. + */ + getFormat (): string + + /** + * Set default message format. + */ + setFormat (format: string): this + + /** + * Empty checks. + */ + isEmpty (): boolean + + isNotEmpty (): boolean + + any (): boolean + + /** + * Count total messages. + */ + count (): number + + /** + * Array & JSON conversions. + */ + toArray (): Record + + jsonSerialize (): any + + toJson (options: number): string + + toPrettyJson (): string + + /** + * String representation. + */ + toString (): string +} \ No newline at end of file diff --git a/packages/contracts/src/Validation/IValidationRule.ts b/packages/contracts/src/Validation/IValidationRule.ts new file mode 100644 index 00000000..83c1238d --- /dev/null +++ b/packages/contracts/src/Validation/IValidationRule.ts @@ -0,0 +1,19 @@ +import type { ValidationRuleCallable } from './RuleBuilder' + +export declare abstract class IValidationRule { + rules: ValidationRuleCallable[] + /** + * Run the validation rule. + */ + abstract validate (attribute: string, value: any, fail: (msg: string) => any): void + /** + * Set the current validator. + */ + // public setValidator?(validator: IValidator): this + /** + * Set the data under validation. + */ + public setData (_data: Record): this + + passes (value: any, attribute: string): boolean | Promise +} \ No newline at end of file diff --git a/packages/contracts/src/Validation/IValidator.ts b/packages/contracts/src/Validation/IValidator.ts new file mode 100644 index 00000000..c2d9353d --- /dev/null +++ b/packages/contracts/src/Validation/IValidator.ts @@ -0,0 +1,125 @@ +import type { DotPaths, MessagesForRules, RulesForData } from './ValidatorContracts' + +import type { BaseValidationRuleClass } from './RuleBuilder' +import type { IMessageBag } from './IMessageBag' +import type { ValidationRuleSet } from './ValidationRuleName' + +export declare class IValidator< + D extends Record = any, + R extends RulesForData = RulesForData +> { + constructor( + data: D, + rules: R, + messages: Partial, string>> + ) + + /** + * Validate the data and return the instance + */ + static make< + D extends Record, + R extends RulesForData + > ( + data: D, + rules: R, + messages: Partial, string>> + ): IValidator + + /** + * Run the validator and store results. + */ + public passes (): Promise + + /** + * Opposite of passes() + */ + public fails (): Promise + + /** + * Throw if validation fails, else return executed data + * + * @throws ValidationException if validation fails + */ + public validate (): Promise> + + /** + * Run the validator's rules against its data. + * @param bagName + * @returns + */ + validateWithBag (bagName: string): Promise> + + /** + * Stop validation on first failure. + */ + stopOnFirstFailure (): this + + /** + * Get the data that passed validation. + */ + public validatedData (): Record + + /** + * Return all validated input. + */ + validated (): Partial + + /** + * Return a portion of validated input + */ + safe (): { + only: (keys: string[]) => Partial; + except: (keys: string[]) => Partial; + } + + /** + * Get the message container for the validator. + */ + // public messages (): Promise + public messages (): Promise, string>>> + + /** + * Add an after validation callback. + * + * @param callback + */ + public after) => void) | BaseValidationRuleClass> (callback: C | C[]): this + + /** + * Get all errors. + */ + public errors (): IMessageBag + + public errorBag (): string + + /** + * Reset validator with new data. + */ + public setData (data: D): this + + /** + * Set validation rules. + */ + public setRules (rules: R): this + + /** + * Add a single rule to existing rules. + */ + public addRule (key: DotPaths, rule: ValidationRuleSet): this + + /** + * Merge additional rules. + */ + public mergeRules (rules: Record): this + + /** + * Get current data. + */ + public getData (): Record + + /** + * Get current rules. + */ + public getRules (): R +} \ No newline at end of file diff --git a/packages/contracts/src/Validation/RuleBuilder.ts b/packages/contracts/src/Validation/RuleBuilder.ts new file mode 100644 index 00000000..5d333905 --- /dev/null +++ b/packages/contracts/src/Validation/RuleBuilder.ts @@ -0,0 +1,11 @@ +import type { IValidationRule } from './IValidationRule' + +export interface ValidationRuleCallable { + name: string; + validator: (value: any, parameters?: string[], attribute?: string) => boolean | Promise; + message?: string +} + +export type CustomValidationRules = IValidationRule | ValidationRuleCallable + +export declare class BaseValidationRuleClass { } \ No newline at end of file diff --git a/packages/validation/src/Contracts/ValidationRuleName.ts b/packages/contracts/src/Validation/ValidationRuleName.ts similarity index 83% rename from packages/validation/src/Contracts/ValidationRuleName.ts rename to packages/contracts/src/Validation/ValidationRuleName.ts index d9649847..d46813dd 100644 --- a/packages/validation/src/Contracts/ValidationRuleName.ts +++ b/packages/contracts/src/Validation/ValidationRuleName.ts @@ -2,9 +2,9 @@ import type In from 'simple-body-validator/lib/cjs/rules/in' import type NotIn from 'simple-body-validator/lib/cjs/rules/notIn' import type Regex from 'simple-body-validator/lib/cjs/rules/regex' import type RequiredIf from 'simple-body-validator/lib/cjs/rules/requiredIf' -import { Rule } from 'simple-body-validator' +import type { Rule } from 'simple-body-validator' -export type ParamableRuleName = +export type ParamableValidationRuleName = | 'accepted_if' | 'after' | 'after_or_equal' @@ -68,16 +68,16 @@ export type PlainRuleName = | 'hex' | 'uuid' -export type ValidationRuleName = ParamableRuleName | PlainRuleName +export type ValidationRuleName = ParamableValidationRuleName | PlainRuleName type MethodRules = Regex | In | NotIn | RequiredIf /** * Single rule value (supports autocomplete + arbitrary strings + Rule instances) */ -type RuleName = ValidationRuleName | `${ParamableRuleName}:${string}` | Rule | MethodRules +type RuleName = ValidationRuleName | `${ParamableValidationRuleName}:${string}` | Rule | MethodRules -export type RuleSet = +export type ValidationRuleSet = | RuleName | RuleName[] | `${ValidationRuleName}${string & `|${string}`}` \ No newline at end of file diff --git a/packages/validation/src/Contracts/ValidatorContracts.ts b/packages/contracts/src/Validation/ValidatorContracts.ts similarity index 92% rename from packages/validation/src/Contracts/ValidatorContracts.ts rename to packages/contracts/src/Validation/ValidatorContracts.ts index 46592a8e..cffe4dc3 100644 --- a/packages/validation/src/Contracts/ValidatorContracts.ts +++ b/packages/contracts/src/Validation/ValidatorContracts.ts @@ -1,4 +1,4 @@ -import { RuleSet, ValidationRuleName } from './ValidationRuleName' +import { ValidationRuleName, ValidationRuleSet } from './ValidationRuleName' /** * Parse rule names from rule string or string[] definitions @@ -52,5 +52,5 @@ export type MessagesForRules> = { * Make rules align with keys in the data object */ export type RulesForData> = Partial< - Record, RuleSet> + Record, ValidationRuleSet> > \ No newline at end of file diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 00000000..288e2d5d --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,50 @@ +export * from './Core/IApplication' +export * from './Core/IContainer' +export * from './Core/IController' +export * from './Core/IRegisterer' +export * from './Core/IServiceProvider' +export * from './Events/IDispatcher' +export * from './Exceptions/IExceptionHandler' +export * from './Foundation/IKernel' +export * from './Foundation/MiddlewareContract' +export * from './Foundation/RateLimiterAdapter' +export * from './Http/IFileBag' +export * from './Http/IHeaderBag' +export * from './Http/IHttpContext' +export * from './Http/IHttpRequest' +export * from './Http/IHttpResponse' +export * from './Http/IInputBag' +export * from './Http/IParamBag' +export * from './Http/IRequest' +export * from './Http/IResponse' +export * from './Http/IServerBag' +export * from './Http/IUploadedFile' +export * from './Http/Utils' +export * from './Queue/IJob' +export * from './Queue/Utils' +export * from './Routing/IAbstractRouteCollection' +export * from './Routing/ICallableDispatcher' +export * from './Routing/ICompiledRoute' +export * from './Routing/IControllerDispatcher' +export * from './Routing/IMiddleware' +export * from './Routing/IMiddlewareHandler' +export * from './Routing/IRoute' +export * from './Routing/IRouteCollection' +export * from './Routing/IRouter' +export * from './Session/FlashBag' +export * from './Session/ISessionManager' +export * from './Session/SessionContract' +export * from './Url/IRequestAwareUrl' +export * from './Url/IUrl' +export * from './Url/IUrlHelpers' +export * from './Url/Utils' +export * from './Utilities/BindingsContract' +export * from './Utilities/ObjContract' +export * from './Utilities/PathLoader' +export * from './Utilities/Utilities' +export * from './Validation/IMessageBag' +export * from './Validation/IValidationRule' +export * from './Validation/IValidator' +export * from './Validation/RuleBuilder' +export * from './Validation/ValidationRuleName' +export * from './Validation/ValidatorContracts' diff --git a/packages/config/src/Contracts/.gitkeep b/packages/contracts/tests/.gitignore similarity index 100% rename from packages/config/src/Contracts/.gitkeep rename to packages/contracts/tests/.gitignore diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 00000000..c2f2ec7d --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/core/package.json b/packages/core/package.json index 4087a078..7f6d7045 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -76,6 +76,7 @@ "tslib": "catalog:" }, "devDependencies": { + "@h3ravel/contracts": "workspace:^", "@types/semver": "catalog:", "typescript": "^5.9.2" } diff --git a/packages/core/src/Application.ts b/packages/core/src/Application.ts index 36ddd1d7..d00a8d9b 100644 --- a/packages/core/src/Application.ts +++ b/packages/core/src/Application.ts @@ -1,47 +1,58 @@ import 'reflect-metadata' -import { FileSystem, type HttpContext, type IApplication, type IPathName, Logger, PathLoader } from '@h3ravel/shared' -import type { H3, H3Event } from 'h3' +import { FileSystem, Logger, PathLoader } from '@h3ravel/shared' +import { type H3, type H3Event } from 'h3' + +import { ConcreteConstructor, IKernel, IUrl, type IApplication, type IHttpContext, type IPathName, type IServiceProvider } from '@h3ravel/contracts' import { InvalidArgumentException, Str } from '@h3ravel/support' -import { AServiceProvider } from './Contracts/ServiceProviderConstructor' +import { AppBuilder, ConfigException } from '@h3ravel/foundation' import { Container } from './Container' import { ContainerResolver } from './Manager/ContainerResolver' import { ProviderRegistry } from './ProviderRegistry' import { Registerer } from './Registerer' -import { type ServiceProvider } from './ServiceProvider' import { detect } from 'detect-port' import dotenv from 'dotenv' import dotenvExpand from 'dotenv-expand' import path from 'node:path' import { readFile } from 'node:fs/promises' import semver from 'semver' -import { Foundation } from './Manager/Foundation' -import { ConfigException } from './Exceptions/ConfigException' export class Application extends Container implements IApplication { + /** + * Indicates if the application has "booted". + */ + #booted = false public paths = new PathLoader() - public context?: (event: H3Event) => Promise + public context?: (event: H3Event) => Promise public h3Event?: H3Event private tries: number = 0 - private booted = false private basePath: string private versions: { [key: string]: string, app: string, ts: string } = { app: '0.0.0', ts: '0.0.0' } private static versions: { [key: string]: string, app: string, ts: string } = { app: '0.0.0', ts: '0.0.0' } private h3App?: H3 - private providers: Array = [] - protected externalProviders: Array = [] + private providers: Array = [] + protected externalProviders: Array> = [] protected filteredProviders: Array = [] + /** + * The route resolver callback. + */ + protected uriResolver?: () => typeof IUrl + /** * List of registered console commands */ public registeredCommands: (new (app: any, kernel: any) => any)[] = [] + /** + * The array of booted callbacks. + */ + protected bootedCallbacks: Array<(...args: any[]) => void> = [] + constructor(basePath: string) { super() - dotenvExpand.expand(dotenv.config({ quiet: true })) this.basePath = basePath @@ -91,7 +102,7 @@ export class Application extends Container implements IApplication { /** * Get all registered providers */ - public getRegisteredProviders () { + public getRegisteredProviders (): IServiceProvider[] { return this.providers } @@ -103,13 +114,13 @@ export class Application extends Container implements IApplication { * Minimal App: Loads only core, config, http, router by default. * Full-Stack App: Installs database, mail, queue, cache → they self-register via their providers. */ - protected async getConfiguredProviders (): Promise> { + protected async getConfiguredProviders (): Promise[]> { return [ - (await import('@h3ravel/core')).CoreServiceProvider, + (await import('@h3ravel/core')).CoreServiceProvider as never, ] } - protected async getAllProviders (): Promise> { + protected async getAllProviders (): Promise>> { const coreProviders = await this.getConfiguredProviders() return [...coreProviders, ...this.externalProviders] } @@ -123,7 +134,7 @@ export class Application extends Container implements IApplication { * * @returns */ - public async quickStartup (providers: Array, filtered: string[] = [], autoRegisterProviders = true) { + public async quickStartup (providers: Array>, filtered: string[] = [], autoRegisterProviders = true) { this.registerProviders(providers, filtered) await this.registerConfiguredProviders(autoRegisterProviders) return this.boot() @@ -159,7 +170,7 @@ export class Application extends Container implements IApplication { * @param providers * @param filtered */ - registerProviders (providers: Array, filtered: string[] = []): void { + registerProviders (providers: Array>, filtered: string[] = []): void { this.externalProviders.push(...providers) this.filteredProviders = filtered } @@ -167,7 +178,7 @@ export class Application extends Container implements IApplication { /** * Register a provider */ - public async register (provider: ServiceProvider) { + public async register (provider: IServiceProvider) { await new ContainerResolver(this).resolveMethodParams(provider, 'register', this) if (provider.registeredCommands && provider.registeredCommands.length > 0) { this.registeredCommands.push(...provider.registeredCommands) @@ -196,6 +207,13 @@ export class Application extends Container implements IApplication { } + /** + * checks if the application is running in Unit Test + */ + public runningUnitTests (): boolean { + return process.env.VITEST === 'true' + } + public getRuntimeEnv (): 'browser' | 'node' | 'unknown' { if (typeof window !== 'undefined' && typeof document !== 'undefined') { return 'browser' @@ -206,12 +224,19 @@ export class Application extends Container implements IApplication { return 'unknown' } + /** + * Determine if the application has booted. + */ + public isBooted (): boolean { + return this.#booted + } + /** * Boot all service providers after registration */ public async boot () { - if (this.booted) return this + if (this.#booted) return this /** * If debug is enabled, let's show the loaded service provider info @@ -236,18 +261,122 @@ export class Application extends Container implements IApplication { */ await provider.boot(this) } + + if (provider.callBootedCallbacks) { + await provider.callBootedCallbacks() + } } } - this.booted = true + this.#booted = true + + this.fireAppCallbacks(this.bootedCallbacks) + + return this + } + + /** + * Register a new "booted" listener. + * + * @param callback + */ + public booted (callback: (...args: any[]) => void): void { + this.bootedCallbacks.push(callback) + + if (this.isBooted()) { + callback(this) + } + } + + /** + * Call the booting callbacks for the application. + * + * @param callbacks + */ + protected fireAppCallbacks (callbacks: Array<(...args: any[]) => void>): void { + let index = 0 + + while (index < callbacks.length) { + callbacks[index](this) + + index++ + } + } + + /** + * Handle the incoming HTTP request and send the response to the browser. + * + * @param request + */ + async handleRequest (): Promise { + this.h3App?.all('/**', async (event) => { + const context = await this.context!(event) + + const kernel = this.make(IKernel) + + if (!this.bound('http.context')) + this.bind('http.context', () => context) + + if (!this.bound('http.request')) + this.bind('http.request', () => context.request) + + if (!this.bound('http.response')) + this.bind('http.response', () => context.response) + + const response = await kernel.handle(context.request) + + if (response) + this.bind('http.response', () => response) + + kernel.terminate(context.request, response!) + + if (typeof response?.prepare !== 'undefined') { + const content = response.prepare(context.request).send() + return content + } else { + return response + } + }) + } + + /** + * Get the URI resolver callback. + */ + getUriResolver (): () => typeof IUrl | undefined { + return this.uriResolver ?? (() => undefined) + } + + /** + * Set the URI resolver callback. + * + * @param callback + */ + setUriResolver (callback: () => typeof IUrl) { + this.uriResolver = callback + return this } + /** + * Determine if middleware has been disabled for the application. + */ + public shouldSkipMiddleware () { + return this.bound('middleware.disable') && this.make('middleware.disable') === true + } + /** * Provide safe overides for the app */ public configure () { - return new Foundation(this) + return new AppBuilder(this) + .withKernels() + } + + /** + * Check if the current application environment matches the one provided + */ + public environment (env: E): E extends undefined ? string : boolean { + return (this.make('config').get('app.env') === env) as never } /** @@ -263,14 +392,11 @@ export class Application extends Container implements IApplication { public async fire (h3App: H3, preferredPort?: number): Promise public async fire (h3App?: H3, preferredPort?: number): Promise { - if (h3App) { - return await this.serve(h3App, preferredPort) - } + if (h3App) + this.h3App = h3App - if (!this?.h3App) { + if (!this?.h3App) throw new ConfigException('[Provide a H3 app instance in the config or install @h3ravel/http]') - } - return await this.serve(this.h3App, preferredPort) } diff --git a/packages/core/src/Container.ts b/packages/core/src/Container.ts index bd44c024..dafc7df9 100644 --- a/packages/core/src/Container.ts +++ b/packages/core/src/Container.ts @@ -1,14 +1,43 @@ -import type { Bindings, IContainer, UseKey } from '@h3ravel/shared' +import 'reflect-metadata' +import { ExtractClassMethods, IContainer, type UseKey, ClassConstructor, type Bindings, CallableConstructor, IMiddleware, ConcreteConstructor } from '@h3ravel/contracts' import { Handler, MiddlewareHandler } from '@h3ravel/foundation' +import { ContainerResolver } from './Manager/ContainerResolver' -type IBinding = UseKey | (new (..._args: any[]) => unknown) +type IBinding = UseKey | (new (...args: any[]) => unknown) -export class Container implements IContainer { +export class Container extends IContainer { public bindings = new Map unknown>() public singletons = new Map() public exceptionHandler?: Handler public middlewareHandler?: MiddlewareHandler + /** + * All of the before resolving callbacks by class type. + */ + private beforeResolvingCallbacks = new Map void)[]>() + /** + * All of the after resolving callbacks by class type. + */ private afterResolvingCallbacks = new Map void)[]>() + /** + * All of the registered rebound callbacks. + */ + protected reboundCallbacks: Record any)[]> = {} + /** + * The container's shared instances. + */ + protected instances = new Map any>() + /** + * The registered type alias. + */ + protected aliases = new Map() + /** + * The registered aliases keyed by the abstract name. + */ + protected abstractAliases = new Map() + /** + * The registered aliases keyed by the abstract name. + */ + protected middlewares = new Map() /** * Check if the target has any decorators @@ -47,6 +76,31 @@ export class Container implements IContainer { this.bindings.set(key, factory) } + /** + * Bind unregistered middlewares to the service container so we can use them later + * + * @param key + * @param middleware + */ + bindMiddleware (key: IMiddleware | string, middleware: ConcreteConstructor) { + this.middlewares.set(key, middleware as never) + } + + /** + * Get all bound and unregistered middlewares in the service container + * + * @param key + * @param middleware + */ + boundMiddlewares (): MapIterator<[string | IMiddleware, IMiddleware]> + boundMiddlewares (key: IMiddleware | string): IMiddleware + boundMiddlewares (key?: IMiddleware | string) { + if (key) { + return this.middlewares.get(key) + } + return this.middlewares.entries() + } + /** * Remove one or more transient services from the container * @@ -70,20 +124,78 @@ export class Container implements IContainer { * @param key * @param factory */ + singleton (key: T | (new (...args: any[]) => Bindings[T]), factory: (app: this) => Bindings[T]): void + singleton (key: T | (abstract new (...args: any[]) => Bindings[T]), factory: (app: this) => Bindings[T]): void + singleton (key: T | (new (...args: any[]) => Bindings[T]), factory: abstract new (...args: any[]) => any): void + singleton (key: T | (abstract new (...args: any[]) => Bindings[T]), factory: abstract new (...args: any[]) => any): void singleton ( - key: T | (new (..._args: any[]) => Bindings[T]), - factory: (app: this) => Bindings[T] - ) { + key: T | (new (...args: any[]) => Bindings[T]), + factory: any + ): void { + + const alias = ContainerResolver.isAbstract(key) ? key.name.toLowerCase().substring(1) as T : undefined + this.bindings.set(key, () => { if (!this.singletons.has(key)) { - this.singletons.set(key, factory(this)) + this.singletons.set(key, this.call(factory)) + } + + if (alias && !this.singletons.has(alias)) { + this.singletons.set(alias, this.call(factory)) } - return this.singletons.get(key)! + + return this.singletons.get(alias ?? key) }) } /** - * Resolve a service from the container + * Read reflected param types, resolve dependencies from the container and + * optionally transform them, finally invoke the specified method on a class instance + * + * @param instance + * @param method + * @param defaultArgs + * @param handler + * @returns + */ + async invoke, M extends ExtractClassMethods> ( + instance: X, + method: M, + defaultArgs?: any[], + handler?: CallableConstructor + ): Promise { + /** + * Get param types for the instance method + */ + const paramTypes: any[] = Reflect.getMetadata('design:paramtypes', instance as never, method as string) || [] + + /** + * Resolve the bound dependencies + */ + let args = await Promise.all( + paramTypes.map(async (paramType: any) => { + const inst = this.make(paramType) + if (handler) { + return await handler(inst) + } + + return inst + }) + ) + + /** + * Ensure that the args is always filled + */ + if (args.length < 1) { + args = defaultArgs ?? [] + } + + const fn = instance[method] + return Reflect.apply(fn as never, instance, args) + } + + /** + * Resolve the gevein service from the container * * @param key */ @@ -91,25 +203,44 @@ export class Container implements IContainer { make any> (key: C): InstanceType make any> (key: F): ReturnType make (key: any): any { + return this.resolve(key) + } + + /** + * Resolve the gevein service from the container + * + * @param abstract + * @param raiseEvents + */ + resolve (abstract: any, raiseEvents = true): any { + abstract = this.getAlias(abstract) + /** * Direct factory binding */ let resolved: any - if (this.bindings.has(key)) { - resolved = this.bindings.get(key)!() - } else if (typeof key === 'function') { + if (raiseEvents) + this.runBeforeResolvingCallbacks(abstract) + + if (this.bindings.has(abstract)) { + resolved = this.bindings.get(abstract)!() + } else if (this.instances.has(abstract)) { + resolved = this.instances.get(abstract) + } else if (typeof abstract === 'function') { /** * If this is a class constructor, auto-resolve via reflection */ - resolved = this.build(key) + resolved = this.build(abstract) } else { throw new Error( - `No binding found for key: ${typeof key === 'string' ? key : (key as any)?.name}` + `No binding found for key: ${typeof abstract === 'string' ? abstract : (abstract as any)?.name}` ) } - this.runAfterResolvingCallbacks(key, resolved) + if (raiseEvents) + this.runAfterResolvingCallbacks(abstract, resolved) + return resolved } @@ -119,16 +250,42 @@ export class Container implements IContainer { * @param key * @param callback */ - afterResolving ( - key: T | (new (..._args: any[]) => Bindings[T]), - callback: (resolved: Bindings[T], app: this) => void - ) { + afterResolving (key: T, callback: (resolved: Bindings[T], app: this) => void): void + afterResolving any> (key: T, callback: (resolved: InstanceType, app: this) => void): void + afterResolving (key: any, callback: (resolved: any, app: this) => void) { const existing = this.afterResolvingCallbacks.get(key) || [] - existing.push(callback) this.afterResolvingCallbacks.set(key, existing) } + /** + * Register a new before resolving callback for all types. + * + * @param key + * @param callback + */ + beforeResolving (key: T, callback: (app: this) => void): void + beforeResolving any> (key: T, callback: (app: this) => void): void + beforeResolving (key: any, callback: (app: this) => void) { + const existing = this.beforeResolvingCallbacks.get(key) || [] + existing.push(callback) + this.beforeResolvingCallbacks.set(key, existing) + } + + /** + * Execute all registered beforeResolving callbacks for a given key + * + * @param key + * @param resolved + */ + private runBeforeResolvingCallbacks (key: T) { + const callbacks = this.beforeResolvingCallbacks.get(key) || [] + + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](this) + } + } + /** * Execute all registered afterResolving callbacks for a given key * @@ -152,7 +309,7 @@ export class Container implements IContainer { * @param ClassType * @returns */ - private build (ClassType: new (..._args: any[]) => Bindings[T]): Bindings[T] { + private build (ClassType: new (...args: any[]) => Bindings[T]): Bindings[T] { let dependencies: any[] = [] if (Array.isArray((ClassType as any).__inject__)) { @@ -167,6 +324,59 @@ export class Container implements IContainer { return new ClassType(...dependencies) } + /** + * Determine if a given string is an alias. + * + * @param name + */ + isAlias (name: string) { + return this.aliases.has(name) && typeof this.aliases.get(name) !== 'undefined' + } + + /** + * Get the alias for an abstract if available. + * + * @param abstract + */ + getAlias (abstract: any): any { + if (typeof abstract === 'string' && this.aliases.has(abstract)) { + return this.getAlias(this.aliases.get(abstract)) + } + + return this.aliases.get(abstract) ?? abstract + } + + /** + * Set the alias for an abstract. + * + * @param token + * @param target + */ + alias (key: [string | ClassConstructor, any][]): this + alias (key: string | ClassConstructor, target: any): this + alias (key: string | ClassConstructor | [string | ClassConstructor, any][], target?: any) { + if (Array.isArray(key)) + for (const [tokn, targ] of key) + this.aliases.set(tokn, targ) + else + this.aliases.set(key, target) + + return this + } + + /** + * Determine if the given abstract type has been bound. + * + * @param string $abstract + * @returns + */ + bound (abstract: T): boolean + bound any> (abstract: C): boolean + bound any> (abstract: F): boolean + bound (abstract: any): boolean { + return this.bindings.has(abstract) || !!this.instances.get(abstract) || this.isAlias(abstract) + } + /** * Check if a service is registered * @@ -177,6 +387,96 @@ export class Container implements IContainer { has any> (key: C): boolean has any> (key: F): boolean has (key: any): boolean { - return this.bindings.has(key) + return this.bound(key) + } + + /** + * Register an existing instance as shared in the container. + * + * @param abstract + * @param instance + */ + instance (key: string, instance: X): X + instance any, X = any> (abstract: K, instance: X): X + instance (abstract: any, instance: any) { + this.removeAbstractAlias(abstract) + + const isBound = this.bound(abstract) + + this.aliases.delete(abstract) + + // We'll check to determine if this type has been bound before, and if it has + // we will fire the rebound callbacks registered with the container and it + // can be updated with consuming classes that have gotten resolved here. + this.instances.set(abstract, instance) + + if (isBound) { + this.rebound(abstract) + } + + return instance + } + + /** + * Call the given method and inject its dependencies. + * + * @param callback + */ + call any> (callback: C): any | Promise + call any> (callback: F): any | Promise + call (callback: (...args: any[]) => any): any | Promise { + if (ContainerResolver.isClass(callback)) { + return this.make(callback) + } + return callback() + } + + /** + * Fire the "rebound" callbacks for the given abstract type. + * + * @param abstract + */ + protected rebound (abstract: any) { + const callbacks = this.getReboundCallbacks(abstract) + if (!callbacks) { + return + } + + const instance = this.make(abstract as never) + + for (const callback of callbacks) { + callback(this, instance) + } + } + + + /** + * Get the rebound callbacks for a given type. + * + * @param abstract + */ + protected getReboundCallbacks (abstract: any) { + return this.reboundCallbacks[abstract] ?? [] + } + + /** + * Remove an alias from the contextual binding alias cache. + * + * @param searched + */ + protected removeAbstractAlias (searched: string) { + if (!this.aliases.has(searched)) { + return + } + + for (const [abstract, aliases] of this.abstractAliases.entries()) { + const filtered = aliases.filter(alias => alias !== searched) + + if (filtered.length > 0) { + this.abstractAliases.set(abstract, filtered) + } else { + this.abstractAliases.delete(abstract) + } + } } } diff --git a/packages/core/src/Contracts/H3ravelContract.ts b/packages/core/src/Contracts/H3ravelContract.ts index c411cffe..8e9c316e 100644 --- a/packages/core/src/Contracts/H3ravelContract.ts +++ b/packages/core/src/Contracts/H3ravelContract.ts @@ -1,4 +1,4 @@ -import { H3 } from 'h3' +import { H3, H3Event } from 'h3' export interface EntryConfig { /** @@ -6,6 +6,10 @@ export interface EntryConfig { * is not installed. */ h3?: H3 + /** + * @param H3Event You can provide your own `H3Event` app instance, this is usefull for testing scenarios. + */ + h3Event?: H3Event /** * Determines if we should initialize the app on call. * diff --git a/packages/core/src/Contracts/ServiceProviderConstructor.ts b/packages/core/src/Contracts/ServiceProviderConstructor.ts index 0b9fac11..59c1b6c7 100644 --- a/packages/core/src/Contracts/ServiceProviderConstructor.ts +++ b/packages/core/src/Contracts/ServiceProviderConstructor.ts @@ -2,7 +2,7 @@ import type { Application, ServiceProvider } from '..' -import { IServiceProvider } from '@h3ravel/shared' +import { IServiceProvider } from '@h3ravel/contracts' export type ServiceProviderConstructor = (new (app: Application) => ServiceProvider) & IServiceProvider; diff --git a/packages/core/src/Controller.ts b/packages/core/src/Controller.ts index e261d217..b1167a41 100644 --- a/packages/core/src/Controller.ts +++ b/packages/core/src/Controller.ts @@ -1,19 +1,14 @@ import { Application } from '.' -import { IController } from '@h3ravel/shared' +import { IController } from '@h3ravel/contracts' /** * Base controller class */ -export abstract class Controller implements IController { +export abstract class Controller extends IController { protected app: Application constructor(app: Application) { + super() this.app = app } - - public show?(..._ctx: any[]): any - public index?(..._ctx: any[]): any - public store?(..._ctx: any[]): any - public update?(..._ctx: any[]): any - public destroy?(..._ctx: any[]): any } diff --git a/packages/core/src/H3ravel.ts b/packages/core/src/H3ravel.ts index 0c85e292..330c08e6 100644 --- a/packages/core/src/H3ravel.ts +++ b/packages/core/src/H3ravel.ts @@ -1,8 +1,8 @@ import { Application, Kernel, OServiceProvider } from '.' +import { IApplication, IHttpContext } from '@h3ravel/contracts' import { EntryConfig } from './Contracts/H3ravelContract' import { H3 } from 'h3' -import { HttpContext } from '@h3ravel/shared' /** * Simple global entry point for H3ravel applications @@ -27,7 +27,7 @@ export const h3ravel = async ( /** * final middleware function to call once the server is fired up */ - middleware: (ctx: HttpContext) => Promise = async () => undefined, + middleware: (ctx: IHttpContext) => Promise = async () => undefined, ): Promise => { const { FlashDataMiddleware, HttpContext, LogRequests, Request, Response } = await import('@h3ravel/http') @@ -52,9 +52,12 @@ export const h3ravel = async ( try { // Get the http app container binding h3App = app.make('http.app') + app.setH3App(h3App) // Define app context factory app.context = async (event) => { + event = config.h3Event ?? event + // If we’ve already attached the context to this event, reuse it if ((event as any)._h3ravelContext) return (event as any)._h3ravelContext @@ -63,23 +66,29 @@ export const h3ravel = async ( const ctx = HttpContext.init({ app, request: await Request.create(event, app), - response: new Response(event, app), + response: new Response(app, event), }, event); (event as any)._h3ravelContext = ctx return ctx } + app.singleton(IApplication, () => app) app.singleton('app.globalMiddleware', () => [ - new LogRequests(), - new FlashDataMiddleware(), - ]) + LogRequests, + FlashDataMiddleware + ].map(e => app.make(e))) // Initialize the Application Kernel - const kernel = new Kernel(app) - - // Register kernel with H3 - h3App.use((event) => kernel.handle(event, middleware)) + // const kernel = new Kernel(app) + // // Register kernel with H3 + // h3App.use(async (event) => { + // const resp = await kernel.handle(event, middleware) + // console.log(resp) + // return resp + // }) + await app.handleRequest() + void middleware } catch { if (!h3App && config.h3) { h3App = config.h3 diff --git a/packages/core/src/Http/Kernel.ts b/packages/core/src/Http/Kernel.ts index 8fa96be5..1e1f585b 100644 --- a/packages/core/src/Http/Kernel.ts +++ b/packages/core/src/Http/Kernel.ts @@ -1,19 +1,27 @@ import { Arr, Obj } from '@h3ravel/support' -import { Resolver, type HttpContext, type IMiddleware } from '@h3ravel/shared' +import type { IHttpContext, IMiddleware, IRouter } from '@h3ravel/contracts' + import { Application } from '..' import type { H3Event } from 'h3' import { MiddlewareHandler } from '@h3ravel/foundation' +import { Resolver } from '@h3ravel/shared' /** * Kernel class handles middleware execution and response transformations. * It acts as the core middleware pipeline for HTTP requests. */ export class Kernel { + + /** + * The router instance. + */ + protected router: IRouter + /** * A factory function that converts an H3Event into an HttpContext. */ - protected context: (event: H3Event) => HttpContext | Promise - protected applicationContext!: HttpContext + protected context: (event: H3Event) => IHttpContext | Promise + protected applicationContext!: IHttpContext /** * @param app - The current application instance @@ -23,6 +31,7 @@ export class Kernel { public app: Application, public middleware: IMiddleware[] = [], ) { + this.router = app.make('router') this.context = async (event) => app.context!(event) } @@ -35,8 +44,9 @@ export class Kernel { */ async handle ( event: H3Event, - next: (ctx: HttpContext) => Promise + next: (ctx: IHttpContext) => Promise ): Promise { + const { request } = await this.app.context!(event) /** * Convert the raw event into a standardized HttpContext */ @@ -52,7 +62,9 @@ export class Kernel { // Resolve or create MiddlewareHandler this.app.middlewareHandler = this.app.has(MiddlewareHandler) ? this.app.make(MiddlewareHandler) - : new MiddlewareHandler() + : new MiddlewareHandler([], this.app); + + (request.constructor as any).enableHttpMethodParameterOverride() /** * Run middleware stack and obtain result @@ -78,7 +90,7 @@ export class Kernel { public async resolve ( event: H3Event, middleware: IMiddleware | IMiddleware[], - handler: (ctx: HttpContext) => Promise + handler: (ctx: IHttpContext) => Promise ): Promise { const { Response } = await import('@h3ravel/http') diff --git a/packages/core/src/Manager/ContainerResolver.ts b/packages/core/src/Manager/ContainerResolver.ts index 1ca21e94..82d9f86b 100644 --- a/packages/core/src/Manager/ContainerResolver.ts +++ b/packages/core/src/Manager/ContainerResolver.ts @@ -2,6 +2,11 @@ import 'reflect-metadata' import { Application } from '..' +type Predicate = + | string + | ((...args: any[]) => any) + | (abstract new (...args: any[]) => any) + export class ContainerResolver { constructor(private app: Application) { } @@ -33,9 +38,17 @@ export class ContainerResolver { }) } - static isClass (C: any) { + static isClass (C: Predicate): C is new (...args: any[]) => any { return typeof C === 'function' && C.prototype !== undefined && Object.toString.call(C).substring(0, 5) === 'class' } + + static isAbstract (C: Predicate): C is new (...args: any[]) => any { + return this.isClass(C) && C.name.startsWith('I') + } + + static isCallable (C: Predicate): C is (...args: any[]) => any { + return typeof C === 'function' && !ContainerResolver.isClass(C) + } } diff --git a/packages/core/src/Manager/Foundation.ts b/packages/core/src/Manager/Foundation.ts deleted file mode 100644 index 708a81c6..00000000 --- a/packages/core/src/Manager/Foundation.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ExceptionHandler, Exceptions, Middleware, MiddlewareHandler } from '@h3ravel/foundation' - -import { Application } from '..' - -export class Foundation { - constructor(private app: Application) { } - - /** - * Register and wire up the application's exception handling layer. - * - * @param using - **/ - public withExceptions (using: (exceptions: Exceptions) => void) { - // Register the ExceptionHandler as a singleton - this.app.singleton(ExceptionHandler, () => new ExceptionHandler()) - - // Default to a no-op callback if none provided - using ??= () => true - - // Hook into the lifecycle to initialize Exceptions once the handler is resolved - this.app.afterResolving(ExceptionHandler, (handler) => { - using(new Exceptions(handler)) - }) - - return this - } - - /** - * Register and wire up the application's middleware handling layer. - * - * @param using - **/ - public withMiddleware (using: (middleware: Middleware) => void) { - // Register the middleware container/manager as a singleton - this.app.bind(MiddlewareHandler, () => new MiddlewareHandler()) - - // Default to no-op callback if none provided - using ??= () => true - - // After resolution, pass an instance of Middleware into the user callback - this.app.afterResolving(MiddlewareHandler, (handler) => { - using(new Middleware(handler)) - }) - - return this - } -} \ No newline at end of file diff --git a/packages/core/src/Manager/Inject.ts b/packages/core/src/Manager/Inject.ts index 060182d5..0c023dc1 100644 --- a/packages/core/src/Manager/Inject.ts +++ b/packages/core/src/Manager/Inject.ts @@ -1,35 +1 @@ -export function Inject (...dependencies: string[]) { - return function (target: any) { - target.__inject__ = dependencies - } -} - -/** - * Allows binding dependencies to both class and class methods - * - * @returns - */ -export function Injectable (): ClassDecorator & MethodDecorator { - return (...args: any[]) => { - if (args.length === 1) { - void args[0] // class target - } - if (args.length === 3) { - void args[0] // target - void args[1] // propertyKey - void args[2] // descriptor - } - } -} - -// export function Injectable (): MethodDecorator & ClassDecorator { -// return ((_target: any, _propertyKey?: string, descriptor?: PropertyDescriptor) => { -// if (descriptor) { -// const original = descriptor.value; -// descriptor.value = async function (...args: any[]) { -// const resolvedArgs = await Promise.all(args); -// return original.apply(this, resolvedArgs); -// }; -// } -// }) as any; -// } +export { Inject, Injectable } from '@h3ravel/foundation' diff --git a/packages/core/src/ProviderRegistry.ts b/packages/core/src/ProviderRegistry.ts index 36f9f5da..d4a62a9e 100644 --- a/packages/core/src/ProviderRegistry.ts +++ b/packages/core/src/ProviderRegistry.ts @@ -1,13 +1,12 @@ +import { ConcreteConstructor, IServiceProvider } from '@h3ravel/contracts' + import type { Application } from './Application' import { ContainerResolver } from '../src/Manager/ContainerResolver' -import { ServiceProvider } from './ServiceProvider' import fg from 'fast-glob' import path from 'node:path' -type ProviderCtor = (new (_app: Application) => ServiceProvider) & Partial - export class ProviderRegistry { - private static providers = new Map() + private static providers = new Map>() private static priorityMap = new Map() private static filteredProviders: string[] = [] private static sortable = true @@ -27,7 +26,7 @@ export class ProviderRegistry { * @param provider * @returns */ - private static getKey (provider: ProviderCtor): string { + private static getKey (provider: ConcreteConstructor): string { // If provider has a declared static uid/id → prefer that const anyProvider = provider as any if (typeof anyProvider.uid === 'string') { @@ -49,7 +48,7 @@ export class ProviderRegistry { * @param providers * @returns */ - static register (...providers: ProviderCtor[]): void { + static register (...providers: ConcreteConstructor[]): void { const list = this.sortable ? this.sort(providers.concat(...this.providers.values())) : providers.concat(...this.providers.values()) @@ -66,7 +65,7 @@ export class ProviderRegistry { * @param providers * @returns */ - static registerMany (providers: ProviderCtor[]): void { + static registerMany (providers: ConcreteConstructor[]): void { const list = this.sortable ? this.sort(providers.concat(...this.providers.values())) : providers.concat(...this.providers.values()) @@ -92,7 +91,7 @@ export class ProviderRegistry { * @param app * @returns */ - static async resolve (app: Application, useServiceContainer: boolean = false): Promise { + static async resolve (app: Application, useServiceContainer: boolean = false): Promise { // Remove all filtered service providers const providers = Array.from(this.providers.values()).filter(e => { @@ -116,8 +115,8 @@ export class ProviderRegistry { * @param providers * @returns */ - static sort (providers: ProviderCtor[]) { - const makeKey = (Provider: ProviderCtor) => `${Provider.name}::${this.getKey(Provider)}` + static sort (providers: ConcreteConstructor[]) { + const makeKey = (Provider: ConcreteConstructor) => `${Provider.name}::${this.getKey(Provider)}` // Step 1: Sort purely by priority (descending) providers.sort((A, B) => ((B as any).priority ?? 0) - ((A as any).priority ?? 0)) @@ -159,7 +158,7 @@ export class ProviderRegistry { */ static doSort () { const raw = this.sort(Array.from(this.providers.values())) - const providers = new Map() + const providers = new Map>() for (const provider of raw) { const key = this.getKey(provider) @@ -174,7 +173,7 @@ export class ProviderRegistry { * * @param priorityMap */ - static log

(providers?: Array

| Map) { + static log

(providers?: Array

| Map) { const sorted = Array.from(((providers as unknown as P[]) ?? this.providers).values()) console.table( @@ -193,7 +192,7 @@ export class ProviderRegistry { * * @returns */ - static all (): ProviderCtor[] { + static all (): ConcreteConstructor[] { return Array.from(this.providers.values()) } @@ -203,7 +202,7 @@ export class ProviderRegistry { * @param provider * @returns */ - static has (provider: ProviderCtor): boolean { + static has (provider: ConcreteConstructor): boolean { return this.providers.has(this.getKey(provider)) } @@ -220,7 +219,7 @@ export class ProviderRegistry { 'node_modules/h3ravel-*/package.json', ]) - const providers: ProviderCtor[] = [] + const providers: ConcreteConstructor[] = [] if (autoRegister) { for (const manifestPath of manifests) { diff --git a/packages/core/src/Providers/CoreServiceProvider.ts b/packages/core/src/Providers/CoreServiceProvider.ts index a3762a68..5d072aed 100644 --- a/packages/core/src/Providers/CoreServiceProvider.ts +++ b/packages/core/src/Providers/CoreServiceProvider.ts @@ -1,5 +1,7 @@ import 'reflect-metadata' +import { Application } from '..' +import { IApplication } from '@h3ravel/contracts' import { ServiceProvider } from '../ServiceProvider' import { str } from '@h3ravel/support' @@ -19,6 +21,8 @@ export class CoreServiceProvider extends ServiceProvider { Object.assign(globalThis, { str, }) + + this.app.alias(IApplication, Application) } boot (): void | Promise { diff --git a/packages/core/src/Registerer.ts b/packages/core/src/Registerer.ts index c4738c7e..d43d4016 100644 --- a/packages/core/src/Registerer.ts +++ b/packages/core/src/Registerer.ts @@ -1,10 +1,13 @@ import { dd, dump } from '@h3ravel/support' import { Application } from '.' +import { IRegisterer } from '@h3ravel/contracts' import nodepath from 'node:path' -export class Registerer { - constructor(private app: Application) { } +export class Registerer extends IRegisterer { + constructor(private app: Application) { + super() + } static register (app: Application) { const reg = new Registerer(app) diff --git a/packages/core/src/ServiceProvider.ts b/packages/core/src/ServiceProvider.ts index 4ce70cdf..5e99b8ce 100644 --- a/packages/core/src/ServiceProvider.ts +++ b/packages/core/src/ServiceProvider.ts @@ -1,74 +1 @@ -import { Application } from './Application' -import { IServiceProvider } from '@h3ravel/shared' - -const Inference = class { } as { new(): IServiceProvider } - -export abstract class ServiceProvider extends Inference { - /** - * The current app instance - */ - protected app: Application - - /** - * Unique Identifier for the service providers - */ - public static uid?: number - - /** - * Sort order - */ - - public static order?: `before:${string}` | `after:${string}` | string | undefined - - /** - * Sort priority - */ - public static priority = 0 - - /** - * Indicate that this service provider only runs in console - */ - public static console = false - - /** - * List of registered console commands - */ - public registeredCommands?: (new (app: any, kernel: any) => any)[] - - constructor(app: Application) { - super() - this.app = app - } - - /** - * Register bindings to the container. - * Runs before boot(). - */ - abstract register (...app: unknown[]): void | Promise; - - /** - * Perform post-registration booting of services. - * Runs after all providers have been registered. - */ - boot?(...app: unknown[]): void | Promise; - - /** - * Register the listed service providers. - * - * @param commands An array of console commands to register. - * - * @deprecated since version 1.16.0. Will be removed in future versions, use `registerCommands` instead - */ - commands (commands: (new (app: any, kernel: any) => any)[]): void { - this.registerCommands(commands) - } - - /** - * Register the listed service providers. - * - * @param commands An array of console commands to register. - */ - registerCommands (commands: (new (app: any, kernel: any) => any)[]) { - this.registeredCommands = commands - } -} \ No newline at end of file +export { ServiceProvider } from '@h3ravel/foundation' \ No newline at end of file diff --git a/packages/core/src/app.globals.d.ts b/packages/core/src/app.globals.d.ts index 1cdbe34a..c1a85d65 100644 --- a/packages/core/src/app.globals.d.ts +++ b/packages/core/src/app.globals.d.ts @@ -1,4 +1,4 @@ -import { HTTPResponse } from 'h3' +import { IResponsable } from '@h3ravel/contracts' export { } @@ -37,7 +37,7 @@ declare global { * @param viewPath * @param params */ - function view (viewPath: string, params?: Record | undefined): Promise + function view (viewPath: string, params?: Record | undefined): Promise /** * Get static asset diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 677efcef..761e7bcf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,11 +3,9 @@ export * from './Container' export * from './Contracts/H3ravelContract' export * from './Contracts/ServiceProviderConstructor' export * from './Controller' -export * from './Exceptions/ConfigException' export { h3ravel } from './H3ravel' export * from './Http/Kernel' export * from './Manager/ContainerResolver' -export * from './Manager/Foundation' export * from './Manager/Inject' export * from './ProviderRegistry' export * from './Providers/CoreServiceProvider' diff --git a/packages/core/tests/single-entry-point.test.ts b/packages/core/tests/single-entry-point.test.ts index 05e3b8fc..c4ebd410 100644 --- a/packages/core/tests/single-entry-point.test.ts +++ b/packages/core/tests/single-entry-point.test.ts @@ -5,12 +5,11 @@ import { h3ravel } from '@h3ravel/core' let app: Application -console.log = vi.fn(() => 0) - describe('Single Entry Point without @h3ravel/http installed', async () => { beforeEach(async () => { + const { EventsServiceProvider } = await import(('@h3ravel/events')) const { RouteServiceProvider } = await import(('@h3ravel/router')) - app = await h3ravel([RouteServiceProvider]) + app = await h3ravel([EventsServiceProvider, RouteServiceProvider]) }) it('returns the fully configured Application instance', async () => { diff --git a/packages/database/package.json b/packages/database/package.json index c57ba963..081449fd 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -70,4 +70,4 @@ "devDependencies": { "typescript": "^5.9.2" } -} +} \ No newline at end of file diff --git a/packages/database/src/Exceptions/ModelNotFoundException.ts b/packages/database/src/Exceptions/ModelNotFoundException.ts new file mode 100755 index 00000000..a5dfcd45 --- /dev/null +++ b/packages/database/src/Exceptions/ModelNotFoundException.ts @@ -0,0 +1,50 @@ +import { Arr } from '@h3ravel/support' +import { Model } from '../Model' +import { RecordsNotFoundException } from './RecordsNotFoundException' + +export class ModelNotFoundException extends RecordsNotFoundException { + /** + * Name of the affected Eloquent model. + */ + protected model?: Model + + /** + * The affected model IDs. + */ + protected ids: (number | string)[] = [] + + /** + * Set the affected Eloquent model and instance ids. + * + * @param model + * @param ids + */ + public setModel (model: Model, ids: (number | string)[] = []) { + this.model = model + this.ids = Arr.wrap(ids) + + this.message = `No query results for model [{${model.constructor.name}}]` + + if (this.ids.length > 0) { + this.message += ' ' + this.ids.join(', ') + } else { + this.message += '.' + } + + return this + } + + /** + * Get the affected Eloquent model. + */ + public getModel () { + return this.model + } + + /** + * Get the affected Eloquent model IDs. + */ + public getIds () { + return this.ids + } +} diff --git a/packages/database/src/Exceptions/RecordNotFoundException.ts b/packages/database/src/Exceptions/RecordNotFoundException.ts new file mode 100644 index 00000000..e80a38b2 --- /dev/null +++ b/packages/database/src/Exceptions/RecordNotFoundException.ts @@ -0,0 +1,2 @@ +export class RecordNotFoundException extends Error { +} diff --git a/packages/database/src/Exceptions/RecordsNotFoundException.ts b/packages/database/src/Exceptions/RecordsNotFoundException.ts new file mode 100644 index 00000000..42d7d730 --- /dev/null +++ b/packages/database/src/Exceptions/RecordsNotFoundException.ts @@ -0,0 +1,2 @@ +export class RecordsNotFoundException extends Error { +} diff --git a/packages/database/src/Model.ts b/packages/database/src/Model.ts index e981526a..a1824f4a 100644 --- a/packages/database/src/Model.ts +++ b/packages/database/src/Model.ts @@ -9,6 +9,6 @@ export class Model extends BaseModel { * @returns */ public resolveRouteBinding (value: any, field: undefined | string | null = null): Promise { - return this.newQuery().where(field ?? 'ids', value).firstOrFail()! as Promise + return this.newQuery().where(field ?? 'ids', value).firstOrFail() as unknown as Promise } } diff --git a/packages/database/src/Providers/DatabaseServiceProvider.ts b/packages/database/src/Providers/DatabaseServiceProvider.ts index 6f4cd092..3e867d83 100644 --- a/packages/database/src/Providers/DatabaseServiceProvider.ts +++ b/packages/database/src/Providers/DatabaseServiceProvider.ts @@ -27,6 +27,8 @@ export class DatabaseServiceProvider extends ServiceProvider { arquebus.addConnection(connection) } + this.app.singleton('db', () => arquebus.fire()) + /** Register Musket Commands */ this.registerCommands([MigrateCommand, MakeCommand, SeedCommand]) } diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index c726dfa5..fce9658f 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -2,6 +2,9 @@ export * from './Commands/MakeCommand' export * from './Commands/MigrateCommand' export * from './Commands/SeedCommand' export * from './Configuration' +export * from './Exceptions/ModelNotFoundException' +export * from './Exceptions/RecordNotFoundException' +export * from './Exceptions/RecordsNotFoundException' export * from './Model' export * from './Providers/DatabaseServiceProvider' export * from './Query/DB' diff --git a/packages/events/CHANGELOG.md b/packages/events/CHANGELOG.md new file mode 100644 index 00000000..e0533505 --- /dev/null +++ b/packages/events/CHANGELOG.md @@ -0,0 +1 @@ +# @h3ravel/events diff --git a/packages/events/README.md b/packages/events/README.md new file mode 100644 index 00000000..7f4b1e51 --- /dev/null +++ b/packages/events/README.md @@ -0,0 +1,43 @@ +

+ + H3ravel Logo + +

H3ravel Events

+ +[![Framework][ix]][lx] +[![Events Package Version][i1]][l1] +[![Downloads][d1]][d1] +[![Tests][tei]][tel] +[![License][lini]][linl] + +
+ +# About H3ravel/events + +This is the events handling system for the [H3ravel](https://h3ravel.toneflix.net) framework. + +## Contributing + +Thank you for considering contributing to the H3ravel framework! The [Contribution Guide](https://h3ravel.toneflix.net/contributing) can be found in the H3ravel documentation and will provide you with all the information you need to get started. + +## Code of Conduct + +In order to ensure that the H3ravel community is welcoming to all, please review and abide by the [Code of Conduct](#). + +## Security Vulnerabilities + +If you discover a security vulnerability within H3ravel, please send an e-mail to Legacy via hamzas.legacy@toneflix.ng. All security vulnerabilities will be promptly addressed. + +## License + +The H3ravel framework is open-sourced software licensed under the [MIT license](LICENSE). + +[ix]: https://img.shields.io/npm/v/%40h3ravel%2Fcore?style=flat-square&label=Framework&color=%230970ce +[lx]: https://www.npmjs.com/package/@h3ravel/core +[i1]: https://img.shields.io/npm/v/%40h3ravel%2Fevents?style=flat-square&label=@h3ravel/events&color=%230970ce +[l1]: https://www.npmjs.com/package/@h3ravel/events +[d1]: https://img.shields.io/npm/dt/%40h3ravel%2Fevents?style=flat-square&label=Downloads&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40h3ravel%2Fevents +[linl]: https://github.com/h3ravel/framework/blob/main/LICENSE +[lini]: https://img.shields.io/github/license/h3ravel/framework +[tel]: https://github.com/h3ravel/framework/actions/workflows/test.yml +[tei]: https://github.com/h3ravel/framework/actions/workflows/test.yml/badge.svg diff --git a/packages/events/package.json b/packages/events/package.json new file mode 100644 index 00000000..ce8f5ab3 --- /dev/null +++ b/packages/events/package.json @@ -0,0 +1,65 @@ +{ + "name": "@h3ravel/events", + "version": "0.1.0", + "description": "Events package for H3ravel.", + "h3ravel": { + "providers": [ + "EventsServiceProvider" + ] + }, + "type": "module", + "main": "./dist/index.cjs", + "types": "./dist/index.d.ts", + "module": "./dist/index.js", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./*": "./*" + } + }, + "homepage": "https://h3ravel.toneflix.net", + "repository": { + "type": "git", + "url": "git+https://github.com/h3ravel/framework.git", + "directory": "packages/events" + }, + "keywords": [ + "h3ravel", + "modern", + "web", + "H3", + "events", + "framework", + "nodejs", + "typescript", + "laravel" + ], + "scripts": { + "build": "tsdown --config-loader unconfig", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "lint": "eslint . --ext .ts", + "test": "jest --passWithNoTests", + "version-patch": "pnpm version patch" + }, + "peerDependencies": { + "@h3ravel/core": "workspace:^" + }, + "devDependencies": { + "typescript": "^5.4.0" + } +} \ No newline at end of file diff --git a/packages/queue/src/Jobs/.gitkeep b/packages/events/src/Contracts/.gitkeep similarity index 100% rename from packages/queue/src/Jobs/.gitkeep rename to packages/events/src/Contracts/.gitkeep diff --git a/packages/events/src/Contracts/EventsContract.ts b/packages/events/src/Contracts/EventsContract.ts new file mode 100644 index 00000000..34157549 --- /dev/null +++ b/packages/events/src/Contracts/EventsContract.ts @@ -0,0 +1,6 @@ +export type ListenerClassConstructor = (new (...args: any) => any) & { + subscribe?(...args: any[]): any +}; + +export type AppEvent = (...args: any[]) => any +export type AppListener = (...args: any[]) => any \ No newline at end of file diff --git a/packages/events/src/Dispatcher.ts b/packages/events/src/Dispatcher.ts new file mode 100644 index 00000000..3a24ea30 --- /dev/null +++ b/packages/events/src/Dispatcher.ts @@ -0,0 +1,294 @@ +import { AppEvent, AppListener, ListenerClassConstructor } from './Contracts/EventsContract' +import { Arr, Str } from '@h3ravel/support' + +import { Container } from '@h3ravel/core' + +export class Dispatcher { + /** + * The IoC container instance. + */ + protected container: Container + + /** + * The registered event listeners. + */ + protected listeners: Record = {} + + /** + * The wildcard listeners. + */ + protected wildcards: Record = {} + + /** + * The cached wildcard listeners. + */ + protected wildcardsCache: Record = {} + + /** + * The queue resolver instance. + */ + protected queueResolver?: (...a: any[]) => any + + /** + * The database transaction manager resolver instance. + */ + protected transactionManagerResolver?: (...a: any[]) => any + + /** + * The currently deferred events. + */ + protected deferredEvents: Record = {} + + /** + * Indicates if events should be deferred. + */ + protected deferringEvents = false + + /** + * The specific events to defer (null means defer all events). + */ + protected eventsToDefer?: AppEvent[] + + /** + * Create a new event dispatcher instance. + */ + constructor(container: Container) { + this.container = container ?? new Container() + } + + /** + * Register an event listener with the dispatcher. + * + * @param events + * @param listener + */ + public listen (events: AppEvent | AppEvent[] | string | string[], listener?: AppListener | AppListener[] | string | string[]) { + for (const event of Arr.wrap(events)) { + if (typeof event === 'string' && listener) { + if (event.includes('*')) { + this.setupWildcardListen(event, listener) + } else { + this.listeners[event].push(listener) + } + } else if (typeof event === 'function') { + event(listener) + } else if (typeof listener === 'function') { + listener() + } + } + } + + /** + * Setup a wildcard listener callback. + * + * @param event + * @param listener + */ + protected setupWildcardListen (event: string, listener: AppListener | AppListener[] | string | string[]) { + this.wildcards[event].push(listener) + + this.wildcardsCache = {} + } + + /** + * Determine if a given event has listeners. + * + * @param eventName + * @return bool + */ + public hasListeners (eventName: string) { + return this.listeners[eventName] || + this.wildcards[eventName] || + this.hasWildcardListeners(eventName) + } + + /** + * Determine if the given event has any wildcard listeners. + * + * @param eventName + */ + public hasWildcardListeners (eventName: string) { + for (const [key] of Object.entries(this.wildcards)) { + if (Str.is(key, eventName)) { + return true + } + } + + return false + } + + /** + * Register an event and payload to be fired later. + * + * @para event + * @param payload + * @return void + */ + public push (event: string, payload: Record | any[] = []) { + this.listen(event + '_pushed', () => { + this.dispatch(event, payload) + }) + } + + /** + * Flush a set of pushed events. + * + * @param event + */ + public flush (event: string) { + this.dispatch(event + '_pushed') + } + + /** + * Resolve the subscriber instance. + * + * @param subscriber + */ + protected resolveSubscriber (subscriber: string | ListenerClassConstructor) { + if (typeof subscriber === 'string') { + return this.container.make(subscriber as never) + } + + return subscriber + } + + /** + * Fire an event until the first non-null response is returned. + * + * @param event + * @param mixed payload + * @return mixed + */ + public until (event: AppEvent, payload = {}) { + return this.dispatch(event, payload, true) + } + + /** + * Fire an event and call the listeners. + * + * @param event + * @param payload + * @param halt + */ + public dispatch (event: Record | string, _payload: Record | any[] = [], _halt = false) { + } + + /** + * Remove a set of listeners from the dispatcher. + * + * @param event + */ + public forget (event: string) { + if (event.includes('*')) { + delete this.wildcards[event] + } else { + delete this.listeners[event] + } + + for (const [key] of Object.entries(this.wildcardsCache)) { + if (Str.is(event, key)) { + delete this.wildcardsCache[key] + } + } + } + + /** + * Forget all of the pushed listeners. + * + * @return void + */ + public forgetPushed () { + for (const [key] of Object.entries(this.listeners)) { + if (key.endsWith('_pushed')) { + this.forget(key) + } + } + } + + /** + * Get the queue implementation from the resolver. + */ + protected resolveQueue () { + return this.queueResolver?.() + } + + /** + * Set the queue resolver implementation. + * + * @param callable $resolver + * @return this + */ + public setQueueResolver (resolver: (...a: any[]) => any) { + this.queueResolver = resolver + + return this + } + + /** + * Get the database transaction manager implementation from the resolver. + */ + protected resolveTransactionManager () { + return this.transactionManagerResolver?.() + } + + /** + * Set the database transaction manager resolver implementation. + * + * @param resolver + */ + public setTransactionManagerResolver (resolver: (...a: any[]) => any) { + this.transactionManagerResolver = resolver + + return this + } + + /** + * Execute the given callback while deferring events, then dispatch all deferred events. + * + * @param callback + * @param events + */ + public defer (callback: (...a: any[]) => any, events: AppEvent[]) { + const wasDeferring = this.deferringEvents + const previousDeferredEvents = this.deferredEvents + const previousEventsToDefer = this.eventsToDefer + + this.deferringEvents = true + this.deferredEvents = {} + this.eventsToDefer = events + + try { + const result = callback() + + this.deferringEvents = false + + for (const args of Object.entries(this.deferredEvents)) { + this.dispatch(...args) + } + + return result + } finally { + this.deferringEvents = wasDeferring + this.deferredEvents = previousDeferredEvents + this.eventsToDefer = previousEventsToDefer + } + } + + /** + * Determine if the given event should be deferred. + * + * @param event + */ + protected shouldDeferEvent (event: AppEvent) { + return this.deferringEvents && (this.eventsToDefer === null || this.eventsToDefer?.includes(event)) + } + + /** + * Gets the raw, unprepared listeners. + * + * @return array + */ + public getRawListeners () { + return this.listeners + } +} diff --git a/packages/events/src/Providers/EventsServiceProvider.ts b/packages/events/src/Providers/EventsServiceProvider.ts new file mode 100644 index 00000000..e19a786d --- /dev/null +++ b/packages/events/src/Providers/EventsServiceProvider.ts @@ -0,0 +1,27 @@ +import { Dispatcher } from '../Dispatcher' +import { IDispatcher } from '@h3ravel/contracts' +import { ServiceProvider } from '@h3ravel/core' + +/** + * Events handling. + */ +export class EventsServiceProvider extends ServiceProvider { + public static priority = 992 + public static order = 'before:RouteServiceProvider' + + register () { + this.app.singleton('app.events', (app) => { + return (new Dispatcher(app as never)) + .setQueueResolver(() => { + // return app.make(QueueFactoryContract) + }) + .setTransactionManagerResolver(function () { + // return app.has('db.transactions') + // ? app.make('db.transactions') + // : undefined + }) + }) + + this.app.alias(IDispatcher, Dispatcher) + } +} diff --git a/packages/events/src/QueuedListenerCalller.ts b/packages/events/src/QueuedListenerCalller.ts new file mode 100644 index 00000000..ce35f17f --- /dev/null +++ b/packages/events/src/QueuedListenerCalller.ts @@ -0,0 +1,118 @@ +import { Container } from '@h3ravel/core' +import { IJob } from '@h3ravel/contracts' +import { ListenerClassConstructor } from './Contracts/EventsContract' + +export class QueuedListenerCalller { + /** + * The underlying queue job instance. + */ + public job!: IJob + + /** + * The listener class. + */ + public className: ListenerClassConstructor + + /** + * The listener method. + */ + public method: string + + /** + * The data to be passed to the listener. + */ + public data: Record + + /** + * The number of times the job may be attempted. + */ + public tries?: number + + /** + * The maximum number of exceptions allowed, regardless of attempts. + */ + public maxExceptions?: number + + /** + * The number of seconds to wait before retrying a job that encountered an uncaught exception. + */ + public backoff?: number + + /** + * The timestamp indicating when the job should timeout. + */ + public retryUntil?: number + + /** + * The number of seconds the job can run before timing out. + */ + public timeout?: number + + /** + * Indicates if the job should fail if the timeout is exceeded. + */ + public failOnTimeout?: boolean = false + + /** + * Indicates if the job should be encrypted. + */ + public shouldBeEncrypted?: boolean = false + + /** + * Create a new job instance. + * + * @param class + * @param method + * @param data + */ + public constructor(className: ListenerClassConstructor, method: string, data: Record) { + this.data = data + this.className = className + this.method = method + } + + /** + * Handle the queued job. + */ + public handle (_container: Container) { + } + + /** + * Set the job instance of the given class if necessary. + * + * @param job + * @param instance + */ + protected setJobInstanceIfNecessary (job: IJob, instance: any) { + void job + void instance + return {} + } + + /** + * Call the failed method on the job instance. + * + * The event instance and the exception will be passed. + * + * @param e + */ + public failed (_e: Error) { + } + + /** + * Unserialize the data if needed. + * + * @return void + */ + protected prepareData () { + } + + /** + * Get the display name for the queued job. + * + * @return string + */ + public displayName () { + return this.className + } +} \ No newline at end of file diff --git a/packages/events/src/index.ts b/packages/events/src/index.ts new file mode 100644 index 00000000..460b480e --- /dev/null +++ b/packages/events/src/index.ts @@ -0,0 +1,4 @@ +export * from './Contracts/EventsContract' +export * from './Dispatcher' +export * from './Providers/EventsServiceProvider' +export * from './QueuedListenerCalller' diff --git a/packages/router/src/Contracts/.gitkeep b/packages/events/tests/.gitkeep similarity index 100% rename from packages/router/src/Contracts/.gitkeep rename to packages/events/tests/.gitkeep diff --git a/packages/events/tsconfig.json b/packages/events/tsconfig.json new file mode 100644 index 00000000..168716d3 --- /dev/null +++ b/packages/events/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/foundation/package.json b/packages/foundation/package.json index a88d4003..b5417bc7 100644 --- a/packages/foundation/package.json +++ b/packages/foundation/package.json @@ -62,9 +62,12 @@ "version-patch": "pnpm version patch" }, "devDependencies": { - "h3": "catalog:prod" + "@types/supertest": "^6.0.3", + "@h3ravel/contracts": "workspace:^", + "supertest": "^7.1.4" }, "dependencies": { + "h3": "catalog:prod", "@h3ravel/shared": "workspace:^", "@h3ravel/support": "workspace:^" } diff --git a/packages/foundation/src/Configuration/AppBuilder.ts b/packages/foundation/src/Configuration/AppBuilder.ts new file mode 100644 index 00000000..33b02fee --- /dev/null +++ b/packages/foundation/src/Configuration/AppBuilder.ts @@ -0,0 +1,93 @@ +import { ExceptionHandler, Exceptions, Kernel, Middleware, MiddlewareList } from '..' +import { IApplication, IKernel } from '@h3ravel/contracts' + +export class AppBuilder { + + /** + * The Folio / page middleware that have been defined by the user. + */ + protected pageMiddleware: MiddlewareList[] = [] + + constructor(private app: IApplication) { } + + /** + * Register the base kernel classes for the application. + */ + public withKernels () { + this.app.singleton(IKernel, Kernel) + + // TODO: Register Console Kernel here too + + return this + } + + /** + * Register and wire up the application's exception handling layer. + * + * @param using + **/ + public withExceptions (using: (exceptions: Exceptions) => void) { + // Register the ExceptionHandler as a singleton + this.app.singleton(ExceptionHandler, () => new ExceptionHandler()) + + // Default to a no-op callback if none provided + using ??= () => true + + // Hook into the lifecycle to initialize Exceptions once the handler is resolved + this.app.afterResolving(ExceptionHandler, (handler) => { + using(new Exceptions(handler)) + }) + + return this + } + + /** + * Register and wire up the application's middleware handling layer. + * + * @param using + **/ + public withMiddleware (callback?: (mw: Middleware) => void) { + // After resolution, pass an instance of Middleware into the user callback + this.app.afterResolving(IKernel, (kernel) => { + const middleware = new Middleware(this.app) + .redirectGuestsTo(() => route('login')) + + if (callback && typeof callback === 'function') { + callback(middleware) + } + + this.pageMiddleware = middleware.getPageMiddleware() + kernel.setGlobalMiddleware(middleware.getGlobalMiddleware()) + kernel.setMiddlewareGroups(middleware.getMiddlewareGroups()) + kernel.setMiddlewareAliases(middleware.getMiddlewareAliases()) + + const priorities = middleware.getMiddlewarePriority() + if (priorities) { + kernel.setMiddlewarePriority(priorities) + } + + // const priorityAppends = middleware.getMiddlewarePriorityAppends() + // if (priorityAppends) { + // for (const [newMiddleware, after] of Object.entries(priorityAppends)) { + // kernel.addToMiddlewarePriorityAfter(after, newMiddleware) + // } + // } + + // const priorityPrepends = middleware.getMiddlewarePriorityPrepends() + // if (priorityPrepends) { + // for (const [newMiddleware, before] of Object.entries(priorityAppends)) { + // kernel.addToMiddlewarePriorityBefore(before, newMiddleware) + // } + // } + }) + + return this + } + + /** + * create + */ + public create () { + + } +} \ No newline at end of file diff --git a/packages/foundation/src/Configuration/Middleware.ts b/packages/foundation/src/Configuration/Middleware.ts index e194fde3..d559a341 100644 --- a/packages/foundation/src/Configuration/Middleware.ts +++ b/packages/foundation/src/Configuration/Middleware.ts @@ -1,7 +1,7 @@ -import { MiddlewareIdentifier, MiddlewareList, RedirectHandler } from '../Contracts/MiddlewareContract' +import { IApplication, IMiddleware } from '@h3ravel/contracts' +import { MiddlewareList, RedirectHandler } from '../Contracts/MiddlewareContract' import { Arr } from '@h3ravel/support' -import { MiddlewareHandler } from '../Http/MiddlewareHandler' /** * Core Middleware configuration container. @@ -38,20 +38,20 @@ export class Middleware { protected groupPrepends: Record = {} protected groupAppends: Record = {} protected groupRemovals: Record = {} - protected groupReplacements: Record> = {} + protected groupReplacements: Record> = {} - protected pageMiddleware: Record = {} - protected _priority: string[] = [] + protected pageMiddleware: MiddlewareList[] = [] + protected _priority: MiddlewareList = [] protected _trustHosts = false protected _statefulApi = false protected _throttleWithRedis = false protected apiLimiter: string | null = null protected authenticatedSessions = false - protected customAliases: Record = {} - protected prependPriority: Record = {} - protected appendPriority: Record = {} + protected customAliases: Record = {} + protected prependPriority: Record = {} + protected appendPriority: Record = {} - constructor(public handler: MiddlewareHandler) { } + constructor(private app?: IApplication) { } /** * Prepend middleware to the application's global middleware stack. @@ -59,7 +59,7 @@ export class Middleware { * @param middleware * @returns */ - public prepend (middleware: MiddlewareList | MiddlewareIdentifier): this { + public prepend (middleware: MiddlewareList | IMiddleware): this { this.prepends = [...Arr.wrap(middleware), ...this.prepends] return this } @@ -70,7 +70,7 @@ export class Middleware { * @param middleware * @returns */ - public append (middleware: MiddlewareList | MiddlewareIdentifier): this { + public append (middleware: MiddlewareList | IMiddleware): this { this.appends = [...this.appends, ...Arr.wrap(middleware)] return this } @@ -81,7 +81,7 @@ export class Middleware { * @param middleware * @returns */ - public remove (middleware: MiddlewareList | MiddlewareIdentifier): this { + public remove (middleware: MiddlewareList | IMiddleware): this { this.removals = [...this.removals, ...Arr.wrap(middleware)] return this } @@ -129,7 +129,7 @@ export class Middleware { * @param middleware * @returns */ - public prependToGroup (group: string, middleware: MiddlewareList | MiddlewareIdentifier): this { + public prependToGroup (group: string, middleware: MiddlewareList | IMiddleware): this { this.groupPrepends[group] = [...Arr.wrap(middleware), ...(this.groupPrepends[group] ?? [])] return this } @@ -141,7 +141,7 @@ export class Middleware { * @param middleware * @returns */ - public appendToGroup (group: string, middleware: MiddlewareList | MiddlewareIdentifier): this { + public appendToGroup (group: string, middleware: MiddlewareList | IMiddleware): this { this.groupAppends[group] = [...(this.groupAppends[group] ?? []), ...Arr.wrap(middleware)] return this } @@ -153,7 +153,7 @@ export class Middleware { * @param middleware * @returns */ - public removeFromGroup (group: string, middleware: MiddlewareList | MiddlewareIdentifier): this { + public removeFromGroup (group: string, middleware: MiddlewareList | IMiddleware): this { this.groupRemovals[group] = [...Arr.wrap(middleware), ...(this.groupRemovals[group] ?? [])] return this } @@ -166,7 +166,7 @@ export class Middleware { * @param replaceWith * @returns */ - public replaceInGroup (group: string, search: string, replaceWith: string): this { + public replaceInGroup (group: string, search: string, replaceWith: IMiddleware): this { this.groupReplacements[group] = this.groupReplacements[group] ?? {} this.groupReplacements[group][search] = replaceWith return this @@ -182,10 +182,10 @@ export class Middleware { * @returns */ public web ( - append: MiddlewareList | MiddlewareIdentifier | [] = [], - prepend: MiddlewareList | MiddlewareIdentifier | [] = [], - remove: MiddlewareList | MiddlewareIdentifier | [] = [], - replace: Record = {} + append: MiddlewareList | IMiddleware | [] = [], + prepend: MiddlewareList | IMiddleware | [] = [], + remove: MiddlewareList | IMiddleware | [] = [], + replace: Record = {} ): this { return this.modifyGroup('web', append, prepend, remove, replace) } @@ -200,10 +200,10 @@ export class Middleware { * @returns */ public api ( - append: MiddlewareList | MiddlewareIdentifier | [] = [], - prepend: MiddlewareList | MiddlewareIdentifier | [] = [], - remove: MiddlewareList | MiddlewareIdentifier | [] = [], - replace: Record = {} + append: MiddlewareList | IMiddleware | [] = [], + prepend: MiddlewareList | IMiddleware | [] = [], + remove: MiddlewareList | IMiddleware | [] = [], + replace: Record = {} ): this { return this.modifyGroup('api', append, prepend, remove, replace) } @@ -220,10 +220,10 @@ export class Middleware { */ protected modifyGroup ( group: string, - append: MiddlewareList | MiddlewareIdentifier | [], - prepend: MiddlewareList | MiddlewareIdentifier | [], - remove: MiddlewareList | MiddlewareIdentifier | [], - replace: Record + append: MiddlewareList | IMiddleware | [], + prepend: MiddlewareList | IMiddleware | [], + remove: MiddlewareList | IMiddleware | [], + replace: Record ): this { if ((append as any) && (append as any).length !== 0) { this.appendToGroup(group, append as any) @@ -235,8 +235,8 @@ export class Middleware { this.removeFromGroup(group, remove as any) } if (replace && Object.keys(replace).length) { - for (const [s, r] of Object.entries(replace)) { - this.replaceInGroup(group, s, r) + for (const [key, middleware] of Object.entries(replace)) { + this.replaceInGroup(group, key, middleware) } } return this @@ -247,8 +247,8 @@ export class Middleware { * @param middleware * @returns */ - public pages (middleware: Record): this { - this.pageMiddleware = { ...middleware } + public pages (middleware: MiddlewareList[]): this { + this.pageMiddleware = [...middleware] return this } @@ -258,7 +258,7 @@ export class Middleware { * @param aliases * @returns */ - public alias (aliases: Record): this { + public alias (aliases: Record = {}): this { this.customAliases = { ...aliases } return this } @@ -269,7 +269,7 @@ export class Middleware { * @param list * @returns */ - public priority (list: string[]): this { + public priority (list: MiddlewareList): this { this._priority = [...list] return this } @@ -281,7 +281,7 @@ export class Middleware { * @param prependKey * @returns */ - public prependToPriorityList (before: string, prependKey: string): this { + public prependToPriorityList (before: IMiddleware, prependKey: string): this { this.prependPriority[prependKey] = before return this } @@ -293,7 +293,7 @@ export class Middleware { * @param appendKey * @returns */ - public appendToPriorityList (after: string, appendKey: string): this { + public appendToPriorityList (after: IMiddleware, appendKey: string): this { this.appendPriority[appendKey] = after return this } @@ -318,15 +318,23 @@ export class Middleware { /** * Build middleware groups with applied group-level replacements, removals, prepends, appends. - * - * @param defaultGroups - * @returns */ - public getMiddlewareGroups (defaultGroups?: Record): Record { + public getMiddlewareGroups (): Record { const built: Record = {} + const middleware: Record = { + 'web': [ + 'SubstituteBindings', + this.authenticatedSessions ? 'auth.session' : null, + ].filter(e => e !== null), + + 'api': [ + this.apiLimiter ? 'throttle:' + this.apiLimiter : null, + ].filter(e => e !== null), + } + // start with defaults if provided, else use current groups - const base = { ...(defaultGroups ?? {}), ...this.groups } + const base = { ...middleware, ...this.groups } for (const [group, list] of Object.entries(base)) { // clone base list for mutations @@ -374,18 +382,25 @@ export class Middleware { } /** - * Register redirect handlers; accepts string or () => string. - * In this core version, we only store them and do not wire into any concrete Authenticate classes. + * Register redirect handlers for the authentication and guest middleware. + * + * @param guests + * @param users + * @returns */ public redirectTo (guests?: RedirectHandler, users?: RedirectHandler): this { - // store as normalized lambdas on customAliases for demo purposes + + guests = typeof guests === 'string' ? () => String(guests) : guests + users = typeof users === 'string' ? () => String(users) : users + if (guests) { - const guestKey = '__redirect_guests' - this.customAliases[guestKey] = typeof guests === 'string' ? guests : guests() + // Authenticate.redirectUsing(guests) + // AuthenticateSession.redirectUsing(guests) + // AuthenticationException.redirectUsing(guests) } + if (users) { - const userKey = '__redirect_users' - this.customAliases[userKey] = typeof users === 'string' ? users : users() + // RedirectIfAuthenticated.redirectUsing(users) } return this } @@ -528,7 +543,7 @@ export class Middleware { * * @returns */ - public getPageMiddleware (): Record { + public getPageMiddleware (): MiddlewareList[] { return { ...this.pageMiddleware } } @@ -537,7 +552,7 @@ export class Middleware { * * @returns */ - public getMiddlewareAliases (): Record { + public getMiddlewareAliases (): Record { return { ...this.defaultAliases(), ...this.customAliases } } @@ -546,7 +561,7 @@ export class Middleware { * * @returns */ - public defaultAliases (): Record { + public defaultAliases (): Record { const aliases: Record = { auth: 'Authenticate', 'auth.basic': 'AuthenticateWithBasicAuth', @@ -559,6 +574,7 @@ export class Middleware { verified: 'EnsureEmailIsVerified', } + // @ts-expect-error TODO: ensure that actuall middlewares are aliased, not strings return aliases } @@ -567,7 +583,7 @@ export class Middleware { * * @returns */ - public getMiddlewarePriority (): string[] { + public getMiddlewarePriority (): MiddlewareList { return [...this._priority] } @@ -576,7 +592,7 @@ export class Middleware { * * @returns */ - public getMiddlewarePriorityPrepends (): Record { + public getMiddlewarePriorityPrepends (): Record { return { ...this.prependPriority } } @@ -585,7 +601,7 @@ export class Middleware { * * @returns */ - public getMiddlewarePriorityAppends (): Record { + public getMiddlewarePriorityAppends (): Record { return { ...this.appendPriority } } } diff --git a/packages/foundation/src/Container/Inject.ts b/packages/foundation/src/Container/Inject.ts new file mode 100644 index 00000000..3bec12e0 --- /dev/null +++ b/packages/foundation/src/Container/Inject.ts @@ -0,0 +1,37 @@ +export function Inject (...dependencies: string[]) { + return function (target: any) { + target.__inject__ = dependencies + } +} + +/** + * Allows binding dependencies to both class and class methods + * + * @returns + */ +export function Injectable (): MethodDecorator & ClassDecorator { + return (target: any, propertyKey?: string | symbol, descriptor?: PropertyDescriptor): any => { + if (descriptor) { + const original = descriptor.value + descriptor.value = async function (...args: any[]) { + const resolvedArgs = await Promise.all(args) + return original.apply(this, resolvedArgs) + } + descriptor.value.__ownerClass = target.constructor + return descriptor + } + } +} + +// export function Injectable (): MethodDecorator & ClassDecorator { +// return ((_target: any, _propertyKey?: string, descriptor?: PropertyDescriptor) => { +// if (descriptor) { +// const original = descriptor.value +// descriptor.value = async function (...args: any[]) { +// const resolvedArgs = await Promise.all(args) +// return original.apply(this, resolvedArgs) +// } +// } +// }) as any +// } + diff --git a/packages/foundation/src/Contracts/MiddlewareContract.ts b/packages/foundation/src/Contracts/MiddlewareContract.ts index 9fed7222..b2ee154d 100644 --- a/packages/foundation/src/Contracts/MiddlewareContract.ts +++ b/packages/foundation/src/Contracts/MiddlewareContract.ts @@ -1,4 +1,4 @@ -import { IMiddleware } from '@h3ravel/shared' +import { IMiddleware } from '@h3ravel/contracts' export type RedirectHandler = string | (() => string); export type MiddlewareIdentifier = string | IMiddleware; diff --git a/packages/foundation/src/Core/ServiceProvider.ts b/packages/foundation/src/Core/ServiceProvider.ts new file mode 100644 index 00000000..8abd2e34 --- /dev/null +++ b/packages/foundation/src/Core/ServiceProvider.ts @@ -0,0 +1,95 @@ +import { IApplication, IServiceProvider } from '@h3ravel/contracts' + +export abstract class ServiceProvider extends IServiceProvider { + /** + * The current app instance + */ + protected app: IApplication + + /** + * Unique Identifier for the service providers + */ + static uid?: number + + /** + * Sort order + */ + + static order?: `before:${string}` | `after:${string}` | string | undefined + + /** + * Sort priority + */ + static priority = 0 + + /** + * Indicate that this service provider only runs in console + */ + static console = false + /** + * Indicate that this service provider only runs in console + */ + console = false + + /** + * Indicate that this service provider only runs in console + */ + runsInConsole = false + + /** + * List of registered console commands + */ + registeredCommands?: (new (app: any, kernel: any) => any)[] + + /** + * All of the registered booted callbacks. + */ + protected bootedCallbacks: Array<(...args: any[]) => void> = [] + + constructor(app: IApplication) { + super() + this.app = app + } + + /** + * Register a booted callback to be run after the "boot" method is called. + * + * @param callback + */ + booted (callback: (...args: any[]) => void): void | Promise { + this.bootedCallbacks.push(callback) + } + + /** + * Call the registered booted callbacks. + */ + async callBootedCallbacks (): Promise { + let index = 0 + + while (index < this.bootedCallbacks.length) { + await this.app.call(this.bootedCallbacks[index]) + + index++ + } + } + + /** + * Register the listed service providers. + * + * @param commands An array of console commands to register. + * + * @deprecated since version 1.16.0. Will be removed in future versions, use `registerCommands` instead + */ + commands (commands: (new (app: any, kernel: any) => any)[]): void { + this.registerCommands(commands) + } + + /** + * Register the listed service providers. + * + * @param commands An array of console commands to register. + */ + registerCommands (commands: (new (app: any, kernel: any) => any)[]) { + this.registeredCommands = commands + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/AccessDeniedHttpException.ts b/packages/foundation/src/Exceptions/AccessDeniedHttpException.ts index b976693f..9c833b87 100644 --- a/packages/foundation/src/Exceptions/AccessDeniedHttpException.ts +++ b/packages/foundation/src/Exceptions/AccessDeniedHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class AccessDeniedHttpException extends HttpExceptionFactory { constructor( diff --git a/packages/foundation/src/Exceptions/BadRequestHttpException.ts b/packages/foundation/src/Exceptions/BadRequestHttpException.ts index bba82152..9b4a677b 100644 --- a/packages/foundation/src/Exceptions/BadRequestHttpException.ts +++ b/packages/foundation/src/Exceptions/BadRequestHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class BadRequestHttpException extends HttpExceptionFactory { constructor( diff --git a/packages/foundation/src/Exceptions/ExceptionHandler.ts b/packages/foundation/src/Exceptions/Base/ExceptionHandler.ts similarity index 81% rename from packages/foundation/src/Exceptions/ExceptionHandler.ts rename to packages/foundation/src/Exceptions/Base/ExceptionHandler.ts index 036cc364..336773f7 100644 --- a/packages/foundation/src/Exceptions/ExceptionHandler.ts +++ b/packages/foundation/src/Exceptions/Base/ExceptionHandler.ts @@ -1,10 +1,11 @@ import { Handler } from './Handler' -import { HttpContext } from '@h3ravel/shared' import { HttpException } from './HttpException' -import { RequestException } from '../Http/RequestException' +import { IHttpContext } from '@h3ravel/contracts' +import { RequestException } from './RequestException' export class ExceptionHandler extends Handler { - public async handle (error: Error, ctx: HttpContext) { + public async handle (error: Error, ctx: IHttpContext) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const e = this.mapException(error) try { @@ -21,7 +22,7 @@ export class ExceptionHandler extends Handler { * Try custom render callbacks */ for (const cb of this.renderCallbacks) { - const response = await cb(error, ctx) + const response = await cb(error, ctx.request) if (response) return response } @@ -33,7 +34,7 @@ export class ExceptionHandler extends Handler { error = HttpException.fromStatusCode(status, error.message || 'Server Error', error) } - return this.render(ctx, error) + return this.render(ctx.request, error) } catch (handlingError) { /** * Fallback for catastrophic errors during handling diff --git a/packages/foundation/src/Exceptions/Exceptions.ts b/packages/foundation/src/Exceptions/Base/Exceptions.ts similarity index 98% rename from packages/foundation/src/Exceptions/Exceptions.ts rename to packages/foundation/src/Exceptions/Base/Exceptions.ts index bfc734b5..3ae1ba29 100644 --- a/packages/foundation/src/Exceptions/Exceptions.ts +++ b/packages/foundation/src/Exceptions/Base/Exceptions.ts @@ -1,6 +1,6 @@ import { Arr } from '@h3ravel/support' import { Handler } from './Handler' -import { RequestException } from '../Http/RequestException' +import { RequestException } from './RequestException' export class Exceptions { /** diff --git a/packages/foundation/src/Exceptions/Handler.ts b/packages/foundation/src/Exceptions/Base/Handler.ts similarity index 92% rename from packages/foundation/src/Exceptions/Handler.ts rename to packages/foundation/src/Exceptions/Base/Handler.ts index 214b29cc..17cd81ca 100644 --- a/packages/foundation/src/Exceptions/Handler.ts +++ b/packages/foundation/src/Exceptions/Base/Handler.ts @@ -1,9 +1,11 @@ -/// +/// -import { ExceptionConditionCallback, ExceptionConstructor, FileSystem, HttpContext, IRequest, IResponse, RenderExceptionCallback, ReportExceptionCallback, ThrottleExceptionCallback } from '@h3ravel/shared' -import { LimitSpec, RateLimiterAdapter } from '../Contracts/RateLimiterAdapter' +import type { ExceptionConditionCallback, ExceptionConstructor, IHttpContext, IRequest, IResponse } from '@h3ravel/contracts' +import { LimitSpec, RateLimiterAdapter } from '../../Contracts/RateLimiterAdapter' +import { IExceptionHandler, type RenderExceptionCallback, type ReportExceptionCallback, type ThrottleExceptionCallback } from '@h3ravel/contracts' -import { InMemoryRateLimiter } from '../Adapters/InMemoryRateLimiter' +import { FileSystem } from '@h3ravel/shared' +import { InMemoryRateLimiter } from '../../Adapters/InMemoryRateLimiter' import { readFileSync } from 'node:fs' /** @@ -12,7 +14,7 @@ import { readFileSync } from 'node:fs' * . * - We will use `RateLimiterAdapter` to plug in Redis / cache-backed limiters later. */ -export abstract class Handler { +export abstract class Handler extends IExceptionHandler { /** * List of exception constructors that should not be reported. */ @@ -78,19 +80,13 @@ export abstract class Handler { */ protected rateLimiter: RateLimiterAdapter = new InMemoryRateLimiter() - /** - * no-op; subclasses can extend constructor and call super() - */ - constructor() { - } - /** * The exception handler method * * @param error * @param ctx */ - public handle?(error: Error, ctx: HttpContext): Promise + public handle?(error: Error, ctx: IHttpContext): Promise /** * Finalize response callback (respondUsing) @@ -361,7 +357,7 @@ export abstract class Handler { * @param error * @returns */ - public async render (ctx: HttpContext, error: any): Promise { + public async render (request: IRequest, error: any): Promise { const e = this.mapException(error) const { Response } = await import('@h3ravel/http') @@ -371,22 +367,22 @@ export abstract class Handler { */ if (e && typeof e.render === 'function') { try { - const resp = await Promise.resolve(e.render(ctx, e)) - if (resp instanceof Response) return this.finalizeRenderedResponse(ctx.request, resp, e) + const resp = await Promise.resolve(e.render(request, e)) + if (resp instanceof Response) return this.finalizeRenderedResponse(request, resp as never, e) } catch { // ignore and continue to handler-level renderers } } /** - * If error implements Responsable-like `toResponse(request)` + * If error implements ResponsableType-like `toResponse(request)` */ if (e && typeof e.toResponse === 'function') { try { - const resp = await Promise.resolve(e.toResponse(ctx.request)) + const resp = await Promise.resolve(e.toResponse(request)) - if (resp instanceof Response) return this.finalizeRenderedResponse(ctx.request, resp, e) - else if (Object.entries(resp).length) return this.getResponse(ctx, resp, e) + if (resp instanceof Response) return this.finalizeRenderedResponse(request, resp as never, e) + else if (Object.entries(resp).length) return this.getResponse(request, resp, e) } catch { // ignore and continue } @@ -397,9 +393,9 @@ export abstract class Handler { */ for (const cb of this.renderCallbacks) { try { - const resp = await Promise.resolve(cb(e, ctx)) + const resp = await Promise.resolve(cb(e, request)) if (resp instanceof Response) { - return this.finalizeRenderedResponse(ctx.request, resp, e) + return this.finalizeRenderedResponse(request, resp, e) } } catch { // swallow render callback errors @@ -409,17 +405,17 @@ export abstract class Handler { /** * Return JSON response when shouldRenderJson / expectsJson, else generic HTML/text */ - if (this.shouldReturnJson(ctx.request, e)) { - return this.finalizeRenderedResponse(ctx.request, this.prepareJsonResponse(ctx.request, e), e) + if (this.shouldReturnJson(request, e)) { + return this.finalizeRenderedResponse(request, this.prepareJsonResponse(request, e), e) } - return this.finalizeRenderedResponse(ctx.request, await this.prepareResponse(ctx.request, e), e) + return this.finalizeRenderedResponse(request, await this.prepareResponse(request, e), e) } /** * getResponse */ - public getResponse ({ request }: HttpContext, payload: Record, e: any): IResponse | Promise { + public getResponse (request: IRequest, payload: Record, e: any): IResponse | Promise { if (this.shouldReturnJson(request, e)) { return response() .setCharset('utf-8') diff --git a/packages/foundation/src/Exceptions/HttpException.ts b/packages/foundation/src/Exceptions/Base/HttpException.ts similarity index 69% rename from packages/foundation/src/Exceptions/HttpException.ts rename to packages/foundation/src/Exceptions/Base/HttpException.ts index c76464d8..c3bbab13 100644 --- a/packages/foundation/src/Exceptions/HttpException.ts +++ b/packages/foundation/src/Exceptions/Base/HttpException.ts @@ -1,17 +1,17 @@ -import { AccessDeniedHttpException } from './AccessDeniedHttpException' -import { BadRequestHttpException } from './BadRequestHttpException' -import { ConflictHttpException } from './ConflictHttpException' -import { GoneHttpException } from './GoneHttpException' -import { LengthRequiredHttpException } from './LengthRequiredHttpException' -import { LockedHttpException } from './LockedHttpException' -import { NotAcceptableHttpException } from './NotAcceptableHttpException' -import { NotFoundHttpException } from './NotFoundHttpException' -import { PreconditionFailedHttpException } from './PreconditionFailedHttpException' -import { PreconditionRequiredHttpException } from './PreconditionRequiredHttpException' -import { ServiceUnavailableHttpException } from './ServiceUnavailableHttpException' -import { TooManyRequestsHttpException } from './TooManyRequestsHttpException' -import { UnprocessableEntityHttpException } from './UnprocessableEntityHttpException' -import { UnsupportedMediaTypeHttpException } from './UnsupportedMediaTypeHttpException' +import { AccessDeniedHttpException } from '../AccessDeniedHttpException' +import { BadRequestHttpException } from '../BadRequestHttpException' +import { ConflictHttpException } from '../ConflictHttpException' +import { GoneHttpException } from '../GoneHttpException' +import { LengthRequiredHttpException } from '../LengthRequiredHttpException' +import { LockedHttpException } from '../LockedHttpException' +import { NotAcceptableHttpException } from '../NotAcceptableHttpException' +import { NotFoundHttpException } from '../NotFoundHttpException' +import { PreconditionFailedHttpException } from '../PreconditionFailedHttpException' +import { PreconditionRequiredHttpException } from '../PreconditionRequiredHttpException' +import { ServiceUnavailableHttpException } from '../ServiceUnavailableHttpException' +import { TooManyRequestsHttpException } from '../TooManyRequestsHttpException' +import { UnprocessableEntityHttpException } from '../UnprocessableEntityHttpException' +import { UnsupportedMediaTypeHttpException } from '../UnsupportedMediaTypeHttpException' /** * HttpException. diff --git a/packages/foundation/src/Exceptions/HttpExceptionFactory.ts b/packages/foundation/src/Exceptions/Base/HttpExceptionFactory.ts similarity index 100% rename from packages/foundation/src/Exceptions/HttpExceptionFactory.ts rename to packages/foundation/src/Exceptions/Base/HttpExceptionFactory.ts diff --git a/packages/foundation/src/Http/RequestException.ts b/packages/foundation/src/Exceptions/Base/RequestException.ts similarity index 97% rename from packages/foundation/src/Http/RequestException.ts rename to packages/foundation/src/Exceptions/Base/RequestException.ts index 12cba56d..06a87c6f 100644 --- a/packages/foundation/src/Http/RequestException.ts +++ b/packages/foundation/src/Exceptions/Base/RequestException.ts @@ -1,4 +1,4 @@ -import { IResponse } from '@h3ravel/shared' +import { IResponse } from '@h3ravel/contracts' export class RequestException { /** diff --git a/packages/foundation/src/Exceptions/ConflictHttpException.ts b/packages/foundation/src/Exceptions/ConflictHttpException.ts index 6729a395..fc77d8c4 100644 --- a/packages/foundation/src/Exceptions/ConflictHttpException.ts +++ b/packages/foundation/src/Exceptions/ConflictHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class ConflictHttpException extends HttpExceptionFactory { constructor( diff --git a/packages/foundation/src/Exceptions/Core/BindingResolutionException.ts b/packages/foundation/src/Exceptions/Core/BindingResolutionException.ts new file mode 100644 index 00000000..ea4c420c --- /dev/null +++ b/packages/foundation/src/Exceptions/Core/BindingResolutionException.ts @@ -0,0 +1,7 @@ + +export class BindingResolutionException extends Error { + constructor(message: string) { + super(message) + this.name = 'BindingResolutionException' + } +} \ No newline at end of file diff --git a/packages/core/src/Exceptions/ConfigException.ts b/packages/foundation/src/Exceptions/Core/ConfigException.ts similarity index 93% rename from packages/core/src/Exceptions/ConfigException.ts rename to packages/foundation/src/Exceptions/Core/ConfigException.ts index 7b60f27c..889a5a4d 100644 --- a/packages/core/src/Exceptions/ConfigException.ts +++ b/packages/foundation/src/Exceptions/Core/ConfigException.ts @@ -2,7 +2,6 @@ import { Logger } from '@h3ravel/shared' export class ConfigException extends Error { key: string - constructor(key: string, type: 'any' | 'config' | 'env' = 'config', cause?: unknown) { const info = { any: `${key} not configured.`, @@ -16,6 +15,7 @@ export class ConfigException extends Error { cause }) + this.name = 'ConfigException' this.key = key } } diff --git a/packages/foundation/src/Exceptions/Core/LogicException.ts b/packages/foundation/src/Exceptions/Core/LogicException.ts new file mode 100644 index 00000000..9e503753 --- /dev/null +++ b/packages/foundation/src/Exceptions/Core/LogicException.ts @@ -0,0 +1,6 @@ +export class LogicException extends Error { + constructor(message: string) { + super(message) + this.name = 'LogicException' + } +} diff --git a/packages/foundation/src/Exceptions/GoneHttpException.ts b/packages/foundation/src/Exceptions/GoneHttpException.ts index 0400c2b4..8c89dd78 100644 --- a/packages/foundation/src/Exceptions/GoneHttpException.ts +++ b/packages/foundation/src/Exceptions/GoneHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class GoneHttpException extends HttpExceptionFactory { constructor( diff --git a/packages/foundation/src/Exceptions/LengthRequiredHttpException.ts b/packages/foundation/src/Exceptions/LengthRequiredHttpException.ts index 823a197f..b1c91221 100644 --- a/packages/foundation/src/Exceptions/LengthRequiredHttpException.ts +++ b/packages/foundation/src/Exceptions/LengthRequiredHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class LengthRequiredHttpException extends HttpExceptionFactory { constructor( diff --git a/packages/foundation/src/Exceptions/LockedHttpException.ts b/packages/foundation/src/Exceptions/LockedHttpException.ts index 5b868f74..c3809652 100644 --- a/packages/foundation/src/Exceptions/LockedHttpException.ts +++ b/packages/foundation/src/Exceptions/LockedHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class LockedHttpException extends HttpExceptionFactory { constructor( diff --git a/packages/foundation/src/Exceptions/NotAcceptableHttpException.ts b/packages/foundation/src/Exceptions/NotAcceptableHttpException.ts index 6f823c91..7dce989f 100644 --- a/packages/foundation/src/Exceptions/NotAcceptableHttpException.ts +++ b/packages/foundation/src/Exceptions/NotAcceptableHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class NotAcceptableHttpException extends HttpExceptionFactory { constructor( diff --git a/packages/foundation/src/Exceptions/NotFoundHttpException.ts b/packages/foundation/src/Exceptions/NotFoundHttpException.ts index a70edd23..a78c4af4 100644 --- a/packages/foundation/src/Exceptions/NotFoundHttpException.ts +++ b/packages/foundation/src/Exceptions/NotFoundHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class NotFoundHttpException extends HttpExceptionFactory { constructor( diff --git a/packages/foundation/src/Exceptions/PreconditionFailedHttpException.ts b/packages/foundation/src/Exceptions/PreconditionFailedHttpException.ts index 91d466c1..8e2caf5b 100644 --- a/packages/foundation/src/Exceptions/PreconditionFailedHttpException.ts +++ b/packages/foundation/src/Exceptions/PreconditionFailedHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class PreconditionFailedHttpException extends HttpExceptionFactory { constructor( diff --git a/packages/foundation/src/Exceptions/PreconditionRequiredHttpException.ts b/packages/foundation/src/Exceptions/PreconditionRequiredHttpException.ts index bd531815..827906aa 100644 --- a/packages/foundation/src/Exceptions/PreconditionRequiredHttpException.ts +++ b/packages/foundation/src/Exceptions/PreconditionRequiredHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class PreconditionRequiredHttpException extends HttpExceptionFactory { constructor( diff --git a/packages/foundation/src/Exceptions/ServiceUnavailableHttpException.ts b/packages/foundation/src/Exceptions/ServiceUnavailableHttpException.ts index 388620bf..041041e0 100644 --- a/packages/foundation/src/Exceptions/ServiceUnavailableHttpException.ts +++ b/packages/foundation/src/Exceptions/ServiceUnavailableHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class ServiceUnavailableHttpException extends HttpExceptionFactory { /** diff --git a/packages/foundation/src/Exceptions/TooManyRequestsHttpException.ts b/packages/foundation/src/Exceptions/TooManyRequestsHttpException.ts index c86dcdb1..effaa170 100644 --- a/packages/foundation/src/Exceptions/TooManyRequestsHttpException.ts +++ b/packages/foundation/src/Exceptions/TooManyRequestsHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class TooManyRequestsHttpException extends HttpExceptionFactory { /** diff --git a/packages/foundation/src/Exceptions/UnprocessableEntityHttpException.ts b/packages/foundation/src/Exceptions/UnprocessableEntityHttpException.ts index bb32dfe4..c967fea0 100644 --- a/packages/foundation/src/Exceptions/UnprocessableEntityHttpException.ts +++ b/packages/foundation/src/Exceptions/UnprocessableEntityHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class UnprocessableEntityHttpException extends HttpExceptionFactory { constructor( diff --git a/packages/foundation/src/Exceptions/UnsupportedMediaTypeHttpException.ts b/packages/foundation/src/Exceptions/UnsupportedMediaTypeHttpException.ts index 67eb9faa..32338b2c 100644 --- a/packages/foundation/src/Exceptions/UnsupportedMediaTypeHttpException.ts +++ b/packages/foundation/src/Exceptions/UnsupportedMediaTypeHttpException.ts @@ -1,4 +1,4 @@ -import { HttpExceptionFactory } from './HttpExceptionFactory' +import { HttpExceptionFactory } from './Base/HttpExceptionFactory' export class UnsupportedMediaTypeHttpException extends HttpExceptionFactory { constructor( diff --git a/packages/foundation/src/Http/Events/RequestHandled.ts b/packages/foundation/src/Http/Events/RequestHandled.ts new file mode 100644 index 00000000..7a36ad7f --- /dev/null +++ b/packages/foundation/src/Http/Events/RequestHandled.ts @@ -0,0 +1,24 @@ +import { IRequest, IResponse } from '@h3ravel/shared' + +export class RequestHandled { + /** + * The request instance. + */ + public request: IRequest + + /** + * The response instance. + */ + public response?: IResponse + + /** + * Create a new event instance. + * + * @param request + * @param response + */ + constructor(request: IRequest, response?: IResponse) { + this.request = request + this.response = response + } +} diff --git a/packages/foundation/src/Http/Kernel.ts b/packages/foundation/src/Http/Kernel.ts new file mode 100644 index 00000000..c0ae5d5a --- /dev/null +++ b/packages/foundation/src/Http/Kernel.ts @@ -0,0 +1,569 @@ +// namespace Illuminate\Foundation\Http; + +import { Arr, DateTime, InvalidArgumentException } from '@h3ravel/support' +import { IApplication, IKernel, IMiddleware, IRequest, IResponse, IRouter } from '@h3ravel/contracts' +import { MiddlewareIdentifier, MiddlewareList } from '../Contracts/MiddlewareContract' + +import { Injectable } from '..' +import { RequestHandled } from './Events/RequestHandled' + +@Injectable() +export class Kernel extends IKernel { + // use InteractsWithTime; + /** + * The bootstrap classes for the application. + */ + #bootstrappers = [ + ] + + /** + * The application's middleware stack. + */ + protected middleware: MiddlewareList = [] + + /** + * The application's route middleware groups. + */ + protected middlewareGroups: Record = {} + + /** + * The application's middleware aliases. + */ + protected middlewareAliases: Record = {} + + /** + * All of the registered request duration handlers. + */ + protected requestLifecycleDurationHandlers: { + threshold?: number + handler?: (...args: any[]) => void + }[] = [] + + /** + * When the kernel starting handling the current request. + */ + #requestStartedAt?: DateTime | undefined + + /** + * The priority-sorted list of middleware. + * + * Forces non-global middleware to always be in the given order. + */ + protected middlewarePriority: MiddlewareList = [] + + /** + * Create a new HTTP kernel instance. + * + * @param app The current application instance + * @param router The current router instance + */ + constructor( + protected app: IApplication, + protected router: IRouter + ) { + super() + this.syncMiddlewareToRouter() + } + + /** + * Handle an incoming HTTP request. + * + * @param request + */ + public async handle (request: IRequest) { + this.#requestStartedAt = new DateTime() + + let response: IResponse | undefined + try { + // request.constructor.prototype.enableHttpMethodParameterOverride() + + response = await this.sendRequestThroughRouter(request) + } catch (e) { + this.reportException(e as never) + + response = await this.renderException(request, e as never) + } + + this.app.make('app.events').dispatch( + new RequestHandled(request, response) + ) + + return response + } + + /** + * Send the given request through the middleware / router. + * + * @param request + */ + protected async sendRequestThroughRouter (request: IRequest): Promise { + this.app.instance('request', request) + + const { Pipeline } = await import('@h3ravel/router') + + return await (new Pipeline(this.app as never)) + .send(request) + .through(this.app.shouldSkipMiddleware() ? [] : this.middleware) + .then(this.dispatchToRouter()) + } + + /** + * Bootstrap the application for HTTP requests. + * + * @return void + */ + public bootstrap () { + // if (!this.app.hasBeenBootstrapped()) { + // this.app.bootstrapWith(this.bootstrappers()); + // } + } + + /** + * Get the route dispatcher callback. + */ + protected dispatchToRouter () { + return async (request: IRequest) => { + this.app.instance('request', request) + + return await this.router.dispatch(request) + } + } + + /** + * Call the terminate method on any terminable middleware. + * + * @param request + * @param response + */ + public terminate (request: IRequest, response: IResponse) { + // this.app.make('app.events').dispatch(new Terminating) + + this.terminateMiddleware(request, response) + + // this.app.terminate(); + + if (!this.#requestStartedAt) return + + this.#requestStartedAt?.tz(this.app.make('config').get('app.timezone') ?? 'UTC') + + /* + * Handle duration thresholds + */ + let end: DateTime + + for (const { threshold, handler } of Object.values(this.requestLifecycleDurationHandlers)) { + end ??= new DateTime() + + if (!threshold || typeof handler !== 'function') { + continue + } + + const diffMs = this.#requestStartedAt?.diff(end, 'milliseconds') ?? 0 + + if (diffMs > threshold) { + handler(this.#requestStartedAt, request, response) + } + } + + this.#requestStartedAt = undefined + } + + /** + * Call the terminate method on any terminable middleware. + * + * @param request + * @param response + */ + protected terminateMiddleware (request: IRequest, response: IResponse) { + const middlewares: IMiddleware[] | MiddlewareIdentifier[] = this.app.shouldSkipMiddleware() ? [] : [ + ...this.gatherRouteMiddleware(request), + ...this.middleware + ] + + //TODO: Handle both stringed and class middleware instances. + for (const middleware of middlewares) { + if (typeof middleware !== 'string') continue + + const [name] = this.parseMiddleware(middleware) + + const instance = this.app.make(name as never) + + if (instance['terminate']) { + instance.terminate(request, response) + } + } + } + + /** + * Register a callback to be invoked when the requests lifecycle duration exceeds a given amount of time. + * + * @param threshold + * @param handler + */ + public whenRequestLifecycleIsLongerThan (threshold: number | DateTime, handler: (...args: any[]) => any) { + //TODO: Pay attention to these + + // threshold = threshold instanceof DateTime + // ? this.secondsUntil(threshold) * 1000 + // : threshold + + // this.requestLifecycleDurationHandlers = { + // 'threshold': threshold, + // 'handler': handler, + // } + } + + /** + * When the request being handled started. + */ + public requestStartedAt () { + return this.#requestStartedAt + } + + /** + * Gather the route middleware for the given request. + */ + protected gatherRouteMiddleware (request: IRequest) { + // TODO: Pay attention to this + // const route = request.route() + // if (route) { + // return this.router.gatherRouteMiddleware(route) + // } + + return [] + } + + /** + * Parse a middleware string to get the name and parameters. + * + * @param middleware + */ + protected parseMiddleware (middleware: string): [string, string[]] { + const parts = middleware.split(':') + const name = parts[0] ?? '' + const parameters = parts[1] ? parts[1].split(',') : [] + + return [name, parameters] + } + + /** + * Determine if the kernel has a given middleware. + * + * @param middleware + */ + public hasMiddleware (middleware: IMiddleware) { + return this.middleware.includes(middleware) + } + + /** + * Add a new middleware to the beginning of the stack if it does not already exist. + * + * @param string middleware + */ + public prependMiddleware (middleware: IMiddleware) { + if (this.middleware.includes(middleware) === false) { + this.middleware = [middleware, ...this.middleware] + } + + return this + } + + /** + * Add a new middleware to end of the stack if it does not already exist. + * + * @param middleware + */ + public pushMiddleware (middleware: IMiddleware) { + if (this.middleware.includes(middleware) === false) { + this.middleware.push(middleware) + } + + return this + } + + /** + * Prepend the given middleware to the given middleware group. + * + * @param group + * @param middleware + * + * @throws {InvalidArgumentException} + */ + public prependMiddlewareToGroup (group: string, middleware: IMiddleware) { + if (!this.middlewareGroups[group]) { + throw new InvalidArgumentException('The [{$group}] middleware group has not been defined.') + } + + if (this.middlewareGroups[group].includes(middleware) === false) { + this.middlewareGroups[group] = [middleware, ...this.middlewareGroups[group]] + } + + this.syncMiddlewareToRouter() + + return this + } + + /** + * Append the given middleware to the given middleware group. + * + * @param group + * @param middleware + * + * @throws {InvalidArgumentException} + */ + public appendMiddlewareToGroup (group: string, middleware: IMiddleware) { + if (!this.middlewareGroups[group]) { + throw new InvalidArgumentException('The [{$group}] middleware group has not been defined.') + } + + if (!this.middlewareGroups[group].includes(middleware)) { + this.middlewareGroups[group].push(middleware) + } + + this.syncMiddlewareToRouter() + + return this + } + + /** + * Prepend the given middleware to the middleware priority list. + * + * @param middleware + */ + public prependToMiddlewarePriority (middleware: IMiddleware) { + if (!this.middlewarePriority.includes(middleware)) { + this.middlewarePriority = [middleware, ...this.middlewarePriority] + } + + this.syncMiddlewareToRouter() + + return this + } + + /** + * Append the given middleware to the middleware priority list. + * + * @param string $middleware + * @return $this + */ + public appendToMiddlewarePriority (middleware: IMiddleware) { + if (!this.middlewarePriority.includes(middleware)) { + this.middlewarePriority.push(middleware) + } + + this.syncMiddlewareToRouter() + + return this + } + + /** + * Add the given middleware to the middleware priority list before other middleware. + * + * @param before + * @param string $middleware + * @return $this + */ + public addToMiddlewarePriorityBefore (before: IMiddleware | IMiddleware[], middleware: IMiddleware) { + return this.addToMiddlewarePriorityRelative(before, middleware, false) + } + + /** + * Add the given middleware to the middleware priority list after other middleware. + * + * @param after + * @param middleware + */ + public addToMiddlewarePriorityAfter (after: IMiddleware | IMiddleware[], middleware: IMiddleware) { + return this.addToMiddlewarePriorityRelative(after, middleware) + } + + /** + * Add the given middleware to the middleware priority list relative to other middleware. + * + * @param string|array $existing + * @param string $middleware + * @param bool $after + * @return $this + */ + protected addToMiddlewarePriorityRelative (existing: IMiddleware | IMiddleware[], middleware: IMiddleware, after = true) { + if (!this.middlewarePriority.includes(middleware)) { + let index = after ? 0 : this.middlewarePriority.length + + for (const existingMiddleware of Arr.wrap(existing)) { + if (this.middlewarePriority.includes(existingMiddleware)) { + const middlewareIndex = this.middlewarePriority.indexOf(existingMiddleware) + + if (after && middlewareIndex > index) { + index = middlewareIndex + 1 + } else if (after === false && middlewareIndex < index) { + index = middlewareIndex + } + } + } + + if (index === 0 && after === false) { + this.middlewarePriority = [middleware, ...this.middlewarePriority] + } else if ((after && index === 0) || index === this.middlewarePriority.length) { + this.middlewarePriority.push(middleware) + } else { + this.middlewarePriority.splice(index, 0, middleware) + } + } + + this.syncMiddlewareToRouter() + + return this + } + + /** + * Sync the current state of the middleware to the router. + * + * @return void + */ + protected syncMiddlewareToRouter () { + // TODO: Pay Attention to these + // this.router.middlewarePriority = this.middlewarePriority + for (const [key, middleware] of Object.entries(this.middlewareGroups)) { + this.router.middlewareGroup(key, middleware) + } + + // for (const [key, middleware] of Object.entries(this.middlewareAliases)) { + // this.router.aliasMiddleware(key, middleware) + // } + } + + /** + * Get the priority-sorted list of middleware. + * + * @return array + */ + public getMiddlewarePriority () { + return this.middlewarePriority + } + + /** + * Get the bootstrap classes for the application. + * + * @return array + */ + protected bootstrappers () { + return this.#bootstrappers + } + + /** + * Report the exception to the exception handler. + * + * @param e + */ + protected reportException (e: Error) { + this.app.exceptionHandler?.report(e) + } + + /** + * Render the exception to a response. + * + * @param request + * @param e + */ + protected renderException (request: IRequest, e: Error) { + return this.app.exceptionHandler?.render(request, e) + } + + /** + * Get the application's global middleware. + * + * @return array + */ + public getGlobalMiddleware () { + return this.middleware + } + + /** + * Set the application's global middleware. + * + * @param middleware + * @returns + */ + public setGlobalMiddleware (middleware: MiddlewareList) { + this.middleware = middleware + + this.syncMiddlewareToRouter() + + return this + } + + /** + * Get the application's route middleware groups. + * + * @return array + */ + public getMiddlewareGroups () { + return this.middlewareGroups + } + + /** + * Set the application's middleware groups. + * + * @param groups + * @returns + */ + public setMiddlewareGroups (groups: Record) { + this.middlewareGroups = groups + this.syncMiddlewareToRouter() + + return this + } + + /** + * Get the application's route middleware aliases. + * + * @return array + */ + public getMiddlewareAliases () { + return this.middlewareAliases + } + + /** + * Set the application's route middleware aliases. + * + * @param aliases + */ + public setMiddlewareAliases (aliases: Record) { + this.middlewareAliases = aliases + + this.syncMiddlewareToRouter() + + return this + } + + /** + * Set the application's middleware priority. + * + * @param priority + */ + public setMiddlewarePriority (priority: MiddlewareList) { + this.middlewarePriority = priority + + this.syncMiddlewareToRouter() + + return this + } + + /** + * Get the Laravel application instance. + */ + public getApplication () { + return this.app + } + + /** + * Set the Laravel application instance. + * + * @param app + */ + public setApplication (app: IApplication) { + this.app = app + + return this + } +} \ No newline at end of file diff --git a/packages/foundation/src/Http/MiddlewareHandler.ts b/packages/foundation/src/Http/MiddlewareHandler.ts index 466f9d70..4c5bf62a 100644 --- a/packages/foundation/src/Http/MiddlewareHandler.ts +++ b/packages/foundation/src/Http/MiddlewareHandler.ts @@ -1,4 +1,4 @@ -import { HttpContext, IMiddleware } from '@h3ravel/shared' +import { IApplication, IHttpContext, IMiddleware, IMiddlewareHandler } from '@h3ravel/contracts' import { Arr } from '@h3ravel/support' @@ -6,8 +6,8 @@ import { Arr } from '@h3ravel/support' * Handles registration and execution of middleware. * Every middleware implements IMiddleware with a handle(context, next) method. */ -export class MiddlewareHandler { - constructor(private middleware: IMiddleware[] = []) { } +export class MiddlewareHandler implements IMiddlewareHandler { + constructor(private middleware: IMiddleware[] = [], private app: IApplication) { } /** * Registers a middleware instance. @@ -30,8 +30,8 @@ export class MiddlewareHandler { */ async run ( - context: HttpContext, - next: (ctx: HttpContext) => Promise + context: IHttpContext, + next: (ctx: IHttpContext) => Promise ) { let index = -1 const dispatch = async (i: number): Promise => { @@ -49,7 +49,10 @@ export class MiddlewareHandler { /** * Execute the current middleware and proceed to the next one */ - return current.handle(context, () => dispatch(i + 1)) + // const handler = this.app.make(current.handle) + // console.log(current, ) + return await this.app.invoke(current, 'handle', [context.request, () => dispatch(i + 1)]) + // return current.handle(context.request, () => dispatch(i + 1)) } return dispatch(0) diff --git a/packages/foundation/src/Testing/supertestAdapter.ts b/packages/foundation/src/Testing/supertestAdapter.ts new file mode 100644 index 00000000..95f4c443 --- /dev/null +++ b/packages/foundation/src/Testing/supertestAdapter.ts @@ -0,0 +1,44 @@ +import { Application, ServiceProvider, h3ravel } from '@h3ravel/core' +import type { IncomingMessage, ServerResponse } from 'node:http' + +import { str } from '@h3ravel/support' +import supertest from 'supertest' + +const makeEvent = (overides: Record = {}) => { + return { + res: { headers: new Headers(), statusCode: 200, ...(overides.res ?? {}) }, + req: { headers: new Headers(), url: overides.url ?? 'http://localhost/test', method: 'get', ...(overides.req ?? {}) }, + } as any +} + + +export async function supertestAdapter (app?: Application, serviceProviders: ServiceProvider[] = []) { + let providers: ServiceProvider[] = [] + + if (!app) { + const { EventsServiceProvider } = await import(('@h3ravel/events')) + const { HttpServiceProvider } = await import(('@h3ravel/http')) + const { RouteServiceProvider } = await import(('@h3ravel/router')) + + providers = [EventsServiceProvider, HttpServiceProvider, RouteServiceProvider, ...serviceProviders] + } + + const handler = async (req: IncomingMessage, res: ServerResponse) => { + + req.url = str(req.url).prepend('http://localhost').toString() + + const event = makeEvent({ req, res }) + + app ??= await h3ravel(providers as never, undefined, { h3Event: event, autoload: false }) + + const { response } = await app.context!(event) + + return response.getContent() + } + + return handler +} + +export const testApp = async (app?: Application, serviceProviders: ServiceProvider[] = []) => { + return supertest(await supertestAdapter(app, serviceProviders)) +} \ No newline at end of file diff --git a/packages/foundation/src/index.ts b/packages/foundation/src/index.ts index 1ce3cab0..fa6c0183 100644 --- a/packages/foundation/src/index.ts +++ b/packages/foundation/src/index.ts @@ -1,16 +1,14 @@ -export * from './Exceptions/HttpException' -export * from './Exceptions/HttpExceptionFactory' export * from './Adapters/InMemoryRateLimiter' +export * from './Configuration/AppBuilder' export * from './Configuration/Middleware' +export * from './Container/Inject' export * from './Contracts/MiddlewareContract' export * from './Contracts/RateLimiterAdapter' +export * from './Core/ServiceProvider' export * from './Exceptions/AccessDeniedHttpException' export * from './Exceptions/BadRequestHttpException' export * from './Exceptions/ConflictHttpException' -export * from './Exceptions/ExceptionHandler' -export * from './Exceptions/Exceptions' export * from './Exceptions/GoneHttpException' -export * from './Exceptions/Handler' export * from './Exceptions/LengthRequiredHttpException' export * from './Exceptions/LockedHttpException' export * from './Exceptions/NotAcceptableHttpException' @@ -21,5 +19,16 @@ export * from './Exceptions/ServiceUnavailableHttpException' export * from './Exceptions/TooManyRequestsHttpException' export * from './Exceptions/UnprocessableEntityHttpException' export * from './Exceptions/UnsupportedMediaTypeHttpException' +export * from './Http/Kernel' export * from './Http/MiddlewareHandler' -export * from './Http/RequestException' +export * from './Testing/supertestAdapter' +export * from './Exceptions/Base/ExceptionHandler' +export * from './Exceptions/Base/Exceptions' +export * from './Exceptions/Base/Handler' +export * from './Exceptions/Base/HttpException' +export * from './Exceptions/Base/HttpExceptionFactory' +export * from './Exceptions/Base/RequestException' +export * from './Exceptions/Core/BindingResolutionException' +export * from './Exceptions/Core/ConfigException' +export * from './Exceptions/Core/LogicException' +export * from './Http/Events/RequestHandled' diff --git a/packages/foundation/tsconfig.json b/packages/foundation/tsconfig.json index 1dcb8687..8bedfcfe 100644 --- a/packages/foundation/tsconfig.json +++ b/packages/foundation/tsconfig.json @@ -1,15 +1,7 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "target": "es2022", - "module": "es2022", - "moduleResolution": "bundler", - "esModuleInterop": true, - "strict": true, - "allowJs": true, - "skipLibCheck": true, - "resolveJsonModule": true + "outDir": "dist" }, - "exclude": ["./dist", "./**/dist", "./node_modules"] + "exclude": ["dist", "node_modules"] } diff --git a/packages/hashing/package.json b/packages/hashing/package.json index 0df62cb6..2c15d0e7 100644 --- a/packages/hashing/package.json +++ b/packages/hashing/package.json @@ -65,6 +65,7 @@ }, "peerDependencies": { "@h3ravel/core": "workspace:^", + "@h3ravel/foundation": "workspace:^", "@h3ravel/support": "workspace:^" }, "peerDependenciesMeta": { @@ -73,6 +74,7 @@ } }, "dependencies": { - "argon2": "catalog:" + "argon2": "catalog:", + "@h3ravel/foundation": "workspace:^" } -} +} \ No newline at end of file diff --git a/packages/hashing/src/Utils/Manager.ts b/packages/hashing/src/Utils/Manager.ts index e8b92563..e2cf358b 100644 --- a/packages/hashing/src/Utils/Manager.ts +++ b/packages/hashing/src/Utils/Manager.ts @@ -1,4 +1,4 @@ -import { InvalidArgumentException, type SnakeToTitleCase, Str } from '@h3ravel/support' +import { type SnakeToTitleCase, Str, InvalidArgumentException } from '@h3ravel/support' import type { Configuration, HashAlgorithm } from '../Contracts/ManagerContract' import { BcryptHasher } from '../Drivers/BcryptHasher' @@ -6,7 +6,7 @@ import { ArgonHasher } from '../Drivers/ArgonHasher' import { Argon2idHasher } from '../Drivers/Argon2idHasher' import path from 'node:path' import { existsSync } from 'node:fs' -import { ConfigException } from '@h3ravel/core' +import { ConfigException } from '@h3ravel/foundation' type CreateMethodName = `create${SnakeToTitleCase}Driver` diff --git a/packages/http/package.json b/packages/http/package.json index 9a1e3b66..d612ff7a 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -56,17 +56,17 @@ "version-patch": "pnpm version patch" }, "dependencies": { + "@h3ravel/contracts": "workspace:^", "@h3ravel/support": "workspace:^", "@h3ravel/musket": "catalog:prod", "@h3ravel/shared": "workspace:^", "@h3ravel/session": "workspace:^", - "@h3ravel/validation": "workspace:^", - "@h3ravel/url": "workspace:^", "h3": "catalog:prod", "srvx": "^0.8.2" }, "peerDependencies": { - "@h3ravel/core": "workspace:^" + "@h3ravel/validation": "workspace:^", + "@h3ravel/foundation": "workspace:^" }, "devDependencies": { "typescript": "^5.4.0" diff --git a/packages/http/src/HttpContext.ts b/packages/http/src/HttpContext.ts index f55c8d4c..7a28c7ab 100644 --- a/packages/http/src/HttpContext.ts +++ b/packages/http/src/HttpContext.ts @@ -1,4 +1,4 @@ -import { IApplication, type HttpContext as IHttpContext, IRequest, IResponse } from '@h3ravel/shared' +import { IApplication, IHttpContext, IRequest, IResponse } from '@h3ravel/contracts' import type { H3Event } from 'h3' /** diff --git a/packages/http/src/Middleware.ts b/packages/http/src/Middleware.ts index 6a522dd1..d3314c3d 100644 --- a/packages/http/src/Middleware.ts +++ b/packages/http/src/Middleware.ts @@ -1,8 +1,10 @@ -import { H3Event } from 'h3' -import { HttpContext } from './HttpContext' -import { IMiddleware } from '@h3ravel/shared' +import { IApplication, IMiddleware } from '@h3ravel/contracts' -export abstract class Middleware implements IMiddleware { - constructor(protected event?: H3Event) { } - abstract handle (context: HttpContext, next: () => Promise): Promise +import { Injectable } from '@h3ravel/foundation' + +@Injectable() +export abstract class Middleware extends IMiddleware { + constructor(protected app: IApplication) { + super() + } } diff --git a/packages/http/src/Middleware/FlashDataMiddleware.ts b/packages/http/src/Middleware/FlashDataMiddleware.ts index b7b83b0d..2f56cd4a 100644 --- a/packages/http/src/Middleware/FlashDataMiddleware.ts +++ b/packages/http/src/Middleware/FlashDataMiddleware.ts @@ -1,10 +1,11 @@ -import { HttpContext } from '../HttpContext' +import { Injectable } from '@h3ravel/foundation' import { Middleware } from '../Middleware' +import { Request } from '..' export class FlashDataMiddleware extends Middleware { - async handle ({ request }: HttpContext, next: () => Promise): Promise { - - const _next = await next() + @Injectable() + async handle (request: Request, next: (request: Request) => Promise): Promise { + const _next = await next(request) request.session().ageFlashData() diff --git a/packages/http/src/Middleware/LogRequests.ts b/packages/http/src/Middleware/LogRequests.ts index b91c80f4..65bb6c92 100644 --- a/packages/http/src/Middleware/LogRequests.ts +++ b/packages/http/src/Middleware/LogRequests.ts @@ -1,11 +1,13 @@ -import { HttpContext } from '../HttpContext' +import { IRequest, IResponse } from '@h3ravel/contracts' + +import { Injectable } from '@h3ravel/foundation' import { Logger } from '@h3ravel/shared' import { Middleware } from '../Middleware' export class LogRequests extends Middleware { - async handle ({ request, response }: HttpContext, next: () => Promise): Promise { - - const _next = await next() + @Injectable() + async handle (request: IRequest, response: IResponse, next: (request: IRequest) => Promise): Promise { + const _next = await next(request) const code = Number(response.getStatusCode()) const method = request.method().toLowerCase() diff --git a/packages/http/src/Middleware/TrustHosts.ts b/packages/http/src/Middleware/TrustHosts.ts new file mode 100644 index 00000000..b0fe11bf --- /dev/null +++ b/packages/http/src/Middleware/TrustHosts.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@h3ravel/foundation' +import { Middleware } from '../Middleware' +import { Request } from '..' + +export class TrustHosts extends Middleware { + /** + * The trusted hosts that have been configured to always be trusted. + */ + protected static alwaysTrust?: string[] | ((...arg: any[]) => string[]) + + /** + * Indicates whether subdomains of the application URL should be trusted. + */ + protected static subdomains?: boolean + + /** + * Get the host patterns that should be trusted. + */ + public hosts () { + if (!TrustHosts.alwaysTrust) { + return [this.allSubdomainsOfApplicationUrl()] + } + + let hosts: (string | undefined)[] + + switch (true) { + case Array.isArray(TrustHosts.alwaysTrust): + hosts = TrustHosts.alwaysTrust + break + + case typeof TrustHosts.alwaysTrust === 'function': + hosts = TrustHosts.alwaysTrust() + break + + default: + hosts = [] + break + } + + if (TrustHosts.subdomains) { + hosts.push(this.allSubdomainsOfApplicationUrl()) + } + + return hosts + } + + /** + * Handle the incoming request. + * + * @param request + * @param next + */ + @Injectable() + public async handle (request: Request, next: (request: Request) => Promise): Promise { + if (this.shouldSpecifyTrustedHosts()) { + Request.setTrustedHosts(this.hosts().filter(e => typeof e !== 'undefined')) + } + + return next(request) + } + + /** + * Specify the hosts that should always be trusted. + * + * @param hosts + * @param subdomains + */ + public static at (hosts: string[] | ((...arg: any[]) => string[]), subdomains = true): void { + TrustHosts.alwaysTrust = hosts + TrustHosts.subdomains = subdomains + } + + /** + * Determine if the application should specify trusted hosts. + * + * @return bool + */ + protected shouldSpecifyTrustedHosts () { + return !this.app.environment('local') && + !this.app.runningUnitTests() + } + + /** + * Get a regular expression matching the application URL and all of its subdomains. + */ + protected allSubdomainsOfApplicationUrl (): string | undefined { + const appUrl = this.app.make('config').get('app.url') + const host = new URL(appUrl).host + + + if (host) { + const escapedHost = host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return `^(.+\\.)?${escapedHost}$` + } + } + + /** + * Flush the state of the middleware. + * + * @return void + */ + public static flushState (): void { + TrustHosts.alwaysTrust = undefined + TrustHosts.subdomains = undefined + } +} \ No newline at end of file diff --git a/packages/http/src/Providers/HttpServiceProvider.ts b/packages/http/src/Providers/HttpServiceProvider.ts index 83b71b5e..0d6090a4 100644 --- a/packages/http/src/Providers/HttpServiceProvider.ts +++ b/packages/http/src/Providers/HttpServiceProvider.ts @@ -1,6 +1,8 @@ /// import { H3, serve } from 'h3' +import { HttpContext, Request, Response } from '..' +import { IApplication, IHttpContext, IRequest, IResponse } from '@h3ravel/contracts' import { FireCommand } from '../Commands/FireCommand' @@ -17,7 +19,7 @@ export class HttpServiceProvider { public static priority = 998 public registeredCommands?: (new (app: any, kernel: any) => any)[] - constructor(private app: any) { } + constructor(private app: IApplication) { } register () { /** Bind HTTP APP to the service container */ @@ -30,6 +32,15 @@ export class HttpServiceProvider { /** Register Musket Commands */ this.registeredCommands = [FireCommand] + + this.app.alias([ + [Request, 'http.request'], + [IRequest, 'http.request'], + [Response, 'http.response'], + [IResponse, 'http.response'], + [HttpContext, 'http.context'], + [IHttpContext, 'http.context'], + ]) } boot () { diff --git a/packages/http/src/Request.ts b/packages/http/src/Request.ts index 35e025d4..e91bd254 100644 --- a/packages/http/src/Request.ts +++ b/packages/http/src/Request.ts @@ -1,19 +1,17 @@ import { getRequestIP, type H3Event } from 'h3' import { Arr, data_get, data_set, Obj, safeDot, Str } from '@h3ravel/support' -import type { DotNestedKeys, DotNestedValue, ISessionManager } from '@h3ravel/shared' -import { IRequest } from '@h3ravel/shared' -import { Application } from '@h3ravel/core' -import { RequestMethod, RequestObject } from '@h3ravel/shared' +import type { DotNestedKeys, DotNestedValue, ISessionManager, IRequest, IRoute, RulesForData, MessagesForRules } from '@h3ravel/contracts' +import { IApplication } from '@h3ravel/contracts' +import { RequestMethod, RequestObject, IUrl } from '@h3ravel/contracts' import { InputBag } from './Utilities/InputBag' import { UploadedFile } from './UploadedFile' import { FormRequest } from './FormRequest' -import { Url } from '@h3ravel/url' import { HttpRequest } from './Utilities/HttpRequest' -import { MessagesForRules, RulesForData, Validator } from '@h3ravel/validation' export class Request< D extends Record = Record, - R extends RulesForData = RulesForData + R extends RulesForData = RulesForData, + U extends Record = Record > extends HttpRequest implements IRequest { /** * The decoded JSON content for the request. @@ -25,6 +23,16 @@ export class Request< */ protected convertedFiles?: Record + /** + * The route resolver callback. + */ + protected routeResolver?: () => IRoute + + /** + * The user resolver callback. + */ + protected userResolver?: (guard?: string) => U + constructor( /** * The current H3 H3Event instance @@ -33,7 +41,7 @@ export class Request< /** * The current app instance */ - app: Application + app: IApplication ) { if (Request.httpMethodParameterOverride) { HttpRequest.enableHttpMethodParameterOverride() @@ -52,13 +60,38 @@ export class Request< /** * The current app instance */ - app: Application + app: IApplication ) { const instance = new Request(event, app) await instance.setBody() await instance.initialize() + globalThis.old = (...args: any[]) => instance.old(args?.[0], args?.[1]) as never + globalThis.request = () => instance + globalThis.session = (...args: any[]) => instance.session(...args) + return instance + } + + /** + * Factory method to create a syncronous Request instance from an H3Event. + */ + static createSync ( + /** + * The current H3 H3Event instance + */ + event: H3Event, + /** + * The current app instance + */ + app: IApplication + ) { + const instance = new Request(event, app) + instance.content = event.req.body + instance.body = instance.content + instance.buildRequirements() + instance.sessionManagerClass = {} as never + globalThis.old = (...args: any[]) => instance.old(args?.[0], args?.[1]) as never globalThis.request = () => instance - globalThis.session = (...args: []) => instance.session(...args) + globalThis.session = (...args: any[]) => instance.session(...args) return instance } @@ -116,6 +149,8 @@ export class Request< rules: R, messages: Partial, string>> = {} ): Promise { + const { Validator } = await import('@h3ravel/validation') + const validator = new Validator(this.all(), rules, messages) return await validator.validate() as D @@ -170,13 +205,11 @@ export class Request< * @param expectArray set to true to return an `UploadedFile[]` array. * @returns */ - public file ( - key?: K, - defaultValue?: any, - expectArray?: E - ): K extends undefined - ? Record - : E extends true ? UploadedFile[] : UploadedFile { + public file (): Record; + public file (key?: undefined, defaultValue?: any, expectArray?: true): Record; + public file (key: string, defaultValue?: any, expectArray?: false | undefined): UploadedFile; + public file (key: string, defaultValue?: any, expectArray?: true): UploadedFile[]; + public file (key?: K, defaultValue?: any, expectArray?: E) { const files = data_get(this.allFiles(), key!, defaultValue) if (!files) return defaultValue @@ -191,6 +224,33 @@ export class Request< return files as any } + /** + * Get the user making the request. + * + * @param guard + */ + public user (guard?: string): U | undefined { + return Reflect.apply(this.getUserResolver(), this, [guard]) + } + + /** + * Get the route handling the request. + * + * @param param + * @param defaultRoute + */ + public route (): IRoute + public route (param?: string, defaultParam?: any): any + public route (param?: string, defaultParam?: any) { + const route = Reflect.apply(this.getRouteResolver(), this, []) + + if (typeof route === 'undefined' || !param) { + return route + } + + return route.parameter(param, defaultParam) + } + /** * Determine if the uploaded data contains a file. * @@ -261,6 +321,17 @@ export class Request< return files } + /** + * Get the current decoded path info for the request. + */ + public decodedPath () { + try { + return decodeURIComponent(this.path()) + } catch { + return this.path() + } + } + /** * Determine if the data contains a given key. * @@ -292,6 +363,13 @@ export class Request< return Object.fromEntries(data) as T } + /** + * Determine if the request is over HTTPS. + */ + public secure () { + return this.isSecure() + } + /** * Get all of the data except for a specified array of items. * @@ -374,6 +452,27 @@ export class Request< return this.sessionManager as any } + /** + * Get the host name. + */ + public host () { + return this.getHost() + } + + /** + * Get the HTTP host being requested. + */ + public httpHost () { + return this.getHttpHost() + } + + /** + * Get the scheme and HTTP host. + */ + public schemeAndHttpHost () { + return this.getSchemeAndHttpHost() + } + /** * Determine if the request is sending JSON. * @@ -430,11 +529,48 @@ export class Request< return getRequestIP(this.event) } + /** + * Get the flashed input from previous request + * + * @param key + * @param defaultValue + * @returns + */ + public async old (): Promise> + public async old (key: string, defaultValue?: any): Promise + public async old (key?: string, defaultValue?: any): Promise { + const payload = await this.session().get('_old', {}) + + if (key) return safeDot(payload, key) || defaultValue + return payload + // new MessageBag(instance.errors().all()) + } + /** * Get a URI instance for the request. */ - public uri (): Url { - return this.getUriInstance() + public uri (): IUrl { + const Url = Reflect.apply(this.app.getUriResolver(), this, [])! + + return Url.of(this.fullUrl(), this.app) + } + + /** + * Get the root URL for the application. + * + * @return string + */ + public root () { + return Str.rtrim(this.getSchemeAndHttpHost() + this.getBaseUrl(), '/') + } + + /** + * Get the URL (no query string) for the request. + * + * @return string + */ + public url () { + return Str.rtrim(this.uri().toString().replace(/\?.*/, ''), '/') } /** @@ -444,6 +580,14 @@ export class Request< return this.event.req.url } + /** + * Get the current path info for the request. + */ + public path (): string { + const pattern = (this.getPathInfo() ?? '').replace(/^\/+|\/+$/g, '') + return pattern === '' ? '/' : pattern + } + /** * Return the Request instance. */ @@ -485,6 +629,42 @@ export class Request< return Obj.get(this.#json.all(), key, defaultValue) } + /** + * Get the user resolver callback. + */ + public getUserResolver (): ((gaurd?: string) => U | undefined) { + return this.userResolver ?? (() => undefined) + } + + /** + * Set the user resolver callback. + * + * @param callback + */ + public setUserResolver (callback: (gaurd?: string) => U) { + this.userResolver = callback + + return this + } + + /** + * Get the route resolver callback. + */ + public getRouteResolver (): () => IRoute | undefined { + return this.routeResolver ?? (() => undefined) + } + + /** + * Set the route resolver callback. + * + * @param callback + */ + public setRouteResolver (callback: () => IRoute) { + this.routeResolver = callback + + return this + } + /** * Get the input source for the request. * diff --git a/packages/http/src/Response.ts b/packages/http/src/Response.ts index 9c388b98..106f9271 100644 --- a/packages/http/src/Response.ts +++ b/packages/http/src/Response.ts @@ -1,28 +1,44 @@ -import type { DotNestedKeys, DotNestedValue, HttpContext } from '@h3ravel/shared' -import { type H3Event, HTTPResponse } from 'h3' +import type { DotNestedKeys, DotNestedValue, IHttpContext, IResponse } from '@h3ravel/contracts' +import { Str, safeDot } from '@h3ravel/support' -import { Application } from '@h3ravel/core' +import { H3Event } from 'h3' import { HttpResponse } from './Utilities/HttpResponse' -import { IResponse } from '@h3ravel/shared' -import { safeDot } from '@h3ravel/support' +import { IApplication } from '@h3ravel/contracts' +import { Responsable } from './Utilities/Responsable' +import { ResponseCodes } from './Utilities/ResponseUtilities' export class Response extends HttpResponse implements IResponse { + static codes = ResponseCodes + /** * The current Http Context */ - context!: HttpContext - - constructor( - /** - * The current H3 H3Event instance - */ - event: H3Event, - /** - * The current app instance - */ - public app: Application - ) { + context!: IHttpContext + + /** + * + * @param app The current app instance + * @param content The current H3 H3Event instance + * @param status The http status code + * @param headers The http headers + */ + constructor(app: IApplication, content: H3Event) + constructor(app: IApplication, content: string, status?: ResponseCodes, headers?: Record) + constructor(public app: IApplication, event?: H3Event | string, status: ResponseCodes = 200, headers: Record = {}) { + const hasHeaders = Object.entries(headers).length > 0 + const content = !(event instanceof H3Event) ? event : '' + event = event instanceof H3Event ? event : app.make('http.context')?.event + super(event) + + if (content || status !== 200 || hasHeaders) { + this.setContent(content) + .setStatusCode(status) + + if (hasHeaders) + this.withHeaders(headers) + } + globalThis.response = () => this } @@ -31,7 +47,7 @@ export class Response extends HttpResponse implements IResponse { */ public sendContent (type?: 'html' | 'json' | 'text' | 'xml', parse?: boolean) { if (!type) { - return this.text(this.content, parse!) + type = Str.detectContentType(this.content) } return this[type].call(this, this.content, parse!) @@ -52,9 +68,10 @@ export class Response extends HttpResponse implements IResponse { * @returns */ async view (viewPath: string, data?: Record | undefined): Promise - async view (viewPath: string, data: Record | undefined, parse: boolean): Promise - async view (viewPath: string, data?: Record | undefined, parse?: boolean): Promise { - return this.html(await this.app.make('edge').render(viewPath, data), parse!) as never + async view (viewPath: string, data: Record | undefined, parse: boolean): Promise + async view (viewPath: string, data?: Record | undefined, parse?: boolean): Promise { + const base = this.html(await this.app.make('edge').render(viewPath, data), parse!) + return new Responsable(base.body!, base) } /** @@ -66,9 +83,10 @@ export class Response extends HttpResponse implements IResponse { * @returns */ async viewTemplate (content: string, data?: Record | undefined): Promise - async viewTemplate (content: string, data: Record | undefined, parse: boolean): Promise - async viewTemplate (content: string, data?: Record | undefined, parse?: boolean): Promise { - return this.html(await this.app.make('edge').renderRaw(content, data), parse!) as never + async viewTemplate (content: string, data: Record | undefined, parse: boolean): Promise + async viewTemplate (content: string, data?: Record | undefined, parse?: boolean): Promise { + const base = this.html(await this.app.make('edge').renderRaw(content, data), parse!) + return new Responsable(base.body!, base) } /** @@ -78,9 +96,10 @@ export class Response extends HttpResponse implements IResponse { * @returns */ html (content?: string): this - html (content: string, parse: boolean): HTTPResponse - html (content?: string, parse?: boolean): HTTPResponse | this { - return this.httpResponse('text/html', content ?? this.content, parse!) as never + html (content: string, parse: boolean): Responsable + html (content?: string, parse?: boolean): Responsable | this { + const base = this.httpResponse('text/html', content ?? this.content, parse!) + return new Responsable(base.body!, base) } /** @@ -88,7 +107,7 @@ export class Response extends HttpResponse implements IResponse { */ json (data?: T): this json (data: T, parse: boolean): T - json (data?: T, parse?: boolean): HTTPResponse | this { + json (data?: T, parse?: boolean): Responsable | this { const content = data ?? this.content return this.httpResponse( 'application/json', @@ -101,8 +120,8 @@ export class Response extends HttpResponse implements IResponse { * Send plain text. */ text (content?: string): this - text (content: string, parse: boolean): HTTPResponse - text (content?: string, parse?: boolean): HTTPResponse | this { + text (content: string, parse: boolean): Responsable + text (content?: string, parse?: boolean): Responsable | this { return this.httpResponse('text/plain', content ?? this.content, parse!) as never } @@ -110,7 +129,7 @@ export class Response extends HttpResponse implements IResponse { * Send plain xml. */ xml (data?: string): this - xml (data: string, parse: boolean): HTTPResponse + xml (data: string, parse: boolean): Responsable xml (data?: string, parse?: boolean) { return this.httpResponse('application/xml', data ?? this.content, parse!) as never } @@ -122,11 +141,11 @@ export class Response extends HttpResponse implements IResponse { * @param data */ private httpResponse (contentType: string, data?: string): this - private httpResponse (contentType: string, data: string, parse: boolean): HTTPResponse + private httpResponse (contentType: string, data: string, parse: boolean): Responsable private httpResponse (contentType: string, data?: string, parse?: boolean) { if (parse) { this.sendHeaders() - return new HTTPResponse( + return new Responsable( data ?? this.content, { status: this.statusCode, statusText: this.statusText, diff --git a/packages/http/src/Utilities/HeaderBag.ts b/packages/http/src/Utilities/HeaderBag.ts index d1c0ae8d..77aa0d43 100644 --- a/packages/http/src/Utilities/HeaderBag.ts +++ b/packages/http/src/Utilities/HeaderBag.ts @@ -1,10 +1,12 @@ import { DateTime, RuntimeException } from '@h3ravel/support' +import { IHeaderBag } from '@h3ravel/contracts' + /** * HeaderBag — A container for HTTP headers - * for Node/H3 environments. + * for H3ravel App. */ -export class HeaderBag implements Iterable<[string, (string | null)[]]> { +export class HeaderBag extends IHeaderBag { protected static readonly UPPER = '_ABCDEFGHIJKLMNOPQRSTUVWXYZ' protected static readonly LOWER = '-abcdefghijklmnopqrstuvwxyz' @@ -13,8 +15,12 @@ export class HeaderBag implements Iterable<[string, (string | null)[]]> { protected cacheControl: Record = {} constructor(headers: Record = {}) { + super() for (const [key, values] of Object.entries(headers)) { this.set(key, values) + if (key.startsWith('HTTP_')) { + this.set(key.slice(5), values) + } } } @@ -97,8 +103,8 @@ export class HeaderBag implements Iterable<[string, (string | null)[]]> { */ public get ( key: string, - defaultValue: string | null = null - ): R extends undefined ? string | null : R { + defaultValue: string | null | undefined = null + ): R extends undefined ? string | null | undefined : R { const headers = this.all(key) || this.all('http-' + key) if (!headers.length) return defaultValue as R extends undefined ? string | null : R return headers[0] as R extends undefined ? string | null : R diff --git a/packages/http/src/Utilities/HttpRequest.ts b/packages/http/src/Utilities/HttpRequest.ts index 9fc915d8..ec5261fc 100644 --- a/packages/http/src/Utilities/HttpRequest.ts +++ b/packages/http/src/Utilities/HttpRequest.ts @@ -1,6 +1,5 @@ -import { getQuery, getRequestURL, getRouterParams, parseCookies, type H3Event } from 'h3' -import { Application } from '@h3ravel/core' -import { ISessionManager, RequestMethod } from '@h3ravel/shared' +import { getQuery, getRouterParams, parseCookies, type H3Event } from 'h3' +import { IApplication } from '@h3ravel/contracts' import { SuspiciousOperationException } from '../Exceptions/SuspiciousOperationException' import { InputBag } from '../Utilities/InputBag' import { HeaderBag } from '../Utilities/HeaderBag' @@ -8,34 +7,34 @@ import { ParamBag } from '../Utilities/ParamBag' import { FileBag } from '../Utilities/FileBag' import { ServerBag } from '../Utilities/ServerBag' import { FormRequest } from '../FormRequest' -import { Url } from '@h3ravel/url' import { HeaderUtility } from './HeaderUtility' import { IpUtils } from './IpUtils' import { ConflictingHeadersException } from '../Exceptions/ConflictingHeadersException' -import { isIP } from 'node:net' -import { HttpContext } from '../HttpContext' +import { Str } from '@h3ravel/support' +import path from 'node:path' +import { IHttpContext, IUrl, ISessionManager, RequestMethod } from '@h3ravel/contracts' export class HttpRequest { - public HEADER_FORWARDED = 0b000001 // When using RFC 7239 - public HEADER_X_FORWARDED_FOR = 0b000010 - public HEADER_X_FORWARDED_HOST = 0b000100 - public HEADER_X_FORWARDED_PROTO = 0b001000 - public HEADER_X_FORWARDED_PORT = 0b010000 - public HEADER_X_FORWARDED_PREFIX = 0b100000 - - public HEADER_X_FORWARDED_AWS_ELB = 0b0011010 // AWS ELB doesn't send X-Forwarded-Host - public HEADER_X_FORWARDED_TRAEFIK = 0b0111110 // All "X-Forwarded-*" headers sent by Traefik reverse proxy - - public METHOD_HEAD = 'HEAD' - public METHOD_GET = 'GET' - public METHOD_POST = 'POST' - public METHOD_PUT = 'PUT' - public METHOD_PATCH = 'PATCH' - public METHOD_DELETE = 'DELETE' - public METHOD_PURGE = 'PURGE' - public METHOD_OPTIONS = 'OPTIONS' - public METHOD_TRACE = 'TRACE' - public METHOD_CONNECT = 'CONNECT' + public static HEADER_FORWARDED = 0b000001 // When using RFC 7239 + public static HEADER_X_FORWARDED_FOR = 0b000010 + public static HEADER_X_FORWARDED_HOST = 0b000100 + public static HEADER_X_FORWARDED_PROTO = 0b001000 + public static HEADER_X_FORWARDED_PORT = 0b010000 + public static HEADER_X_FORWARDED_PREFIX = 0b100000 + + public static HEADER_X_FORWARDED_AWS_ELB = 0b0011010 // AWS ELB doesn't send X-Forwarded-Host + public static HEADER_X_FORWARDED_TRAEFIK = 0b0111110 // All "X-Forwarded-*" headers sent by Traefik reverse proxy + + public static METHOD_HEAD = 'HEAD' + public static METHOD_GET = 'GET' + public static METHOD_POST = 'POST' + public static METHOD_PUT = 'PUT' + public static METHOD_PATCH = 'PATCH' + public static METHOD_DELETE = 'DELETE' + public static METHOD_PURGE = 'PURGE' + public static METHOD_OPTIONS = 'OPTIONS' + public static METHOD_TRACE = 'TRACE' + public static METHOD_CONNECT = 'CONNECT' /** * Names for headers that can be trusted when @@ -47,22 +46,22 @@ export class HttpRequest { * by popular reverse proxies (like Apache mod_proxy or Amazon EC2). */ private TRUSTED_HEADERS = { - [this.HEADER_FORWARDED]: 'FORWARDED', - [this.HEADER_X_FORWARDED_FOR]: 'X_FORWARDED_FOR', - [this.HEADER_X_FORWARDED_HOST]: 'X_FORWARDED_HOST', - [this.HEADER_X_FORWARDED_PROTO]: 'X_FORWARDED_PROTO', - [this.HEADER_X_FORWARDED_PORT]: 'X_FORWARDED_PORT', - [this.HEADER_X_FORWARDED_PREFIX]: 'X_FORWARDED_PREFIX', + [HttpRequest.HEADER_FORWARDED]: 'FORWARDED', + [HttpRequest.HEADER_X_FORWARDED_FOR]: 'X_FORWARDED_FOR', + [HttpRequest.HEADER_X_FORWARDED_HOST]: 'X_FORWARDED_HOST', + [HttpRequest.HEADER_X_FORWARDED_PROTO]: 'X_FORWARDED_PROTO', + [HttpRequest.HEADER_X_FORWARDED_PORT]: 'X_FORWARDED_PORT', + [HttpRequest.HEADER_X_FORWARDED_PREFIX]: 'X_FORWARDED_PREFIX', } private FORWARDED_PARAMS = { - [this.HEADER_X_FORWARDED_FOR]: 'for', - [this.HEADER_X_FORWARDED_HOST]: 'host', - [this.HEADER_X_FORWARDED_PROTO]: 'proto', - [this.HEADER_X_FORWARDED_PORT]: 'host', + [HttpRequest.HEADER_X_FORWARDED_FOR]: 'for', + [HttpRequest.HEADER_X_FORWARDED_HOST]: 'host', + [HttpRequest.HEADER_X_FORWARDED_PROTO]: 'proto', + [HttpRequest.HEADER_X_FORWARDED_PORT]: 'host', } - #uri!: Url + #uri!: IUrl /** * Parsed request body @@ -71,14 +70,28 @@ export class HttpRequest { #method?: RequestMethod = undefined + #isHostValid: boolean = true + + #isIisRewrite: boolean = false + protected format?: string + protected basePath?: string + + protected baseUrl?: string + + protected requestUri?: string + + protected pathInfo?: string + protected formData!: FormRequest private preferredFormat?: string private isForwardedValid: boolean = true + public static trustedHosts: string[] = [] private static trustedHeaderSet: number = -1 + protected static trustedHostPatterns: RegExp[] = [] /** * Gets route parameters. @@ -116,7 +129,7 @@ export class HttpRequest { /** * The current Http Context */ - context!: HttpContext + context!: IHttpContext /** * The request attributes (parameters parsed from the PATH_INFO, ...). @@ -155,7 +168,7 @@ export class HttpRequest { /** * The current app instance */ - public app: Application + public app: IApplication ) { } /** @@ -170,6 +183,11 @@ export class HttpRequest { * @param content The raw body data */ public async initialize (): Promise { + this.buildRequirements() + this.sessionManagerClass = (await import(('@h3ravel/session'))).SessionManager + } + + protected buildRequirements () { this.params = getRouterParams(this.event) this.request = new InputBag(this.formData ? this.formData.input() : {}, this.event) this.query = new InputBag(getQuery(this.event), this.event) @@ -182,15 +200,13 @@ export class HttpRequest { // this.languages = undefined // this.charsets = undefined // this.encodings = undefined - // this.pathInfo = undefined - // this.requestUri = undefined - // this.baseUrl = undefined - // this.basePath = undefined + this.pathInfo = undefined + this.requestUri = undefined + this.baseUrl = undefined + this.basePath = undefined this.#method = undefined this.format = undefined - this.#uri = (await import(String('@h3ravel/url'))).Url.of(getRequestURL(this.event).toString(), this.app) - - this.sessionManagerClass = (await import(('@h3ravel/session'))).SessionManager + // this.#uri = Url.of(getRequestURL(this.event).toString(), this.app) } /** @@ -218,10 +234,309 @@ export class HttpRequest { /** * Get a URI instance for the request. */ - public getUriInstance (): Url { + public getUriInstance (): IUrl { return this.#uri } + /** + * Returns the requested URI (path and query string). + * + * @return {string} The raw URI (i.e. not URI decoded) + */ + public getRequestUri (): string { + return this.requestUri ??= this.prepareRequestUri() + } + + /** + * Gets the scheme and HTTP host. + * + * If the URL was called with basic authentication, the user + * and the password are not added to the generated string. + */ + public getSchemeAndHttpHost (): string { + return this.getScheme() + '://' + this.getHttpHost() + } + + /** + * Returns the HTTP host being requested. + * + * The port name will be appended to the host if it's non-standard. + */ + public getHttpHost (): string { + const scheme = this.getScheme() + const port = this.getPort() + + if (('http' === scheme && 80 == port) || ('https' === scheme && 443 == port)) { + return this.getHost() + } + + return this.getHost() + ':' + port + } + + /** + * Returns the root path from which this request is executed. + * + * @returns {string} The raw path (i.e. not urldecoded) + */ + public getBasePath (): string { + return this.basePath ??= this.prepareBasePath() + } + + /** + * Returns the root URL from which this request is executed. + * + * The base URL never ends with a /. + * + * This is similar to getBasePath(), except that it also includes the + * script filename (e.g. index.php) if one exists. + * + * @return string The raw URL (i.e. not urldecoded) + */ + public getBaseUrl (): string { + let trustedPrefix = '' + let trustedPrefixValues: string[] + + // the proxy prefix must be prepended to any prefix being needed at the webserver level + if (this.isFromTrustedProxy() && (trustedPrefixValues = this.getTrustedValues(HttpRequest.HEADER_X_FORWARDED_PREFIX))) { + trustedPrefix = Str.rtrim(trustedPrefixValues[0], '/') + } + + return trustedPrefix + this.getBaseUrlReal() + } + + /** + * Returns the real base URL received by the webserver from which this request is executed. + * The URL does not include trusted reverse proxy prefix. + * + * @return string The raw URL (i.e. not urldecoded) + */ + private getBaseUrlReal (): string { + return this.baseUrl ??= this.prepareBaseUrl() + } + + /** + * Gets the request's scheme. + */ + public getScheme (): string { + return this.isSecure() ? 'https' : 'http' + } + + /** + * Prepares the base URL. + */ + protected prepareBaseUrl (): string { + const requestUri = this.getRequestUri() ?? '' + const scriptName = path.basename(__filename) // current script filename + const baseUrl = '/' + scriptName + + // ensure requestUri starts with / + const normalizedRequestUri = requestUri.startsWith('/') ? requestUri : '/' + requestUri + + // check if full baseUrl matches start of requestUri + if (normalizedRequestUri.startsWith(baseUrl)) { + return baseUrl + } + + // fallback: use directory of script + const dirBase = path.dirname(baseUrl) + if (normalizedRequestUri.startsWith(dirBase)) { + return dirBase.replace(/[/\\]+$/, '') + } + + // nothing matches, return empty + return '' + } + + /** + * Prepares the Request URI. + */ + protected prepareRequestUri (): string { + let requestUri = '' + // console.log(this.server.all()) + // IIS-style URL rewrite could be behind a header like x-original-url + const unencodedUrl = this.server.get('x-original-url') ?? '' + if (this.isIisRewrite() && unencodedUrl) { + requestUri = unencodedUrl + this.server.remove('x-original-url') + } else if (this.server.has('REQUEST_URI')) { + requestUri = this.server.get('REQUEST_URI') ?? '' + + if (requestUri && requestUri[0] === '/') { + // Remove fragment + const hashPos = requestUri.indexOf('#') + if (hashPos !== -1) { + requestUri = requestUri.substring(0, hashPos) + } + } else { + // Could be full URL from proxy, parse path + query + try { + const urlObj = new URL(requestUri) + requestUri = urlObj.pathname + if (urlObj.search) { + requestUri += urlObj.search + } + } catch { + // fallback if invalid URL, keep as-is + } + } + } else { + // fallback: just use request path + requestUri = this.getRequestUri() ?? '/' + } + + // normalize the request URI for future use + this.server.set('REQUEST_URI', requestUri) + + return requestUri + } + + /** + * Prepares the base path. + */ + protected prepareBasePath (): string { + const baseUrl = this.getBaseUrl() + if (!baseUrl) { + return '' + } + + const scriptFilename = this.server.get('SCRIPT_FILENAME') ?? '' + const filename = path.basename(scriptFilename) + + let basePath: string + if (path.basename(baseUrl) === filename) { + basePath = path.dirname(baseUrl) + } else { + basePath = baseUrl + } + + // normalize Windows paths to forward slashes + basePath = basePath.replace(/\\/g, '/') + + // remove trailing slash + return basePath.replace(/\/+$/, '') + } + + /** + * Prepares the path info. + */ + protected preparePathInfo (): string { + let requestUri = this.getRequestUri() + if (!requestUri) return '/' + + // Remove the query string + const qPos = requestUri.indexOf('?') + if (qPos !== -1) { + requestUri = requestUri.substring(0, qPos) + } + + // Ensure it starts with / + if (requestUri && requestUri[0] !== '/') { + requestUri = '/' + requestUri + } + + const baseUrl = this.getBaseUrlReal() + if (baseUrl == null) { + return requestUri + } + + // Remove the base URL prefix + let pathInfo = requestUri.substring(baseUrl.length) + + // Ensure pathInfo starts with / + if (!pathInfo || pathInfo[0] !== '/') { + pathInfo = '/' + pathInfo + } + + return pathInfo + } + + + /** + * Returns the port on which the request is made. + * + * This method can read the client port from the "X-Forwarded-Port" header + * when trusted proxies were set via "setTrustedProxies()". + * + * The "X-Forwarded-Port" header must contain the client port. + * + * @return int|string|null Can be a string if fetched from the server bag + */ + public getPort (): number | string | undefined { + let pos: number + let host: string | string[] | undefined | null + + if (this.isFromTrustedProxy() && (host = this.getTrustedValues(HttpRequest.HEADER_X_FORWARDED_PORT))) { + host = host[0] + } else if (this.isFromTrustedProxy() && (host = this.getTrustedValues(HttpRequest.HEADER_X_FORWARDED_HOST))) { + host = host[0] + } else if (!(host = this.headers.get('HOST'))) { + return this.server.get('SERVER_PORT') + } + + if (host[0] === '[') { + pos = host.lastIndexOf(':', host.lastIndexOf(']')) + } else { + pos = host.lastIndexOf(':') + } + + if (pos !== -1) { + const portStr = typeof host === 'string' ? host.substring(pos + 1) : host.at(0)?.substring(pos + 1) + if (portStr) { + return parseInt(portStr, 10) + } + } + + return 'https' === this.getScheme() ? 443 : 80 + } + + public getHost (): string { + let host: string | undefined | null + + if (this.isFromTrustedProxy() && (host = this.getTrustedValues(HttpRequest.HEADER_X_FORWARDED_HOST)?.[0])) { + // do nothing, host already assigned + } else if (!(host = this.headers.get('HOST'))) { + host = this.server.get('SERVER_NAME') ?? this.server.get('SERVER_ADDR') ?? process.env.SERVER_NAME ?? '' + } + + /* trim and remove port number, lowercase */ + host = (host ?? '').trim().replace(/:\d+$/, '').toLowerCase() + + /* validate host */ + if (host && !HttpRequest.isHostValid(host)) { + if (!this.#isHostValid) { + return '' + } + this.#isHostValid = false + throw new SuspiciousOperationException(`Invalid Host "${host}".`) + } + + /* trusted host patterns */ + const ctor = this.constructor as typeof HttpRequest + + if (ctor.trustedHostPatterns.length > 0) { + if (ctor.trustedHosts.includes(host)) { + return host + } + + for (const pattern of ctor.trustedHostPatterns) { + if (pattern.test(host)) { + ctor.trustedHosts.push(host) + return host + } + } + + if (!this.#isHostValid) { + return '' + } + + this.#isHostValid = false + throw new SuspiciousOperationException(`Untrusted Host "${host}".`) + } + + return host + } + + /** * Checks whether the request is secure or not. * @@ -231,7 +546,7 @@ export class HttpRequest { * The "X-Forwarded-Proto" header must contain the protocol: "https" or "http". */ public isSecure (): boolean { - const proto = this.getTrustedValues(this.HEADER_X_FORWARDED_PROTO) + const proto = this.getTrustedValues(HttpRequest.HEADER_X_FORWARDED_PROTO) if (this.isFromTrustedProxy() && proto) { return ['https', 'on', 'ssl', '1'].includes(proto[0]?.toLowerCase()) @@ -243,6 +558,24 @@ export class HttpRequest { } + /** + * Is this IIS with UrlRewriteModule? + * + * This method consumes, caches and removed the IIS_WasUrlRewritten env var, + * so we don't inherit it to sub-requests. + */ + private isIisRewrite (): boolean { + try { + if (1 === this.server.getInt('IIS_WasUrlRewritten')) { + this.#isIisRewrite = true + this.server.remove('IIS_WasUrlRewritten') + } + } catch { /** */ } + + return this.#isIisRewrite + } + + /** * Returns the value of the requested header. */ @@ -292,6 +625,35 @@ export class HttpRequest { return 'XMLHttpRequest' === this.getHeader('X-Requested-With') } + /** + * See https://url.spec.whatwg.org/. + */ + private static isHostValid (host: string): boolean { + /** + * Validate IPv6: [::1] or similar + */ + if (host[0] === '[') { + const last = host[host.length - 1] + if (last === ']') { + const inside = host.substring(1, host.length - 1) + return Str.validateIp(inside, 'ipv6') + } + return false + } + + /** + * Validate IPv4: ends with .123 or .123. + */ + if (/\.[0-9]+\.?$/.test(host)) { + return Str.validateIp(host, 'ipv4') + } + + /** + * fallback: remove valid chars and check if anything remains + */ + return '' === host.replace(/[-a-zA-Z0-9_]+\.?/g, '') + } + /** * Initializes HTTP request formats. */ @@ -570,7 +932,7 @@ export class HttpRequest { /** * This method is rather heavy because it splits and merges headers, and it's called by many other methods such as - * getPort(), isSecure(), getHost(), getClientIps(), getBaseUrl() etc. Thus, we try to cache the results for + * getPort(), isSecure(), getHost(), getClientIps(), this.() etc. Thus, we try to cache the results for * best performance. */ private getTrustedValues (type: number, ip?: string | null): string[] { @@ -580,7 +942,7 @@ export class HttpRequest { const cacheKey = type + '\0' + ((trustedHeaderSet & type) ? this.headers.get(trustedHeaders[type]) ?? '' : '') + - '\0' + (ip ?? '') + '\0' + (this.headers.get(trustedHeaders[this.HEADER_FORWARDED]) ?? '') + '\0' + (ip ?? '') + '\0' + (this.headers.get(trustedHeaders[HttpRequest.HEADER_FORWARDED]) ?? '') if (this.trustedValuesCache[cacheKey]) { return this.trustedValuesCache[cacheKey] @@ -593,18 +955,18 @@ export class HttpRequest { if ((trustedHeaderSet & type) && this.headers.has(trustedHeaders[type])) { const headerValue = this.headers.get(trustedHeaders[type])! for (const v of headerValue.split(',')) { - const value = (type === this.HEADER_X_FORWARDED_PORT ? '0.0.0.0:' : '') + v.trim() + const value = (type === HttpRequest.HEADER_X_FORWARDED_PORT ? '0.0.0.0:' : '') + v.trim() clientValues.push(value) } } // Handle Forwarded header (RFC 7239) if ( - (trustedHeaderSet & this.HEADER_FORWARDED) && + (trustedHeaderSet & HttpRequest.HEADER_FORWARDED) && this.FORWARDED_PARAMS[type] && - this.headers.has(trustedHeaders[this.HEADER_FORWARDED]) + this.headers.has(trustedHeaders[HttpRequest.HEADER_FORWARDED]) ) { - const forwarded = this.headers.get(trustedHeaders[this.HEADER_FORWARDED])! + const forwarded = this.headers.get(trustedHeaders[HttpRequest.HEADER_FORWARDED])! const parts = HeaderUtility.split(forwarded, ',;=') const param = this.FORWARDED_PARAMS[type] @@ -616,7 +978,7 @@ export class HttpRequest { } if (v == null) continue - if (type === this.HEADER_X_FORWARDED_PORT) { + if (type === HttpRequest.HEADER_X_FORWARDED_PORT) { if (v.endsWith(']') || !(v = v.substring(v.lastIndexOf(':')))) { v = this.isSecure() ? ':443' : ':80' } @@ -653,11 +1015,17 @@ export class HttpRequest { this.isForwardedValid = false throw new ConflictingHeadersException( - `The request has both a trusted "${trustedHeaders[this.HEADER_FORWARDED]}" header and a trusted "${trustedHeaders[type]}" header, conflicting with each other. ` + + `The request has both a trusted "${trustedHeaders[HttpRequest.HEADER_FORWARDED]}" header and a trusted "${trustedHeaders[type]}" header, conflicting with each other. ` + 'You should either configure your proxy to remove one of them, or configure your project to distrust the offending one.' ) } + /** + * + * @param clientIps + * @param ip + * @returns + */ private normalizeAndFilterClientIps (clientIps: string[], ip: string): string[] { if (!clientIps || clientIps.length === 0) { return [] @@ -688,7 +1056,7 @@ export class HttpRequest { } // Validate IP format - if (isIP(clientIp) > 0) { + if (Str.validateIp(clientIp)) { clientIps.splice(i, 1) i-- continue @@ -706,6 +1074,42 @@ export class HttpRequest { return clientIps.length > 0 ? clientIps.reverse() : (firstTrustedIp ? [firstTrustedIp] : []) } + /** + * Sets a list of trusted host patterns. + * + * You should only list the hosts you manage using regexes. + * + * @param hostPatterns + */ + public static setTrustedHosts (hostPatterns: string[]): void { + /* Convert host patterns to case-insensitive regex */ + this.trustedHostPatterns = hostPatterns.map( + (hostPattern) => new RegExp(hostPattern, 'i') + ) + + /** + * reset trusted hosts when patterns change + */ + this.trustedHosts = [] + } + + /** + * Returns the path being requested relative to the executed script. + * + * The path info always starts with a /. + * + * @return {string} The raw path (i.e. not urldecoded) + */ + public getPathInfo (): string { + return this.pathInfo ??= this.preparePathInfo() + } + + /** + * Gets the list of trusted host patterns. + */ + public static getTrustedHosts (): RegExp[] { + return this.trustedHostPatterns + } /** * Enables support for the _method request parameter to determine the intended HTTP method. diff --git a/packages/http/src/Utilities/HttpResponse.ts b/packages/http/src/Utilities/HttpResponse.ts index 0455b076..f12d98a0 100644 --- a/packages/http/src/Utilities/HttpResponse.ts +++ b/packages/http/src/Utilities/HttpResponse.ts @@ -1,14 +1,13 @@ import { DateTime, InvalidArgumentException } from '@h3ravel/support' import { HTTP_RESPONSE_CACHE_CONTROL_DIRECTIVES, statusTexts } from '../Utilities/ResponseUtilities' +import { IRequest, ResponseObject } from '@h3ravel/contracts' import { CacheOptions } from '../Contracts/HttpContract' import { Cookie } from './Cookie' import type { H3Event } from 'h3' import { HeaderBag } from '../Utilities/HeaderBag' import { HttpResponseException } from '../Exceptions/HttpResponseException' -import { Request } from '..' import { ResponseHeaderBag } from '../Utilities/ResponseHeaderBag' -import type { ResponseObject } from '@h3ravel/shared' export class HttpResponse { protected statusCode: number = 200 @@ -692,7 +691,7 @@ export class HttpResponse { * compliant with RFC 2616. Most of the changes are based on * the Request that is "associated" with this Response. **/ - public prepare (request: Request): this { + public prepare (request: IRequest): this { const isInformational = this.isInformational() const isEmpty = this.isEmpty() @@ -775,7 +774,7 @@ export class HttpResponse { * * @see http://support.microsoft.com/kb/323308 */ - protected ensureIEOverSSLCompatibility (request: Request) { + protected ensureIEOverSSLCompatibility (request: IRequest) { const contentDisposition = this.headers.get('Content-Disposition') || '' const userAgent = request.headers.get('user-agent') || '' diff --git a/packages/http/src/Utilities/ParamBag.ts b/packages/http/src/Utilities/ParamBag.ts index 20cba970..ef96a266 100644 --- a/packages/http/src/Utilities/ParamBag.ts +++ b/packages/http/src/Utilities/ParamBag.ts @@ -1,4 +1,4 @@ -import { IParamBag, RequestObject } from '@h3ravel/shared' +import { IParamBag, RequestObject } from '@h3ravel/contracts' import { BadRequestException } from '../Exceptions/BadRequestException' import { H3Event } from 'h3' @@ -22,12 +22,12 @@ export class ParamBag implements IParamBag { /** * Returns the parameters. * @ - * @param key The name of the parameter to return or null to get them all + * @param key The name of the parameter to return or undefined to get them all * * @throws BadRequestException if the value is not an array */ all (key?: string) { - if (key === null) return { ...this.parameters } + if (!key) return { ...this.parameters } const value = key ? this.parameters[key] : undefined if (value && typeof value !== 'object') { throw new BadRequestException(`Unexpected value for parameter "${key}": expected object, got ${typeof value}`) diff --git a/packages/http/src/Utilities/Responsable.ts b/packages/http/src/Utilities/Responsable.ts new file mode 100644 index 00000000..3f791dc3 --- /dev/null +++ b/packages/http/src/Utilities/Responsable.ts @@ -0,0 +1,18 @@ +import { IRequest, IResponsable, IResponse } from '@h3ravel/contracts' + +import { HTTPResponse } from 'h3' +import { Response } from '../Response' + +export class Responsable extends IResponsable { + toResponse (request: IRequest): IResponse { + return new Response( + request.app, + this.body as string, + this.status, + Object.fromEntries(this.headers.entries()) + ) + } + HTTPResponse (): HTTPResponse { + return super.constructor as unknown as HTTPResponse + } +} \ No newline at end of file diff --git a/packages/http/src/Utilities/ServerBag.ts b/packages/http/src/Utilities/ServerBag.ts index 31ae7d0d..86b478bd 100644 --- a/packages/http/src/Utilities/ServerBag.ts +++ b/packages/http/src/Utilities/ServerBag.ts @@ -1,5 +1,7 @@ -import { H3Event } from 'h3' +import { H3Event, getRequestProtocol } from 'h3' + import { ParamBag } from './ParamBag' +import { Str } from '@h3ravel/support' /** * ServerBag — a simplified version of Symfony's ServerBag @@ -10,6 +12,25 @@ import { ParamBag } from './ParamBag' */ export class ServerBag extends ParamBag { + private static serverData: { + SERVER_PROTOCOL?: string + REQUEST_METHOD?: string + REQUEST_URI?: string + PATH_INFO?: string + QUERY_STRING?: string + SERVER_NAME?: string + SERVER_PORT?: string + REMOTE_ADDR?: string + REMOTE_PORT?: string + HTTP_HOST?: string + HTTP_USER_AGENT?: string + HTTP_ACCEPT?: string + HTTP_ACCEPT_LANGUAGE?: string + HTTP_ACCEPT_ENCODING?: string + HTTP_REFERER?: string + HTTPS?: string + } = {} + constructor( parameters: Record = {}, /** @@ -17,9 +38,43 @@ export class ServerBag extends ParamBag { */ event: H3Event ) { - super(Object.fromEntries( - Object.entries(parameters).map(([k, v]) => [k.toLowerCase(), v]) - ), event) + super({}, event) + this.add(Object.fromEntries(Object.entries(parameters).map(([k, v]) => [k.toLowerCase(), v]))) + this.add(Object.fromEntries(Object.entries(ServerBag.initialize(event, this.getHeaders())).map(([k, v]) => [Str.slugify(k, '-', { '_': '-' }), v]))) + this.add(ServerBag.initialize(event, this.getHeaders())) + } + + static initialize (event: H3Event, headers: Record) { + const req = event.req + // const socket = this.event.req?? {} + const url = new URL(req.url ?? '/') + const host = headers.host + const method = req.method ?? 'GET' + const protocol = getRequestProtocol(event) + const isHttps = protocol === 'https' || !!event.req.headers.get('x-forwarded-proto')?.includes('https') + + // Populate keys similar to PHP/Laravel $_SERVER / Symfony Request->server + // this.serverData.SERVER_PROTOCOL = `HTTP/${(req?.httpVersion ?? '1.1')}` + this.serverData.SERVER_PROTOCOL = protocol + this.serverData.REQUEST_METHOD = method + this.serverData.REQUEST_URI = url.href + this.serverData.PATH_INFO = url.pathname + this.serverData.QUERY_STRING = url.search + this.serverData.SERVER_NAME = host + this.serverData.SERVER_PORT = url.port + this.serverData.REMOTE_ADDR = undefined + this.serverData.REMOTE_PORT = undefined + this.serverData.HTTP_HOST = headers.HOST ?? headers.HTTP_HOST ?? host + this.serverData.HTTP_USER_AGENT = headers.USER_AGENT ?? headers.HTTP_USER_AGENT ?? '' + this.serverData.HTTP_ACCEPT = headers.ACCEPT ?? '' + this.serverData.HTTP_ACCEPT_LANGUAGE = headers.ACCEPT_LANGUAGE ?? headers.HTTP_ACCEPT_LANGUAGE ?? '' + this.serverData.HTTP_ACCEPT_ENCODING = headers.ACCEPT_ENCODING ?? headers.HTTP_ACCEPT_ENCODING ?? '' + this.serverData.HTTP_REFERER = headers.REFERER ?? headers.HTTP_REFERER ?? '' + this.serverData.HTTPS = isHttps ? 'on' : 'off' + + // this.serverData._headers = headers + // this.serverData._env = process.env + return this.serverData } /** @@ -73,13 +128,13 @@ export class ServerBag extends ParamBag { * Returns a specific header by name, case-insensitive. */ public get (name: string): string | undefined { - return this.parameters[name.toLowerCase()] + return this.parameters[name.toLowerCase()] || this.parameters[name] } /** * Returns true if a header exists. */ public has (name: string): boolean { - return name.toLowerCase() in this.parameters + return name.toLowerCase() in this.parameters || name in this.parameters } } diff --git a/packages/http/src/app.globals.d.ts b/packages/http/src/app.globals.d.ts index 97c2002a..61781a96 100644 --- a/packages/http/src/app.globals.d.ts +++ b/packages/http/src/app.globals.d.ts @@ -1,9 +1,18 @@ -import { ISessionManager } from '@h3ravel/shared' -import { Request, Response } from '.' +import type { ISessionManager } from '@h3ravel/contracts' +import type { Request, Response } from '.' export { } declare global { + /** + * Get the flashed input from previous request + * + * @param key + * @param defaultValue + * @returns + */ + function old (): Promise> + function old (key: string, defaultValue?: any): Promise /** * Get an instance of the Request class * diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index e757d5d7..cc60681c 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -10,6 +10,7 @@ export * from './HttpContext' export * from './Middleware' export * from './Middleware/FlashDataMiddleware' export * from './Middleware/LogRequests' +export * from './Middleware/TrustHosts' export * from './Providers/HttpServiceProvider' export * from './Request' export * from './Resources/ApiResource' @@ -25,6 +26,7 @@ export * from './Utilities/HttpResponse' export * from './Utilities/InputBag' export * from './Utilities/IpUtils' export * from './Utilities/ParamBag' +export * from './Utilities/Responsable' export * from './Utilities/ResponseHeaderBag' export * from './Utilities/ResponseUtilities' export * from './Utilities/ServerBag' diff --git a/packages/http/tests/Request.spec.ts b/packages/http/tests/Request.spec.ts index 98831d32..24fb7588 100644 --- a/packages/http/tests/Request.spec.ts +++ b/packages/http/tests/Request.spec.ts @@ -1,6 +1,5 @@ import { Application, h3ravel } from '@h3ravel/core' // if this exists -import { HttpContext, Response } from '@h3ravel/http' -import { beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest' +import { beforeEach, describe, expect, it, test, vi } from 'vitest' import { HttpServiceProvider } from '../src/Providers/HttpServiceProvider' import { InputBag } from '../src/Utilities/InputBag' @@ -410,7 +409,7 @@ describe('Request', () => { // const ctx = HttpContext.init({ // app, // request: await Request.create(event, app), - // response: new Response(event, app), + // response: new Response(app, event), // }, event) diff --git a/packages/http/tests/Response.spec.ts b/packages/http/tests/Response.spec.ts index f1b47361..7c351708 100644 --- a/packages/http/tests/Response.spec.ts +++ b/packages/http/tests/Response.spec.ts @@ -34,7 +34,7 @@ describe('Response', () => { beforeEach(() => { event = makeEvent() app = new Application() - iResponse = new Response(event, app) + iResponse = new Response(app, event) }) it('stores the app and event', () => { @@ -85,12 +85,12 @@ describe('Response', () => { }) it('getEvent with key returns nested value', () => { - const r = new Response(makeEvent({ url: '/foo' }), app) + const r = new Response(app, makeEvent({ url: '/foo' })) expect(r.getEvent('req.url')).toBe('/foo') }) it('returns Response class instance from global response helper', async () => { - const res = new Response(makeEvent({ url: '/foo' }), app) + const res = new Response(app, makeEvent({ url: '/foo' })) expect(response()).toBe(res) expect(response()).toBeInstanceOf(Response) diff --git a/packages/queue/package.json b/packages/queue/package.json index 3e2fa982..58801959 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -57,9 +57,10 @@ "version-patch": "pnpm version patch" }, "peerDependencies": { - "@h3ravel/core": "workspace:^" + "@h3ravel/core": "workspace:^", + "@h3ravel/contracts": "workspace:^" }, "devDependencies": { "typescript": "^5.4.0" } -} +} \ No newline at end of file diff --git a/packages/queue/src/Contracts/JobContract.ts b/packages/queue/src/Contracts/JobContract.ts new file mode 100644 index 00000000..1077e4d1 --- /dev/null +++ b/packages/queue/src/Contracts/JobContract.ts @@ -0,0 +1 @@ +export type JobClassConstructor = (new (...args: any) => any); \ No newline at end of file diff --git a/packages/queue/src/Events/JobFailed.ts b/packages/queue/src/Events/JobFailed.ts new file mode 100644 index 00000000..721ce768 --- /dev/null +++ b/packages/queue/src/Events/JobFailed.ts @@ -0,0 +1,17 @@ +import { Job } from '../Jobs/Job' + +export class JobFailed { + /** + * Create a new event instance. + * + * @param connectionName The connection name. + * @param job The job instance. + * @param exception The exception that caused the job to fail. + */ + constructor( + public connectionName: string, + public job: Job, + public exception: Error, + ) { + } +} \ No newline at end of file diff --git a/packages/queue/src/Exceptions/ManuallyFailedException.ts b/packages/queue/src/Exceptions/ManuallyFailedException.ts new file mode 100644 index 00000000..156ee1f3 --- /dev/null +++ b/packages/queue/src/Exceptions/ManuallyFailedException.ts @@ -0,0 +1,3 @@ +import { RuntimeException } from '@h3ravel/support' + +export class ManuallyFailedException extends RuntimeException { } \ No newline at end of file diff --git a/packages/queue/src/Exceptions/MaxAttemptsExceededException.ts b/packages/queue/src/Exceptions/MaxAttemptsExceededException.ts new file mode 100644 index 00000000..b65f6ed0 --- /dev/null +++ b/packages/queue/src/Exceptions/MaxAttemptsExceededException.ts @@ -0,0 +1,21 @@ +import { RuntimeException, tap } from '@h3ravel/support' + +import { Job } from '../Jobs/Job' + +export class MaxAttemptsExceededException extends RuntimeException { + /** + * The job instance. + */ + public job!: Job + + /** + * Create a new instance for the job. + * + * @param job + */ + public static forJob (job: Job) { + return tap(new MaxAttemptsExceededException(job.resolveName() + ' has been attempted too many times.'), (e) => { + e.job = job + }) + } +} \ No newline at end of file diff --git a/packages/queue/src/Exceptions/TimeoutExceededException.ts b/packages/queue/src/Exceptions/TimeoutExceededException.ts new file mode 100644 index 00000000..7bca8fef --- /dev/null +++ b/packages/queue/src/Exceptions/TimeoutExceededException.ts @@ -0,0 +1,16 @@ +import { Job } from '../Jobs/Job' +import { MaxAttemptsExceededException } from './MaxAttemptsExceededException' +import { tap } from '@h3ravel/support' + +export class TimeoutExceededException extends MaxAttemptsExceededException { + /** + * Create a new instance for the job. + * + * @param job + */ + public static forJob (job: Job) { + return tap(new TimeoutExceededException(job.resolveName() + ' has timed out.'), (e) => { + e.job = job + }) + } +} \ No newline at end of file diff --git a/packages/queue/src/Jobs/Job.ts b/packages/queue/src/Jobs/Job.ts new file mode 100644 index 00000000..689cd039 --- /dev/null +++ b/packages/queue/src/Jobs/Job.ts @@ -0,0 +1,335 @@ +import { ClassConstructor, IDispatcher, JobPayload } from '@h3ravel/contracts' + +import { Container } from '@h3ravel/core' +import { JobFailed } from '../Events/JobFailed' +import { JobName } from './JobName' +import { ManuallyFailedException } from '../Exceptions/ManuallyFailedException' +import { TimeoutExceededException } from '../Exceptions/TimeoutExceededException' + +export abstract class Job { + /** + * The job handler instance. + */ + protected instance!: Job + + /** + * The IoC container instance. + */ + protected container!: Container + + /** + * Indicates if the job has been deleted. + */ + protected deleted: boolean = false + + /** + * Indicates if the job has been released. + */ + protected released: boolean = false + + /** + * Indicates if the job has failed. + */ + protected failed: boolean = false + + /** + * The name of the connection the job belongs to. + */ + protected connectionName!: string + + /** + * The name of the queue the job belongs to. + */ + protected queue?: string + + /** + * Get the job identifier. + */ + public abstract getJobId (): string | number | undefined; + + /** + * Get the raw body of the job. + */ + public abstract getRawBody (): string; + + /** + * Get the UUID of the job. + * + * @return string|null + */ + public uuid () { + return this.payload()['uuid'] ?? null + } + + /** + * Fire the job. + * + * @return void + */ + public fire () { + const payload = this.payload() + + const [instance, method] = JobName.parse(payload['job']); + + (this.instance = this.resolve(instance))[method](this, payload['data']) + } + + /** + * Delete the job from the queue. + */ + public delete () { + this.deleted = true + } + + /** + * Determine if the job has been deleted. + */ + public isDeleted () { + return this.deleted + } + + /** + * Release the job back into the queue after (n) seconds. + * + * @param delay + */ + public release (delay = 0) { + this.released = true + } + + /** + * Determine if the job was released back into the queue. + * + * @return bool + */ + public isReleased () { + return this.released + } + + /** + * Determine if the job has been deleted or released. + */ + public isDeletedOrReleased () { + return this.isDeleted() || this.isReleased() + } + + /** + * Determine if the job has been marked as a failure. + */ + public hasFailed () { + return this.failed + } + + /** + * Mark the job as "failed". + */ + public markAsFailed () { + this.failed = true + } + + /** + * Delete the job, call the "failed" method, and raise the failed job event. + * + * @param e + */ + public fail (e: Error) { + this.markAsFailed() + + if (this.isDeleted()) { + return + } + + // const commandName = this.payload()['data']['commandName'] ?? false; + + // TODO: Handle this + // If the exception is due to a job timing out, we need to rollback the current + // database transaction so that the failed job count can be incremented with + // the proper value. Otherwise, the current transaction will never commit. + // if (e instanceof TimeoutExceededException && + // commandName && + // in_array(Batchable:: class, class_uses_recursive(commandName))) { + // const batchRepository = this.resolve(BatchRepository:: class); + + // try { + // batchRepository.rollBack(); + // } catch (e) { + // // ... + // } + // } + + if (this.shouldRollBackDatabaseTransaction(e)) { + this.container.make('db') + .connection(this.container.make('config').get('queue.failed.database')) + .rollBack(0) + } + + try { + // If the job has failed, we will delete it, call the "failed" method and then call + // an event indicating the job has failed so it can be logged if needed. This is + // to allow every developer to better keep monitor of their failed queue jobs. + this.delete() + + this.failedJob(e) + } finally { + this.resolve(IDispatcher).dispatch(new JobFailed( + this.connectionName, this, e || new ManuallyFailedException() + )) + } + } + + /** + * Determine if the current database transaction should be rolled back to level zero. + * + * @param e + */ + protected shouldRollBackDatabaseTransaction (e: Error) { + return e instanceof TimeoutExceededException && + this.container.make('config').get('queue.failed.database') && + ['database', 'database-uuids'].includes(this.container.make('config').get('queue.failed.driver')) && + this.container.has('db') + } + + /** + * Process an exception that caused the job to fail. + * + * @param e + */ + protected failedJob (e: Error, ..._args: any[]) { + const payload = this.payload() + + const [classInstance] = JobName.parse(payload.job) + + this.instance = this.resolve(classInstance) + + if (typeof this.instance.failed === 'function') { + this.instance.failedJob(payload.data, e, payload.uuid ?? '', this) + } + } + + /** + * Resolve the given class. + */ + protected resolve (className: C): InstanceType { + return this.container.make(className) + } + + /** + * Get the resolved job handler instance. + * + * @return mixed + */ + public getResolvedJob () { + return this.instance + } + + /** + * Get the decoded body of the job. + */ + public payload (): JobPayload { + return JSON.parse(this.getRawBody()) + } + + /** + * Get the number of times to attempt a job. + * + * @return int|null + */ + public maxTries () { + return this.payload()['maxTries'] ?? null + } + + /** + * Get the number of times to attempt a job after an exception. + * + * @return int|null + */ + public maxExceptions () { + return this.payload()['maxExceptions'] ?? null + } + + /** + * Determine if the job should fail when it timeouts. + * + * @return bool + */ + public shouldFailOnTimeout () { + return this.payload()['failOnTimeout'] ?? false + } + + /** + * The number of seconds to wait before retrying a job that encountered an uncaught exception. + * + * @return int|int[]|null + */ + public backoff () { + return this.payload()['backoff'] ?? this.payload()['delay'] ?? null + } + + /** + * Get the number of seconds the job can run. + * + * @return int|null + */ + public timeout () { + return this.payload()['timeout'] ?? null + } + + /** + * Get the timestamp indicating when the job should timeout. + * + * @return int|null + */ + public retryUntil () { + return this.payload()['retryUntil'] ?? null + } + + /** + * Get the name of the queued job class. + * + * @return string + */ + public getName () { + return this.payload()['job'] + } + + /** + * Get the resolved display name of the queued job class. + * + * Resolves the name of "wrapped" jobs such as class-based handlers. + */ + public resolveName () { + return JobName.resolve(this.getName(), this.payload()) + } + + /** + * Get the class of the queued job. + * + * Resolves the class of "wrapped" jobs such as class-based handlers. + * + * @return string + */ + public resolveQueuedJobClass () { + return JobName.resolveClassName(this.getName(), this.payload()) + } + + /** + * Get the name of the connection the job belongs to. + */ + public getConnectionName () { + return this.connectionName + } + + /** + * Get the name of the queue the job belongs to. + */ + public getQueue () { + return this.queue + } + + /** + * Get the service container instance. + */ + public getContainer () { + return this.container + } +} diff --git a/packages/queue/src/Jobs/JobName.ts b/packages/queue/src/Jobs/JobName.ts new file mode 100644 index 00000000..b716bdb1 --- /dev/null +++ b/packages/queue/src/Jobs/JobName.ts @@ -0,0 +1,41 @@ +import { JobClassConstructor } from '../Contracts/JobContract' + +export class JobName { + /** + * Parse the given job name into a class / method array. + * + * @param job + */ + public static parse (_job: string): [JobClassConstructor, string] { + // TODO: Implement this + return [{} as JobClassConstructor, ''] + } + + /** + * Get the resolved name of the queued job class. + * + * @param name + * @param payload + */ + public static resolve (name: string, payload: Record) { + if (!payload.displayName) { + return payload.displayName + } + + return name + } + + /** + * Get the class name for queued job class. + * + * @param name + * @param payload + */ + public static resolveClassName (name: string, payload: Record) { + if (typeof payload.data.commandName === 'string') { + return payload.data.commandName + } + + return name + } +} diff --git a/packages/queue/src/index.ts b/packages/queue/src/index.ts index 5d2dc3c0..d087ad52 100644 --- a/packages/queue/src/index.ts +++ b/packages/queue/src/index.ts @@ -1,4 +1,11 @@ +export * from './Contracts/JobContract' export * from './Drivers/MemoryDriver' export * from './Drivers/RedisDriver' +export * from './Events/JobFailed' +export * from './Exceptions/ManuallyFailedException' +export * from './Exceptions/MaxAttemptsExceededException' +export * from './Exceptions/TimeoutExceededException' +export * from './Jobs/Job' +export * from './Jobs/JobName' export * from './Providers/QueueServiceProvider' export * from './QueueManager' diff --git a/packages/router/package.json b/packages/router/package.json index 12e8cb5f..498519f4 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -57,15 +57,19 @@ "test": "jest --passWithNoTests", "version-patch": "pnpm version patch" }, + "peerDependencies": { + "@h3ravel/database": "workspace:^" + }, "dependencies": { + "h3": "catalog:prod", + "@h3ravel/contracts": "workspace:^", "@h3ravel/core": "workspace:^", + "@h3ravel/events": "workspace:^", "@h3ravel/musket": "catalog:prod", - "@h3ravel/database": "workspace:^", "@h3ravel/http": "workspace:^", "@h3ravel/shared": "workspace:^", "@h3ravel/support": "workspace:^", "@h3ravel/foundation": "workspace:^", - "h3": "catalog:prod", "reflect-metadata": "catalog:" } } \ No newline at end of file diff --git a/packages/router/src/AbstractRouteCollection.ts b/packages/router/src/AbstractRouteCollection.ts new file mode 100644 index 00000000..a804987c --- /dev/null +++ b/packages/router/src/AbstractRouteCollection.ts @@ -0,0 +1,113 @@ +import type { IAbstractRouteCollection, RouteMethod } from '@h3ravel/contracts' + +import { NotFoundHttpException } from '@h3ravel/foundation' +import { Request } from '@h3ravel/http' +import { Route } from './Route' + +/* + * AbstractRouteCollection provides the shared route-matching logic + * used by RouteCollection. It is responsible for scanning candidate + * routes, matching domain/URI patterns, extracting parameters, and + * resolving the matched route. + */ +export abstract class AbstractRouteCollection implements IAbstractRouteCollection { + public static verbs: RouteMethod[] = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] + abstract get (method?: string): Record | Route[] + abstract getRoutes (): Route[] + + /* + * Match a request against a set of routes belonging to one HTTP verb. + * + * @param routes + * @param req + * @returns + */ + protected matchAgainstRoutes ( + routes: Record | Route[], + req: Request, + ): Route | null { + const path = req.path() + const host = req.getHost() + + for (let route of (Array.isArray(routes) ? routes : Object.entries(routes))) { + route = Array.isArray(route) ? route[1] : route + + /* + * Domain match check. + */ + if (route.domain() && !this.matchDomain(route.domain(), host)) { + continue + } + + /* + * URI match check (simple or compiled). + */ + if (!this.matchUri(route, path)) { + continue + } + + return route + } + + return null + } + + /* + * Final handler for a matched route. Responsible for: + * - Throwing for not found + * - Throwing for method not allowed + * - Attaching params extracted from the match + */ + protected handleMatchedRoute (req: Request, route?: Route | null): Route { + if (route) { + return route.bind(req) + } + + throw new NotFoundHttpException(`The route ${req.path()} could not be found.`, undefined, 404) + } + + /** + * Determine if any routes match on another HTTP verb. + * + * @param request + */ + protected checkForAlternateVerbs (request: Request): string[] { + // get all verbs except the current request method + const methods = AbstractRouteCollection.verbs.filter(m => m !== request.getMethod()) + + // check which verbs have matching routes + const allowedMethods = methods.filter(method => { + const routesForMethod = this.get(method) + return this.matchAgainstRoutes(routesForMethod, request) != null + }) + + return allowedMethods + } + + /* + * Determine if a domain matches (supports wildcard patterns). + * Example: "*.example.com" matches "api.example.com" + */ + protected matchDomain (domain: string, host: string): boolean { + if (!domain) return true + if (domain === host) return true + + if (domain.includes('*')) { + const pattern = domain.replace('*', '(.*)') + const regex = new RegExp(`^${pattern}$`) + return regex.test(host) + } + + return false + } + + /* + * Match URI path against the route's pattern or compiled regex. + */ + protected matchUri (route: Route, path: string): boolean { + /* + * Fallback simple literal match. + */ + return path === route.uri() + } +} diff --git a/packages/router/src/CallableDispatcher.ts b/packages/router/src/CallableDispatcher.ts new file mode 100644 index 00000000..7c6ca70b --- /dev/null +++ b/packages/router/src/CallableDispatcher.ts @@ -0,0 +1,41 @@ +import { CallableConstructor, ICallableDispatcher } from '@h3ravel/contracts' + +import { Application } from '@h3ravel/core' +import { Route } from './Route' +import { RouteDependencyResolver } from './TraitLike/RouteDependencyResolver' + +export class CallableDispatcher extends ICallableDispatcher { + resolver: RouteDependencyResolver + + /** + * + * @param container The container instance. + */ + public constructor(protected container: Application) { + super() + this.resolver = new RouteDependencyResolver(container) + } + + /** + * Dispatch a request to a given callback. + * + * @param route + * @param handler + * @param method + */ + public async dispatch (route: Route, handler: CallableConstructor) { + return handler(this.container.make('http.context'), ...Object.values(this.resolveParameters(route))) + } + + /** + * Resolve the parameters for the callable. + * + * @param route + * @param handler + */ + protected resolveParameters (route: Route) { + return this.resolver.resolveMethodDependencies( + route.parametersWithoutNulls() + ) + } +} diff --git a/packages/router/src/Commands/RouteListCommand.ts b/packages/router/src/Commands/RouteListCommand.ts index 2b475ac0..74386be5 100644 --- a/packages/router/src/Commands/RouteListCommand.ts +++ b/packages/router/src/Commands/RouteListCommand.ts @@ -1,8 +1,10 @@ -import { Logger, LoggerChalk, RouteDefinition, RouteMethod } from '@h3ravel/shared' +import { Logger, LoggerChalk, RouteMethod } from '@h3ravel/shared' +import { Application } from '@h3ravel/core' +import { ClassicRouteDefinition } from '@h3ravel/contracts' import { Command } from '@h3ravel/musket' -export class RouteListCommand extends Command { +export class RouteListCommand extends Command { /** * The name and signature of the console command. @@ -40,7 +42,7 @@ export class RouteListCommand extends Command { /** * Sort the routes alphabetically */ - const list = [...(this.app.make('app.routes') as RouteDefinition[])].sort((a, b) => { + const list = [...(this.app.make('app.routes') as ClassicRouteDefinition[])].sort((a, b) => { if (a.path === '/' && b.path !== '/') return -1 if (b.path === '/' && a.path !== '/') return 1 return a.path.localeCompare(b.path) @@ -96,9 +98,9 @@ export class RouteListCommand extends Command { private pair (method: RouteMethod) { switch (method.toLowerCase()) { case 'get': - return Logger.log('|', 'gray', false) + Logger.log('HEAD', this.color('head'), false) + return Logger.log('|', 'gray', false) + Logger.log('HEAD', this.color('HEAD'), false) case 'put': - return Logger.log('|', 'gray', false) + Logger.log('PATCH', this.color('patch'), false) + return Logger.log('|', 'gray', false) + Logger.log('PATCH', this.color('PATCH'), false) default: return '' } diff --git a/packages/router/src/CompiledRoute.ts b/packages/router/src/CompiledRoute.ts new file mode 100644 index 00000000..3a9fd635 --- /dev/null +++ b/packages/router/src/CompiledRoute.ts @@ -0,0 +1,61 @@ +export class CompiledRoute { + private path: string + private paramNames: string[] + private optionalParams: Record + private regex: RegExp + private hostPattern?: string + private hostRegex?: RegExp + + constructor(path: string, paramNames: string[], optionalParams: Record, hostPattern?: string) { + this.path = path + this.paramNames = paramNames + this.optionalParams = optionalParams + this.hostPattern = hostPattern + + // Build the main path regex + this.regex = this.buildRegex(this.path, this.paramNames, this.optionalParams) + + // If host pattern provided, compile host regex too + if (this.hostPattern) { + this.hostRegex = this.buildRegex(this.hostPattern, [], {}) + } + } + + /** + * Get the compiled path regex + */ + public getRegex (): RegExp { + return this.regex + } + + /** + * Get the compiled host regex (if any) + */ + public getHostRegex (): RegExp | undefined { + return this.hostRegex + } + + /** + * Returns list of all param names (including optional) + */ + public getParamNames (): string[] { + return [...this.paramNames] + } + + /** + * Returns optional params record + */ + public getOptionalParams (): Record { + return { ...this.optionalParams } + } + + /** + * Internal: build a regex from a path pattern + */ + private buildRegex (path: string, paramNames: string[], optionalParams: Record): RegExp { + const regexStr = path.replace(/:([a-zA-Z0-9_]+)\??/g, (_, paramName) => { + return optionalParams[paramName] === null ? '([^/]*)' : '([^/]+)' + }) + return new RegExp(`^${regexStr}$`) + } +} diff --git a/packages/router/src/Contracts/Pipeline.ts b/packages/router/src/Contracts/Pipeline.ts new file mode 100644 index 00000000..13cd1ec5 --- /dev/null +++ b/packages/router/src/Contracts/Pipeline.ts @@ -0,0 +1,3 @@ +import { IMiddleware } from '@h3ravel/contracts' + +export type Pipe = string | (abstract new (...args: any[]) => any) | ((...args: any[]) => any) | IMiddleware \ No newline at end of file diff --git a/packages/router/src/ControllerDispatcher.ts b/packages/router/src/ControllerDispatcher.ts new file mode 100644 index 00000000..2124fb90 --- /dev/null +++ b/packages/router/src/ControllerDispatcher.ts @@ -0,0 +1,67 @@ +import { ControllerMethod, IController, IControllerDispatcher, RouteMethod } from '@h3ravel/contracts' + +import { Application } from '@h3ravel/core' +import { Collection } from '@h3ravel/support' +import { FiltersControllerMiddleware } from './TraitLike/FiltersControllerMiddleware' +import { Route } from './Route' +import { RouteDependencyResolver } from './TraitLike/RouteDependencyResolver' + +export class ControllerDispatcher extends IControllerDispatcher { + resolver: RouteDependencyResolver + + /** + * + * @param container The container instance. + */ + public constructor(protected container: Application) { + super() + this.resolver = new RouteDependencyResolver(container) + } + + /** + * Dispatch a request to a given controller and method. + * + * @param route + * @param controller + * @param method + */ + public async dispatch (route: Route, controller: Required, method: ControllerMethod) { + const parameters = await this.resolveParameters(route, controller, method) + + if (Object.prototype.hasOwnProperty.call(controller, 'callAction')) { + return controller.callAction(method, Object.values(parameters)) + } + + return await controller[method].apply(controller, [...Object.values(parameters)]) + } + + /** + * Resolve the parameters for the controller. + * + * @param route + * @param controller + * @param method + */ + protected async resolveParameters (route: Route, controller: IController, method: ControllerMethod) { + return this.resolver.resolveClassMethodDependencies( + route.parametersWithoutNulls(), controller, method + ) + } + + /** + * Get the middleware for the controller instance. + * + * @param controller + * @param method + */ + public getMiddleware (controller: IController, method: RouteMethod) { + if (!Object.prototype.hasOwnProperty.call(controller, 'getMiddleware')) { + return [] + } + + return (new Collection(controller.getMiddleware())) + .reject((data) => FiltersControllerMiddleware.methodExcludedByOptions(method, data.options)) + .pluck('middleware') + .all() + } +} diff --git a/packages/router/src/Events/PreparingResponse.ts b/packages/router/src/Events/PreparingResponse.ts new file mode 100644 index 00000000..997aabeb --- /dev/null +++ b/packages/router/src/Events/PreparingResponse.ts @@ -0,0 +1,16 @@ +import { IRequest, ResponsableType } from '@h3ravel/contracts' + +export class PreparingResponse { + /** + * Create a new event instance. + * + * + * @param $request The request instance. + * @param $response The response instance. + */ + constructor( + public request: IRequest, + public response: ResponsableType, + ) { + } +} diff --git a/packages/router/src/Events/ResponsePrepared.ts b/packages/router/src/Events/ResponsePrepared.ts new file mode 100644 index 00000000..9aeefc70 --- /dev/null +++ b/packages/router/src/Events/ResponsePrepared.ts @@ -0,0 +1,16 @@ +import { IRequest, ResponsableType } from '@h3ravel/contracts' + +export class ResponsePrepared { + /** + * Create a new event instance. + * + * + * @param $request The request instance. + * @param $response The response instance. + */ + constructor( + public request: IRequest, + public response: ResponsableType, + ) { + } +} diff --git a/packages/router/src/Events/RouteMatched.ts b/packages/router/src/Events/RouteMatched.ts new file mode 100644 index 00000000..ebf985a8 --- /dev/null +++ b/packages/router/src/Events/RouteMatched.ts @@ -0,0 +1,16 @@ +import { Request } from '@h3ravel/http' +import { Route } from '../Route' + +export class RouteMatched { + /** + * Create a new event instance. + * + * @param route The route instance. + * @param request The request instance. + */ + public constructor( + public route: Route, + public request: Request, + ) { + } +} \ No newline at end of file diff --git a/packages/router/src/Events/Routing.ts b/packages/router/src/Events/Routing.ts new file mode 100644 index 00000000..d63f3f51 --- /dev/null +++ b/packages/router/src/Events/Routing.ts @@ -0,0 +1,13 @@ +import { Request } from '@h3ravel/http' + +export class Routing { + /** + * Create a new event instance. + * + * @param request The request instance. + */ + public constructor( + public request: Request, + ) { + } +} \ No newline at end of file diff --git a/packages/router/src/Helpers.ts b/packages/router/src/Helpers.ts index e5689bb9..7fc5701d 100644 --- a/packages/router/src/Helpers.ts +++ b/packages/router/src/Helpers.ts @@ -1,4 +1,4 @@ -import { type HttpContext } from '@h3ravel/shared' +import { IHttpContext } from '@h3ravel/contracts' import { Model } from '@h3ravel/database' export class Helpers { @@ -38,7 +38,7 @@ export class Helpers { * @param model - The model instance to resolve bindings against * @returns A resolved model instance or an object containing param values */ - static async resolveRouteModelBinding (path: string, ctx: HttpContext, model: Model): Promise { + static async resolveRouteModelBinding (path: string, ctx: IHttpContext, model: Model): Promise { const name = model.constructor.name.toLowerCase() /** * Extract field (defaults to 'id' if not specified after '|') diff --git a/packages/router/src/Matchers/HostValidator.ts b/packages/router/src/Matchers/HostValidator.ts new file mode 100644 index 00000000..ec696894 --- /dev/null +++ b/packages/router/src/Matchers/HostValidator.ts @@ -0,0 +1,19 @@ +import { Request } from '@h3ravel/http' +import { Route } from '../Route' + +export class HostValidator { + /** + * Validate a given rule against a route and request. + * + * @param route + * @param request + */ + public matches (route: Route, request: Request) { + const hostRegex = route.getCompiled()?.getHostRegex() + + if (!hostRegex) { + return true + } + return hostRegex.test(request.getHost()) + } +} \ No newline at end of file diff --git a/packages/router/src/Matchers/MethodValidator.ts b/packages/router/src/Matchers/MethodValidator.ts new file mode 100644 index 00000000..2a0eda23 --- /dev/null +++ b/packages/router/src/Matchers/MethodValidator.ts @@ -0,0 +1,14 @@ +import { Request } from '@h3ravel/http' +import { Route } from '../Route' + +export class MethodValidator { + /** + * Validate a given rule against a route and request. + * + * @param route + * @param request + */ + public matches (route: Route, request: Request) { + return route.methods.includes(request.getMethod().toLowerCase() as never) + } +} \ No newline at end of file diff --git a/packages/router/src/Matchers/SchemeValidator.ts b/packages/router/src/Matchers/SchemeValidator.ts new file mode 100644 index 00000000..514b25fb --- /dev/null +++ b/packages/router/src/Matchers/SchemeValidator.ts @@ -0,0 +1,20 @@ +import { Request } from '@h3ravel/http' +import { Route } from '../Route' + +export class SchemeValidator { + /** + * Validate a given rule against a route and request. + * + * @param route + * @param request + */ + public matches (route: Route, request: Request) { + if (route.httpOnly()) { + return !request.secure() + } else if (route.secure()) { + return request.secure() + } + + return true + } +} \ No newline at end of file diff --git a/packages/router/src/Matchers/UriValidator.ts b/packages/router/src/Matchers/UriValidator.ts new file mode 100644 index 00000000..92271587 --- /dev/null +++ b/packages/router/src/Matchers/UriValidator.ts @@ -0,0 +1,17 @@ +import { Request } from '@h3ravel/http' +import { Route } from '../Route' +import { Str } from '@h3ravel/support' + +export class UriValidator { + /** + * Validate a given rule against a route and request. + * + * @param route + * @param request + */ + public matches (route: Route, request: Request) { + const path = Str.rtrim(request.getPathInfo(), '/') || '/' + + return route.getCompiled()?.getRegex().test(decodeURIComponent(path)) + } +} \ No newline at end of file diff --git a/packages/router/src/Middleware/.gitkeep b/packages/router/src/Middleware/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/router/src/Middleware/SubstituteBindings.ts b/packages/router/src/Middleware/SubstituteBindings.ts new file mode 100644 index 00000000..be5c3106 --- /dev/null +++ b/packages/router/src/Middleware/SubstituteBindings.ts @@ -0,0 +1,41 @@ +import { IApplication, IRouter } from '@h3ravel/contracts' +import { Middleware, Request } from '@h3ravel/http' + +export class SubstituteBindings extends Middleware { + /** + * + * @param router The router instance. + */ + constructor(protected app: IApplication, protected router: IRouter) { + super(app) + } + + /** + * Handle an incoming request. + * + * @param request + * @param next + */ + async handle (request: Request, next: (request: Request) => Promise) { + + const route = request.route() + + try { + this.router.substituteBindings(route) + this.router.substituteImplicitBindings(route) + } catch (e) { + const { ModelNotFoundException } = await import('@h3ravel/database') + + if (e instanceof ModelNotFoundException) { + const getMissing = route.getMissing() + if (typeof getMissing !== 'undefined') { + return getMissing(request, e) + } + + throw e + } + } + + return next(request) + } +} diff --git a/packages/router/src/MiddlewareResolver.ts b/packages/router/src/MiddlewareResolver.ts new file mode 100644 index 00000000..ccf680b7 --- /dev/null +++ b/packages/router/src/MiddlewareResolver.ts @@ -0,0 +1,92 @@ +import { IApplication, MiddlewareIdentifier } from '@h3ravel/contracts' + +import { MiddlewareList } from 'packages/contracts/dist' + +type MiddlewareMap = Record +type MiddlewareGroups = Record + +export class MiddlewareResolver { + static app: IApplication + + static setApp (app: IApplication) { + this.app = app + return this + } + + /** + * Resolve the middleware name to a class name(s) preserving passed parameters. + */ + static resolve ( + name: MiddlewareIdentifier, + map: MiddlewareMap, + middlewareGroups: MiddlewareGroups + ): MiddlewareIdentifier | MiddlewareList { + /** + * Inline middleware (closure) + */ + if (typeof name !== 'string') { + return name + } + + /** + * Mapped closure + */ + if (map[name] && typeof map[name] === 'function') { + return map[name] + } + + /** + * Middleware group + */ + if (middlewareGroups[name]) { + return this.parseMiddlewareGroup(name, map, middlewareGroups) + } + + /** + * Parse name + parameters + */ + const [base, parameters] = name.split(':', 2) + + const resolved = map[base] ?? base + + return parameters ? `${resolved}:${parameters}` : resolved + } + + /** + * Parse the middleware group and format it for usage. + */ + protected static parseMiddlewareGroup ( + name: string, + map: MiddlewareMap, + middlewareGroups: MiddlewareGroups + ): MiddlewareList { + const results: MiddlewareList = [] + + for (const middleware of middlewareGroups[name]) { + /** + * Nested group + */ + if (typeof middleware === 'string' && middlewareGroups[middleware]) { + results.push(...this.parseMiddlewareGroup(middleware, map, middlewareGroups)) + continue + } + + let resolved: MiddlewareIdentifier = '' + let parameters: string = '' + + if (typeof middleware === 'string') { + const base = middleware.split(':', 2)[0] + parameters = middleware.split(':', 2)[1] + + resolved = map[base] ?? base + + results.push(parameters ? `${String(resolved)}:${parameters}` : String(resolved)) + } else { + results.push(middleware) + } + + } + + return results + } +} diff --git a/packages/router/src/Pipeline.ts b/packages/router/src/Pipeline.ts new file mode 100644 index 00000000..004190c6 --- /dev/null +++ b/packages/router/src/Pipeline.ts @@ -0,0 +1,232 @@ +import { Container, ContainerResolver } from '@h3ravel/core' + +import { CallableConstructor } from '@h3ravel/contracts' +import { Pipe } from './Contracts/Pipeline' +import { RuntimeException } from '@h3ravel/support' + +export class Pipeline { + /** + * The final callback to be executed after the pipeline ends regardless of the outcome. + */ + finally?: (...args: any[]) => any + + /** + * Indicates whether to wrap the pipeline in a database transaction. + */ + protected withinTransaction?: string | false = false + + /** + * The container implementation. + */ + protected container?: Container + + /** + * The object being passed through the pipeline. + */ + private passable!: XP + + /** + * The array of class pipes. + */ + private pipes: Pipe[] = [] + + /** + * The method to call on each pipe. + */ + protected method = 'handle' + + constructor(app?: Container) { + this.container = app + } + + /** + * Set the method to call on the pipes. + * + * @param method + */ + via (method: string) { + this.method = method + + return this + } + + send (passable: XP) { + this.passable = passable + return this + } + + through (pipes: any[]) { + this.pipes = pipes + return this + } + + /** + * Run the pipeline with a final destination callback. + * + * @param destination + */ + async then (destination: (passable: XP) => Promise): Promise { + const pipes = [...this.pipes].reverse() + // Build the pipeline chain using reduce (mirrors Laravel’s array_reduce) + const pipeline = pipes.reduce( + this.carry(), + this.prepareDestination(destination), + ) + + try { + if (this.withinTransaction !== false) { + const connection = this.getContainer() + .make('db') + .connection(this.withinTransaction) + + return await connection.transaction(async () => { + return pipeline(this.passable) + }) + } + + // Normal flow + return await pipeline(this.passable) + } catch (e: any) { + console.log('Pipeline Error:', e) + throw e + } finally { + if (this.finally) { + (this.finally)(this.passable) + } + } + } + + /** + * Run the pipeline and return the result. + */ + async thenReturn () { + return await this.then(async function (passable) { + return passable + }) + } + + private carry () { + return (stack: (passable: XP) => Promise, pipe: Pipe) => { + return async (passable: XP) => { + try { + // pipe is a callable middleware fn + if (typeof pipe === 'function' && ContainerResolver.isCallable(pipe)) { + return await pipe(passable, stack) + } + + let instance = pipe as Exclude + let parameters: any[] = [passable, stack] + + // If pipe is a string (class reference) + if (typeof pipe === 'string') { + const [name, extras] = this.parsePipeString(pipe) + const bound = this.getContainer().boundMiddlewares(name) + instance = this.getContainer().make(bound as never) + parameters = [passable, stack, ...extras] + + // Pipe is an object instance + } else if (typeof pipe === 'function') { + instance = this.getContainer().make(pipe) + } + + const handler: CallableConstructor = instance[this.method as never] ?? instance + const result = await handler.apply(instance, parameters) + + return await this.handleCarry(result) + + } catch (e: any) { + return this.handleException(passable, e) + } + } + } + } + + private async handleCarry (carry: any) { + if (typeof carry?.then === 'function') { + return await carry + } + + return carry + } + + /** + * Get the final piece of the Closure onion. + * + * @param destination + */ + private prepareDestination (destination: (passable: XP) => Promise) { + return async (passable: XP) => { + try { + return await destination(passable ?? this.passable) + } catch (e: any) { + return this.handleException(passable ?? this.passable, e) + } + } + } + + /** + * Handle the given exception. + * + * @param _passable + * @param e + * @throws {Error} + */ + protected handleException (_passable: any, e: Error) { + throw e + } + + /** + * Parse full pipe string to get name and parameters. + * + * @param pipe + */ + private parsePipeString (pipe: string): [string, any[]] { + const [name, paramString] = pipe.split(':') + const params = paramString ? paramString.split(',') : [] + return [name, params] + } + + /** + * Set the container instance. + * + * @param container + */ + setContainer (container: Container) { + this.container = container + + return this + } + + /** + * Execute each pipeline step within a database transaction. + * + * @param withinTransaction + */ + setWithinTransaction (withinTransaction?: string | false) { + this.withinTransaction = withinTransaction + + return this + } + + /** + * Set a final callback to be executed after the pipeline ends regardless of the outcome. + * + * @param callback + */ + setFinally (callback: (...args: any[]) => any) { + this.finally = callback + + return this + } + + /** + * Get the container instance. + */ + protected getContainer () { + if (!this.container) { + throw new RuntimeException('A container instance has not been passed to the Pipeline.') + } + + return this.container + } +} diff --git a/packages/router/src/Providers/RouteServiceProvider.ts b/packages/router/src/Providers/RouteServiceProvider.ts index d7fde5bb..7cbf149f 100644 --- a/packages/router/src/Providers/RouteServiceProvider.ts +++ b/packages/router/src/Providers/RouteServiceProvider.ts @@ -1,7 +1,9 @@ +import { IRouter } from '@h3ravel/contracts' import { Logger } from '@h3ravel/shared' import { RouteListCommand } from '../Commands/RouteListCommand' import { Router } from '../Router' import { ServiceProvider } from '@h3ravel/core' +import { SubstituteBindings } from '../Middleware/SubstituteBindings' import path from 'node:path' import { readdir } from 'node:fs/promises' @@ -18,11 +20,21 @@ export class RouteServiceProvider extends ServiceProvider { public static priority = 997 register () { - this.app.singleton('router', () => { + this.app.bindMiddleware('SubstituteBindings', SubstituteBindings) + + this.booted(() => { + const router = this.app.make(IRouter) + if (typeof router.getRoutes === 'function') { + router.getRoutes().refreshActionLookups() + router.getRoutes().refreshNameLookups() + } + }) + + const router = () => { try { const h3App = this.app.make('http.app') - return new Router(h3App, this.app) + return new Router(h3App, this.app as never) } catch (error: any) { if (String(error.message).includes('http.app')) Logger.log([ @@ -33,7 +45,11 @@ export class RouteServiceProvider extends ServiceProvider { else Logger.log(error, 'white') } return {} as Router - }) + } + + this.app.singleton('router', router) + this.app.alias(Router, 'router') + this.app.alias(IRouter, 'router') this.registerCommands([RouteListCommand]) } @@ -42,23 +58,32 @@ export class RouteServiceProvider extends ServiceProvider { * Load routes from src/routes */ async boot () { + await this.loadRoutes() + } + + /** + * Load the application routes. + */ + protected async loadRoutes () { try { const routePath = this.app.getPath('routes') const files = (await readdir(routePath)).filter((e) => { - return !e.includes('.d.ts') && !e.includes('.d.cts') && !e.includes('.map') + return !e.includes('.d.') && !e.includes('.map') }) - for (let i = 0; i < files.length; i++) { - const routesModule = await import(path.join(routePath, files[i])) + for (const file of files) { + const { default: route } = await import(path.join(routePath, file)) - if (typeof routesModule.default === 'function') { + if (typeof route === 'function') { const router = this.app.make('router') - routesModule.default(router) + route(router) } } } catch (e: any) { - Logger.log([['No auto discorvered routes.', 'white'], [e.message, ['grey', 'italic']]], '\n') + if (!this.app.runningUnitTests()) { + Logger.log([['No auto discorvered routes.', 'white'], [e.message, ['grey', 'italic']]], '\n') + } } } } diff --git a/packages/router/src/Route.ts b/packages/router/src/Route.ts new file mode 100644 index 00000000..c50508b5 --- /dev/null +++ b/packages/router/src/Route.ts @@ -0,0 +1,713 @@ +import { ActionInput, CallableConstructor, ControllerMethod, IController, IControllerDispatcher, IRoute, ResponsableType, RouteActions, RouteMethod } from '@h3ravel/contracts' +import { Application, Container } from '@h3ravel/core' +import { Arr, Obj, Str, isClass } from '@h3ravel/support' + +import { CallableDispatcher } from './CallableDispatcher' +import { CompiledRoute } from './CompiledRoute' +import { ControllerDispatcher } from './ControllerDispatcher' +import { H3 } from 'h3' +import { LogicException } from '@h3ravel/foundation' +import { Request } from '@h3ravel/http' +import { RouteAction } from './RouteAction' +import { RouteParameterBinder } from './RouteParameterBinder' +import { RouteUri } from './RouteUri' +import { Router } from './Router' + +export class Route extends IRoute { + /** + * The URI pattern the route responds to. + */ + #uri: string + + /** + * The the matched parameters' original values object. + */ + #originalParameters?: Record + + /** + * The parameter names for the route. + */ + #parameterNames?: string[] + + /** + * The default values for the route. + */ + _defaults: Record = {} + + /** + * The router instance used by the route. + */ + protected router!: Router + + /** + * The compiled version of the route. + */ + compiled?: CompiledRoute = undefined + + /** + * The matched parameters object. + */ + parameters?: Record + + /** + * The container instance used by the route. + */ + protected container!: Application + + /** + * The fields that implicit binding should use for a given parameter. + */ + protected bindingFields!: Record + + /** + * The route action array. + */ + action: RouteActions + + /** + * The HTTP methods the route responds to. + */ + methods: RouteMethod[] + + /** + * The route path that can be handled by H3. + */ + path: string = '' + + /** + * The computed gathered middleware. + */ + computedMiddleware?: any[] + + /** + * The controller instance. + */ + controller?: Required + + /** + * + * @param methods The HTTP methods the route responds to. + * @param uri The URI pattern the route responds to. + */ + constructor( + methods: RouteMethod | RouteMethod[], + uri: string, + action: ActionInput + ) { + super() + this.#uri = uri + this.methods = Arr.wrap(methods) + this.action = Arr.except(this.parseAction(action), ['prefix']) + + if (this.methods.includes('GET') && !this.methods.includes('HEAD')) { + this.methods.push('HEAD') + } + + this.prefix(Obj.isPlainObject(action) ? Obj.get(action as any, 'prefix') : '') + } + + /** + * Set the router instance on the route. + * + * @param router + */ + setRouter (router: Router): this { + this.router = router + + return this + } + + /** + * Set the container instance on the route. + * + * @param container + */ + setContainer (container: Application) { + this.container = container + + return this + } + + /** + * Set the URI that the route responds to. + * + * @param uri + */ + setUri (uri: string) { + this.#uri = this.parseUri(uri) + this.path = this.#uri + .replace(/\{([^}]+)\}/g, ':$1') + .replace(/:([^/]+)\?\s*$/, '*') + .replace(/:([^/]+)\?(?=\/|$)/g, ':$1') + return this + } + + /** + * Parse the route URI and normalize / store any implicit binding fields. + * + * @param uri + */ + protected parseUri (uri: string): string { + this.bindingFields = {} + + const parsed = RouteUri.parse(uri) + + this.bindingFields = parsed.bindingFields + + return parsed.uri + } + + /** + * Get the URI associated with the route. + */ + uri () { + return this.#uri + } + + /** + * Add a prefix to the route URI. + * + * @param prefix + */ + prefix (prefix: string) { + prefix ??= '' + + this.updatePrefixOnAction(prefix) + + const uri = Str.rtrim(prefix, '/') + '/' + Str.ltrim(this.#uri, '/') + + return this.setUri(uri !== '/' ? Str.trim(uri, '/') : uri) + } + + /** + * Update the "prefix" attribute on the action array. + * + * @param prefix + */ + protected updatePrefixOnAction (prefix: string) { + const newPrefix = Str.trim(Str.rtrim(prefix, '/') + '/' + Str.ltrim(this.action.prefix ?? '', '/'), '/') + + if (newPrefix) { + this.action.prefix = newPrefix + } + } + + /** + * Get the name of the route instance. + */ + getName () { + return this.action.as ?? undefined + } + + /** + * Add or change the route name. + * + * @param name + * + * @throws {InvalidArgumentException} + */ + name (name: string): this { + this.action.as = this.action.as ? this.action.as + name : name + return this + } + + /** + * Determine whether the route's name matches the given patterns. + * + * @param patterns + */ + named (...patterns: string[]) { + const routeName = this.getName() + + if (!routeName) return false + + for (const pattern of patterns) + if (Str.is(pattern, routeName)) return true + + return false + } + + /** + * Get the action name for the route. + */ + getActionName () { + return this.action.handler ?? 'Closure' + } + + /** + * Get the method name of the route action. + * + * @return string + */ + getActionMethod () { + const name = this.getActionName() + return typeof name === 'string' ? Arr.last(name.split('@')) : name.name + } + + /** + * Get the action array or one of its properties for the route. + * @param key + */ + getAction (key?: string) { + if (!key) return this.action + + return Obj.get(this.action, key) + } + + /** + * Determine if the route only responds to HTTP requests. + */ + httpOnly () { + return Obj.has(this.action, 'http') + } + + /** + * Get or set the middlewares attached to the route. + * + * @param middleware + */ + middleware (middleware?: X | X[]): X extends undefined ? any : this { + if (!middleware) + return Arr.wrap(this.action.middleware ?? []) as never + + if (!Array.isArray(middleware)) + middleware = Arr.wrap(middleware) + + for (let index = 0; index < middleware.length; index++) { + const value = middleware[index] + middleware[index] = value + } + + this.action.middleware = [...Arr.wrap(this.action.middleware ?? []), ...middleware] as never + + return this + } + + /** + * Specify that the "Authorize" / "can" middleware should be applied to the route with the given options. + * + * @param ability + * @param models + */ + can (ability: string, models: string | string[] = []) { + return !models + ? this.middleware(['can:' + ability]) + : this.middleware(['can:' + ability + ',' + Arr.wrap(models).join(',')]) + } + + /** + * Set the action array for the route. + * + * @param action + */ + setAction (action: RouteActions) { + this.action = action + + if (this.action.domain) { + this.domain(this.action.domain) + } + + if (this.action.can) { + for (const can of this.action.can) { + this.can(can[0], can[1] ?? []) + } + } + + return this + } + + /** + * Determine if the route only responds to HTTPS requests. + */ + secure () { + return this.action.https === true + } + + /** + * Sync the current route with H3 + * + * @param h3App + */ + sync (h3App: H3) { + for (const method of this.methods) { + h3App[method.toLowerCase() as Lowercase](this.getPath(), () => response) + } + } + + /** + * Bind the route to a given request for execution. + * + * @param request + */ + bind (request: Request) { + this.compileRoute() + + this.parameters = (new RouteParameterBinder(this)).parameters(request) + + this.#originalParameters = this.parameters + + return this + } + + /** + * Get or set the domain for the route. + * + * @param domain + * + * @throws {InvalidArgumentException} + */ + domain (domain?: D): D extends undefined ? string : this { + if (!domain) return this.getDomain() as never + + const parsed = RouteUri.parse(domain) + + this.action.domain = parsed.uri ?? '' + + this.bindingFields = Object.assign({}, this.bindingFields, parsed.bindingFields) + + return this as never + } + + /** + * Parse the route action into a standard array. + * + * @param action + * + * @throws {UnexpectedValueException} + */ + protected parseAction (action: ActionInput) { + return RouteAction.parse(this.#uri, action) + } + + /** + * Run the route action and return the response. + */ + async run (): Promise { + this.container ??= new Container() as never + + try { + if (this.isControllerAction()) { + return await this.runController() + } + + return this.runCallable() + } catch (e) { + console.log(e) + return e.getResponse() + } + } + + /** + * Get the key / value list of parameters without empty values. + */ + parametersWithoutNulls () { + return Object.fromEntries(Object.entries(this.getParameters()).filter(e => !!e)) + } + + /** + * Get the key / value list of original parameters for the route. + * + * @throws {LogicException} + */ + originalParameters () { + if (this.#originalParameters) { + return this.#originalParameters + } + + throw new LogicException('Route is not bound.') + } + + /** + * Get the matched parameters object. + */ + getParameters () { + return this.parameters ?? {} + } + + /** + * Get a given parameter from the route. + * + * @param name + * @param defaultParam + */ + parameter (name: string, defaultParam?: any) { + return Obj.get(this.getParameters(), name, defaultParam) + } + + /** + * Get the domain defined for the route. + */ + getDomain (): string | undefined { + if (this.action && this.action.domain) { + return this.action.domain.replace(/https?:\/\//, '') + } + + return '' + } + + /** + * Get the compiled version of the route. + */ + getCompiled () { + return this.compiled + } + + /** + * Set a default value for the route. + * + * @param key + * @param value + */ + defaults (key: string, value: any) { + this._defaults[key] = value + + return this + } + + /** + * Set the default values for the route. + * + * @param defaults + */ + setDefaults (defaults: Record) { + this._defaults = defaults + + return this + } + + /** + * Get the optional parameter names for the route. + */ + getOptionalParameterNames (): Record { + const matches = [...this.uri().matchAll(/\{([\w:]+?)\??\}/g)] + if (!matches.length) return {} + + const result: Record = {} + for (const match of matches) { + result[match[1]] = null + } + + return result + } + + /** + * Get all of the parameter names for the route. + */ + parameterNames () { + if (this.#parameterNames) { + return this.#parameterNames + } + + return this.#parameterNames = this.compileParameterNames() + } + + /** + * Checks whether the route's action is a controller. + */ + protected isControllerAction () { + return !!this.action.uses && isClass(this.action.uses) + } + + protected compileParameterNames (): string[] { + const pattern = /\{([\w:]+?)\??\}/g + const fullUri = (this.getDomain() ?? '') + this.uri + const matches = [...fullUri.matchAll(pattern)] + + return matches.map(m => m[1]) + } + + /** + * Compile the route once, cache the result, return compiled data + */ + compileRoute (): CompiledRoute { + if (!this.compiled) { + const optionalParams = this.getOptionalParameterNames() + const paramNames: string[] = [] + + // extract all param names in order + this.uri().replace(/\{([\w:]+?)\??\}/g, (_, paramName) => { + paramNames.push(paramName) + return '' + }) + + this.compiled = new CompiledRoute(this.uri(), paramNames, optionalParams) + } + + return this.compiled + } + + /** + * Get the value of the action that should be taken on a missing model exception. + */ + getMissing (): CallableConstructor | undefined { + return this.action['missing'] ?? undefined + } + + /** + * The route path that can be handled by H3. + */ + getPath (): string { + return this.path + } + + /** + * Define the callable that should be invoked on a missing model exception. + * + * @param missing + */ + missing (missing: CallableConstructor): this { + this.action['missing'] = missing + + return this + } + + /** + * Specify middleware that should be removed from the given route. + * + * @param middleware + */ + withoutMiddleware (middleware: any): this { + this.action.excluded_middleware = Object.assign({}, + this.action.excluded_middleware ?? {}, + Arr.wrap(middleware) + ) + + return this + } + + /** + * Get the middleware that should be removed from the route. + */ + excludedMiddleware (): any { + return this.action.excluded_middleware ?? {} + } + + /** + * Get all middleware, including the ones from the controller. + */ + gatherMiddleware () { + if (this.computedMiddleware) { + return this.computedMiddleware + } + + this.computedMiddleware = [] + + return this.computedMiddleware = Router.uniqueMiddleware([...this.middleware(), ...this.controllerMiddleware()]) + } + + /** + * Get the dispatcher for the route's controller. + * + * @throws {BindingResolutionException} + */ + controllerDispatcher () { + if (this.container.bound(IControllerDispatcher)) { + return this.container.make(IControllerDispatcher) + } + + return new ControllerDispatcher(this.container) + } + + /** + * Run the route action and return the response. + * + * @return mixed + * @throws {NotFoundHttpException} + */ + protected async runController () { + return await this.controllerDispatcher().dispatch( + this, + this.getController()!, + this.getControllerMethod() + ) + } + + protected async runCallable () { + const callable = this.action.uses + + return new CallableDispatcher(this.container).dispatch(this, callable) + } + + /** + * Get the controller instance for the route. + * + * @return mixed + * + * @throws {BindingResolutionException} + */ + getController () { + if (!this.isControllerAction()) { + return undefined + } + + if (!this.controller) { + const instance = this.getControllerClass() + + this.controller = this.container.make(instance) + } + + return this.controller + } + + /** + * Flush the cached container instance on the route. + */ + flushController () { + this.computedMiddleware = undefined + this.controller = undefined + } + + /** + * Get the controller class used for the route. + */ + getControllerClass () { + return this.isControllerAction() ? this.action.uses : undefined + } + + /** + * Get the controller method used for the route. + */ + getControllerMethod (): ControllerMethod { + const holder = isClass(this.action.uses) && typeof this.action.controller === 'string' ? this.action.controller : 'index' + return Str.parseCallback(holder)[1] as ControllerMethod + } + + /** + * Get the middleware for the route's controller. + * + * @return array + */ + controllerMiddleware () { + let controllerClass: string | undefined, controllerMethod: string | undefined + + if (!this.isControllerAction()) { + return [] + } + + if (typeof this.action.uses === 'string') { + [controllerClass, controllerMethod] = [ + this.getControllerClass(), + this.getControllerMethod(), + ] + } else { + // + } + + console.log(controllerClass, controllerMethod, this.action, 'controllerMiddleware') + // if (is_a(controllerClass, HasMiddleware.lass, true)) { + // return this.staticallyProvidedControllerMiddleware( + // controllerClass, + // controllerMethod + // ) + // } + + // if (method_exists(Object.prototype.hasOwnProperty.call(controllerClass, 'getMiddleware')) { + // return this.controllerDispatcher().getMiddleware( + // this.getController(), + // controllerMethod + // ) + // } + + return [] + } +} \ No newline at end of file diff --git a/packages/router/src/RouteAction.ts b/packages/router/src/RouteAction.ts new file mode 100644 index 00000000..74077c09 --- /dev/null +++ b/packages/router/src/RouteAction.ts @@ -0,0 +1,149 @@ +import type { ActionInput, IController, RouteActions } from '@h3ravel/contracts' + +import { LogicException } from '@h3ravel/foundation' +import { UnexpectedValueException } from '@h3ravel/http' + +export class RouteAction { + /** + * The route action array. + */ + private static action: RouteActions = {} + + static parse (uri: string, action?: ActionInput): RouteActions { + /** + * If no action was provided return the missing action error handler + */ + if (!action) { + return this.missingAction(uri) + } + + /** + * Handle closure + */ + if (typeof action === 'function' && !this.isClass(action)) { + return { uses: action } + } + + /** + * Handle Controller class + */ + if (this.isClass(action)) { + return { + uses: action, + controller: action.name + '@index', + } + } + + /** + * Handle [Controller, method] map + */ + if (Array.isArray(action)) { + const [uses, method] = action + + if (!this.isClass(uses)) { + throw new LogicException( + `Invalid controller reference for route: ${uri}` + ) + } + + return { + uses, + controller: uses.name + '@' + method, + } + } + + /** + * Handle an object with "uses" property + */ + if (typeof action === 'object' && (action as RouteActions).uses) { + this.action = action + + return this.normalizeUses((action as RouteActions).uses, uri) + } + + throw new LogicException( + `Unrecognized route action for URI: ${uri}` + ) + } + + /** + * Normalize the "uses" field + */ + private static normalizeUses (uses: any, uri: string): RouteActions { + /** + * uses: function + */ + if (typeof uses === 'function' && !this.isClass(uses)) { + return { ...this.action, uses } + } + + /** + * uses: Controller + */ + if (this.isClass(uses)) { + return { + ...this.action, + uses: this.action, + controller: this.action.name + '@index', + } + } + + /** + * uses: [Controller, 'method'] + */ + if (Array.isArray(uses)) { + const [controller, method] = uses + + if (!this.isClass(controller)) { + throw new LogicException( + `Invalid controller reference in 'uses' for route: ${uri}` + ) + } + + return { + ...this.action, + uses: controller as never, + controller: controller.name + '@' + method, + } + } + + throw new LogicException( + `Invalid 'uses' value for route: ${uri}` + ) + } + + /** + * Missing action fallback + */ + private static missingAction (uri: string): RouteActions { + return { + handler: () => { + throw new LogicException( + `Route for [${uri}] has no action.` + ) + }, + } + } + + /** + * Make an action for an invokable controller. + * + * @param action + * + * @throws {UnexpectedValueException} + */ + protected static makeInvokable (action: IController) { + if (!action['__invoke']) { + throw new UnexpectedValueException(`Invalid route action: [${action}].`) + } + + return action['__invoke'] + } + + /** + * Detect if a value is a class constructor + */ + private static isClass (value: any): value is typeof IController { + return typeof value === 'function' && value.prototype && value.prototype.constructor === value + } +} \ No newline at end of file diff --git a/packages/router/src/RouteCollection.ts b/packages/router/src/RouteCollection.ts new file mode 100644 index 00000000..fa07913d --- /dev/null +++ b/packages/router/src/RouteCollection.ts @@ -0,0 +1,197 @@ +import type { IRouteCollection, RouteActions } from '@h3ravel/contracts' + +import { AbstractRouteCollection } from './AbstractRouteCollection' +import { Request } from '@h3ravel/http' +import { Route } from './Route' + +export class RouteCollection extends AbstractRouteCollection implements IRouteCollection { + /** + * An array of the routes keyed by method. + */ + protected routes: Record> = {} + + /** + * A flattened array of all of the routes. + */ + protected allRoutes: Record = {} + + /** + * A look-up table of routes by their names. + */ + protected nameList: Record = {} + + /** + * A look-up table of routes by controller action. + */ + protected actionList: Record = {} + + /** + * Add a Route instance to the collection. + */ + public add (route: Route): Route { + this.addToCollections(route) + + this.addLookups(route) + + return route + } + + /** + * Add the given route to the arrays of routes. + */ + protected addToCollections (route: Route): void { + const domainAndUri = `${route.getDomain()}${route.uri()}` + for (const method of route.methods) { + if (!this.routes[method]) { + this.routes[method] = {} + } + this.routes[method][domainAndUri] = route + } + + this.allRoutes[route.methods.join('|') + domainAndUri] = route + } + + /** + * Add the route to any look-up tables if necessary. + */ + protected addLookups (route: Route): void { + // Name lookup + const name = route.getName() + if (name && !this.inNameLookup(name)) { + this.nameList[name] = route + } + + // Controller action lookup + const action = route.getAction() + + const controller = action.controller ?? null + + if (controller && !this.inActionLookup(controller)) { + this.addToActionList(action, route) + } + } + + /** + * Add a route to the controller action dictionary. + */ + protected addToActionList (action: RouteActions, route: Route): void { + const key = (typeof action.controller === 'string' ? action.controller : action.controller?.constructor.name) ?? '' + + if (key) { + this.actionList[key] = route + } + } + + /** + * Determine if the given controller is in the action lookup table. + */ + protected inActionLookup (controller: string): boolean { + return Object.prototype.hasOwnProperty.call(this.actionList, controller) + } + + /** + * Determine if the given name is in the name lookup table. + */ + protected inNameLookup (name: string): boolean { + return Object.prototype.hasOwnProperty.call(this.nameList, name) + } + + /** + * Refresh the name look-up table. + * + * This is done in case any names are fluently defined or if routes are overwritten. + */ + public refreshNameLookups (): void { + this.nameList = {} + + for (const key of Object.keys(this.allRoutes)) { + const route = this.allRoutes[key] + const name = route.getName() + if (name && !this.inNameLookup(name)) { + this.nameList[name] = route + } + } + } + + /** + * Refresh the action look-up table. + * + * This is done in case any actions are overwritten with new controllers. + */ + public refreshActionLookups (): void { + this.actionList = {} + + for (const key of Object.keys(this.allRoutes)) { + const route = this.allRoutes[key] + const controller = route.getAction().controller ?? null + if (controller && !this.inActionLookup(controller)) { + this.addToActionList(route.getAction(), route) + } + } + } + + /** + * Find the first route matching a given request. + * + * May throw framework-specific exceptions (MethodNotAllowed / NotFound). + */ + public match (request: Request): Route { + const routesByMethod = this.get(request.getMethod()) as Record + + const route = this.matchAgainstRoutes(routesByMethod, request) + return this.handleMatchedRoute(request, route) + } + + /** + * Get routes from the collection by method. + */ + public get (method?: string): Record | Route[] { + if (typeof method === 'undefined' || method === null) { + return this.getRoutes() + } + + return this.routes[method] ?? {} + } + + /** + * Determine if the route collection contains a given named route. + */ + public hasNamedRoute (name: string): boolean { + return this.getByName(name) !== null + } + + /** + * Get a route instance by its name. + */ + public getByName (name: string): Route | null { + return this.nameList[name] ?? null + } + + /** + * Get a route instance by its controller action. + */ + public getByAction (action: string): Route | null { + return this.actionList[action] ?? null + } + + /** + * Get all of the routes in the collection. + */ + public getRoutes (): Route[] { + return Object.values(this.allRoutes) + } + + /** + * Get all of the routes keyed by their HTTP verb / method. + */ + public getRoutesByMethod (): Record> { + return this.routes + } + + /** + * Get all of the routes keyed by their name. + */ + public getRoutesByName (): Record { + return this.nameList + } +} \ No newline at end of file diff --git a/packages/router/src/RouteGroup.ts b/packages/router/src/RouteGroup.ts new file mode 100644 index 00000000..105ad558 --- /dev/null +++ b/packages/router/src/RouteGroup.ts @@ -0,0 +1,93 @@ +import { Arr, Obj, Str } from '@h3ravel/support' + +import { RouteActions } from '@h3ravel/shared' + +export class RouteGroup { + /** + * Merge route groups into a new array. + * + * @param newAct + * @param old + * @param prependExistingPrefix + */ + public static merge (newAct: RouteActions, old: RouteActions, prependExistingPrefix = true): RouteActions { + if (newAct.domain) { + delete old.domain + } + + if (newAct.controller) { + delete old.controller + } + + newAct = Object.assign(RouteGroup.formatAs(newAct, old), { + namespace: RouteGroup.formatNamespace(newAct, old), + prefix: RouteGroup.formatPrefix(newAct, old, prependExistingPrefix), + where: RouteGroup.formatWhere(newAct, old), + }) + + return Obj.deepMerge(Arr.except( + old, ['namespace', 'prefix', 'where', 'as'] + ), newAct) + } + + /** + * Format the namespace for the new group attributes. + * + * @param newAct + * @param old + */ + protected static formatNamespace (newAct: RouteActions, old: RouteActions) { + if (newAct.namespace) { + return !!old.namespace && !!newAct.namespace + ? Str.trim(old.namespace, '/') + '/' + Str.trim(newAct.namespace, '/') + : Str.trim(newAct.namespace, '/') + } + + return old.namespace ?? undefined + } + + /** + * Format the prefix for the new group attributes. + * + * @param newAct + * @param old + * @param prependExistingPrefix + */ + protected static formatPrefix (newAct: RouteActions, old: RouteActions, prependExistingPrefix = true) { + const prefix = old.prefix ?? '' + + if (prependExistingPrefix) { + return newAct.prefix ? Str.trim(prefix, '/') + '/' + Str.trim(newAct.prefix, '/') : prefix + } + + return newAct.prefix ? Str.trim(newAct['prefix'], '/') + '/' + Str.trim(prefix, '/') : prefix + } + + /** + * Format the "wheres" for the new group attributes. + * + * @param newAct + * @param old + */ + protected static formatWhere (newAct: RouteActions, old: RouteActions) { + return Object.assign({}, + old.where ?? {}, + newAct.where ?? {} + ) + } + + /** + * Format the "as" clause of the new group attributes. + * + * @param newAct + * @param old + * @param prependExistingPrefix + */ + protected static formatAs (newAct: RouteActions, old: RouteActions) { + if (old.as) { + newAct.as = old.as + (newAct.as ?? '') + } + + return newAct + } +} diff --git a/packages/router/src/RouteParameterBinder.ts b/packages/router/src/RouteParameterBinder.ts new file mode 100644 index 00000000..42ff62ef --- /dev/null +++ b/packages/router/src/RouteParameterBinder.ts @@ -0,0 +1,112 @@ +import { Obj } from '@h3ravel/support' +import { Request } from '@h3ravel/http' +import { Route } from './Route' + +export class RouteParameterBinder { + /** + * Create a new Route parameter binder instance. + * + * @param route The route instance. + */ + public constructor(protected route: Route) { + } + + /** + * Get the parameters for the route. + * + * @param request + */ + public parameters (request: Request) { + let parameters = this.bindPathParameters(request) + + // If the route has a regular expression for the host part of the URI, we will + // compile that and get the parameter matches for this domain. We will then + // merge them into this parameters array so that this array is completed. + if (this.route.compiled?.getHostRegex()) { + parameters = this.bindHostParameters( + request, parameters + ) + } + + return this.replaceDefaults(parameters) + } + + /** + * Get the parameter matches for the path portion of the URI. + * + * @param request + */ + protected bindPathParameters (request: Request): Record { + // ensure path starts with '/' + const path = '/' + (request.decodedPath().replace(/^\/+/, '')) + + const pathRegex = this.route.compiled?.getRegex() ?? '' + const matches = path.match(pathRegex) ?? [] + + // slice off full match and map to keys + return this.matchToKeys(matches.slice(1)) + } + + /** + * Extract the parameter list from the host part of the request. + * + * @param request + * @param parameters + */ + protected bindHostParameters (request: Request, parameters: Record): Record { + const host = request.getHost() + const hostRegex = this.route.compiled?.getHostRegex() ?? '' + const matches = host.match(hostRegex) ?? [] + + // slice off the full match (index 0) and map to keys + const bound = this.matchToKeys(matches.slice(1)) + + // merge with existing parameters + return { ...bound, ...parameters } + } + + /** + * Combine a set of parameter matches with the route's keys. + * + * @param matches + */ + protected matchToKeys (matches: string[]): Record { + const parameterNames = this.route.parameterNames() + if (!parameterNames || parameterNames.length === 0) { + return {} + } + + const parameters: Record = {} + + // map names to values in order + for (let i = 0; i < parameterNames.length; i++) { + const name = parameterNames[i] + const value = matches[i] + if (typeof value === 'string' && value.length > 0) { + parameters[name] = value + } + } + + return parameters + } + + /** + * Replace null parameters with their defaults. + * + * @param parameters + * @return array + */ + protected replaceDefaults (parameters: Record) { + for (const [key, value] of Object.entries(parameters)) { + parameters[key] = value ?? Obj.get(this.route._defaults, key) + } + + for (const [key, value] of Object.entries(this.route._defaults)) { + if (!parameters[key]) { + parameters[key] = value + } + } + + return parameters + } +} \ No newline at end of file diff --git a/packages/router/src/RouteUri.ts b/packages/router/src/RouteUri.ts new file mode 100644 index 00000000..09ee9de5 --- /dev/null +++ b/packages/router/src/RouteUri.ts @@ -0,0 +1,56 @@ +export class RouteUri { + /** + * The route URI. + */ + public uri: string + + /** + * The fields that should be used when resolving bindings. + */ + public bindingFields: Record = {} + + /** + * Create a new route URI instance. + * + * @param uri The route URI. + * @param bindingFields The fields that should be used when resolving bindings. + */ + public constructor(uri: string, bindingFields: Record = {}) { + this.uri = uri + this.bindingFields = bindingFields + } + + /** + * Parse the given URI. + * + * @param uri The route URI. + */ + static parse (uri: string) { + const regex = /\{([\w:]+?)\??\}/g + const matches = [...uri.matchAll(regex)] + + const bindingFields: Record = {} + + for (const match of matches) { + const fullMatch = match[0] + const inner = match[1] + + if (!inner.includes(':')) { + continue + } + + const segments = inner.split(':') + + bindingFields[segments[0]] = segments[1] + + const hasOptional = fullMatch.includes('?') + const replacement = hasOptional + ? `{${segments[0]}?}` + : `{${segments[0]}}` + + uri = uri.replace(fullMatch, replacement) + } + + return new RouteUri(uri, bindingFields) + } +} \ No newline at end of file diff --git a/packages/router/src/Router.ts b/packages/router/src/Router.ts index f2d96cd7..4a6da58f 100644 --- a/packages/router/src/Router.ts +++ b/packages/router/src/Router.ts @@ -2,22 +2,65 @@ import 'reflect-metadata' import { H3Event, Middleware, MiddlewareOptions, type H3 } from 'h3' import { Application, Container, Kernel } from '@h3ravel/core' import { Request, Response, HttpContext } from '@h3ravel/http' -import { Str } from '@h3ravel/support' -import { Resolver, RouteEventHandler } from '@h3ravel/shared' -import type { EventHandler, ExtractControllerMethods, IController, IMiddleware, IResponse, IRouter, RouterEnd } from '@h3ravel/shared' +import { Arr, Collection, isClass, Str, Stringable, tap } from '@h3ravel/support' +import { Dispatcher } from '@h3ravel/events' +import { FileSystem } from '@h3ravel/shared' +import { IMiddleware, IRequest, IResponse, IRouter, RouteActions, RouterEnd, ActionInput, MiddlewareList, MiddlewareIdentifier, ResponsableType } from '@h3ravel/contracts' +import type { EventHandler, ClassicRouteDefinition, ExtractClassMethods, IController } from '@h3ravel/contracts' import { Helpers } from './Helpers' -import { Model } from '@h3ravel/database' -import { RouteDefinition, RouteMethod } from '@h3ravel/shared' +import { RouteMethod, RouteEventHandler, IResponsable } from '@h3ravel/contracts' import { ExceptionHandler } from '@h3ravel/foundation' +import { Route } from './Route' +import { Routing } from './Events/Routing' +import { RouteMatched } from './Events/RouteMatched' +import { RouteCollection } from './RouteCollection' +import { RouteGroup } from './RouteGroup' +import { MiddlewareResolver } from './MiddlewareResolver' +import { PreparingResponse } from './Events/PreparingResponse' +import { ResponsePrepared } from './Events/ResponsePrepared' +import { Pipeline } from './Pipeline' export class Router implements IRouter { - private routes: RouteDefinition[] = [] - private nameMap: string[] = [] + private routes: ClassicRouteDefinition[] = [] + private routeNames: string[] = [] + private routePrefixes: string[] = [] private groupPrefix = '' + private current?: Route + private collection: RouteCollection + private currentRequest!: IRequest + + /** + * All of the short-hand keys for middlewares. + */ + #middleware: Record = {} + private middlewareMap: IMiddleware[] = [] private groupMiddleware: EventHandler[] = [] - constructor(protected h3App: H3, private app: Application) { } + /** + * All of the middleware groups. + */ + protected middlewareGroups: Record = {} + + /** + * The route group attribute stack. + */ + protected groupStack: Record[] = [] + + /** + * The event dispatcher instance. + */ + protected events: Dispatcher + + /** + * All of the verbs supported by the router. + */ + public static verbs: RouteMethod[] = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] + + constructor(protected h3App: H3, private app: Application) { + this.events = app.has('app.events') ? app.make('app.events') : undefined + this.collection = new RouteCollection() + } /** * Route Resolver @@ -29,7 +72,7 @@ export class Router implements IRouter { private resolveHandler (handler: EventHandler, middleware: IMiddleware[] = []) { return async (event: H3Event) => { this.app.context ??= async (event) => { - // If we’ve already attached the context to this event, reuse it + // Reuse the already attached context for this event if any if ((event as any)._h3ravelContext) return (event as any)._h3ravelContext @@ -37,7 +80,7 @@ export class Router implements IRouter { const ctx = HttpContext.init({ app: this.app, request: await Request.create(event, this.app), - response: new Response(event, this.app), + response: new Response(this.app, event), }, event); (event as any)._h3ravelContext = ctx @@ -60,6 +103,24 @@ export class Router implements IRouter { } } + /** + * Add a route to the stack + * + * @param method + * @param path + * @param handler + * @param name + * @param middleware + */ + #addRoute ( + methods: RouteMethod | RouteMethod[], + uri: string, + action: ActionInput + ): Route { + const route = this.collection.add(this.createRoute(methods, uri, action)) + return route + } + /** * Add a route to the stack * @@ -70,18 +131,18 @@ export class Router implements IRouter { * @param middleware */ private addRoute ( - method: RouteMethod, + method: Lowercase, path: string, handler: EventHandler, name?: string, middleware: IMiddleware[] = [], - signature: RouteDefinition['signature'] = ['', ''] + signature: ClassicRouteDefinition['signature'] = ['', ''] ) { /** * Join all defined route names to make a single route name */ - if (this.nameMap.length > 0) { - name = this.nameMap.join('.') + if (this.routeNames.length > 0) { + name = this.routeNames.join('.') } /** @@ -91,12 +152,133 @@ export class Router implements IRouter { middleware = this.middlewareMap } - const fullPath = `${this.groupPrefix}${path}`.replace(/\/+/g, '/') + /** + * Join all defined prefixes + */ + const prefix = this.routePrefixes.join('') + + const fullPath = `${this.groupPrefix}${prefix}${path}`.replace(/\/+/g, '/') this.routes.push({ method, path: fullPath, name, handler, signature }) + + /** + * Register Route as a H3 route + */ this.h3App[method](fullPath, this.resolveHandler(handler, middleware)) this.app.singleton('app.routes', () => this.routes) } + /** + * Get the currently dispatched route instance. + */ + public getCurrentRoute (): Route | undefined { + return this.current + } + + /** + * Check if a route with the given name exists. + * + * @param name + */ + public has (...name: string[]): boolean { + for (const value of name) { + if (!this.collection.hasNamedRoute(value)) { + return false + } + } + + return true + } + + /** + * Get the current route name. + */ + public currentRouteName (): string | undefined { + return this.current?.getName() + } + + /** + * Alias for the "currentRouteNamed" method. + * + * @param patterns + */ + public is (...patterns: string[]): boolean { + return this.currentRouteNamed(...patterns) + } + + /** + * Determine if the current route matches a pattern. + * + * @param patterns + */ + public currentRouteNamed (...patterns: string[]): boolean { + return !!this.current?.named(...patterns) + } + + /** + * Get the underlying route collection. + */ + public getRoutes (): RouteCollection { + return this.collection + } + + /** + * Determine if the action is routing to a controller. + * + * @param action + */ + protected actionReferencesController (action: ActionInput) { + if (typeof action !== 'function') { + return typeof action === 'string' || + (action && !Array.isArray(action) && (action as RouteActions).uses && typeof action === (action as RouteActions).uses) + } + + return false + } + + /** + * Create a new route instance. + * + * @param methods + * @param uri + * @param action + */ + protected createRoute (methods: RouteMethod | RouteMethod[], uri: string, action: ActionInput) { + // If the route is routing to a controller we will parse the route action into + // an acceptable array format before registering it and creating this route + // instance itself. We need to build the Closure that will call this out. + // if (this.actionReferencesController(action)) { + // action = this.convertToControllerAction(action) + // } + + const route = this.newRoute( + methods, this.prefix(uri), action + ) + + // If we have groups that need to be merged, we will merge them now after this + // route has already been created and is ready to go. After we're done with + // the merge we will be ready to return the route back out to the caller. + if (this.hasGroupStack()) { + this.mergeGroupAttributesIntoRoute(route) + } + + return route + } + + /** + * Create a new Route object. + * + * @param methods + * @param uri + * @param action + */ + public newRoute (methods: RouteMethod | RouteMethod[], uri: string, action: ActionInput) { + return new Route(methods, uri, action) + .setRouter(this) + .setContainer(this.app) + .setUri(uri) + // .sync(this.h3App) + } + /** * Resolves a route handler definition into an executable EventHandler. * @@ -111,7 +293,7 @@ export class Router implements IRouter { * @param handler Event handler function OR controller class * @param methodName Method to invoke on the controller (defaults to 'index') */ - private resolveControllerOrHandler any> ( + private resolveControllerOrHandler any> ( handler: EventHandler | C, methodName?: string, path?: string, @@ -121,6 +303,7 @@ export class Router implements IRouter { */ if (typeof handler === 'function' && typeof (handler as any).prototype !== 'undefined') { return async (ctx) => { + const { Model } = await import('@h3ravel/database') let controller: IController if (Container.hasAnyDecorator(handler as any)) { /** @@ -147,10 +330,18 @@ export class Router implements IRouter { throw new Error(`Method "${String(action)}" not found on controller ${handler.name}`) } + // const method = this.app.invoke(controller, action, [ctx], async (inst) => { + // if (inst instanceof Model) { + // // Route model binding returns a Promise + // return await Helpers.resolveRouteModelBinding(path ?? '', ctx, inst) + // } + // return inst + // }) + /** * Get param types for the controller method */ - const paramTypes: [] = Reflect.getMetadata('design:paramtypes', controller, action) || [] + const paramTypes: any[] = Reflect.getMetadata('design:paramtypes', controller, action) || [] /** * Resolve the bound dependencies @@ -188,7 +379,7 @@ export class Router implements IRouter { /** * Call the controller method, passing all resolved dependencies */ - return await this.handleResponse(async () => await controller[action]?.(...args), ctx) + return await this.handleResponse(async () => await (controller[action] as any)?.(...args), ctx) } } @@ -231,6 +422,235 @@ export class Router implements IRouter { } } + /** + * Dispatch the request to the application. + * + * @param request + */ + public async dispatch (request: Request) { + this.currentRequest = request + return await this.dispatchToRoute(request) + } + + /** + * Dispatch the request to a route and return the response. + * + * @param request + */ + public async dispatchToRoute (request: Request) { + return await this.runRoute(request, this.findRoute(request)) + } + + /** + * Find the route matching a given request. + * + * @param request + */ + protected findRoute (request: Request) { + this.events.dispatch(new Routing(request)) + + const route = this.collection.match(request) + + this.current = route + + route.setContainer(this.app) + + this.app.instance(Route, route) + + return route + } + + /** + * Return the response for the given route. + * + * @param request + * @param route + */ + protected async runRoute (request: Request, route: Route) { + request.setRouteResolver(() => route) + + this.events.dispatch(new RouteMatched(route, request)) + // console.log(route.methods, route.getPath(), 'route.methods') + const response = await this.prepareResponse(request, await this.runRouteWithinStack(route, request)) + + return response + } + + /** + * Run the given route within a Stack (onion) instance. + * + * @param route + * @param request + */ + protected async runRouteWithinStack (route: Route, request: Request) { + const shouldSkipMiddleware = this.app.bound('middleware.disable') && this.app.make('middleware.disable') === true + const middleware = shouldSkipMiddleware ? [] : this.gatherRouteMiddleware(route) + + return await (new Pipeline(this.app as never)) + .send(request) + .through(middleware) + .then(async (request) => { + return this.prepareResponse(request, await route.run()) + }) + } + + /** + * Get all of the defined middleware short-hand names. + * + * @return array + */ + getMiddleware () { + return this.#middleware + } + + /** + * Register a short-hand name for a middleware. + * + * @param name + * @param class + */ + aliasMiddleware (name: string, cls: IMiddleware) { + this.#middleware[name] = cls + + return this + } + + /** + * Gather the middleware for the given route with resolved class names. + * + * @param route + */ + public gatherRouteMiddleware (route: Route) { + return this.resolveMiddleware( + route.gatherMiddleware(), + route.excludedMiddleware() + ) + } + + /** + * Resolve a flat array of middleware classes from the provided array. + * + * @param middleware + * @param excluded + * @return array + */ + resolveMiddleware (middleware: IMiddleware[], excluded: any[] = []) { + excluded = excluded.length === 0 + ? excluded + : (new Collection(excluded)) + .map((name) => MiddlewareResolver.setApp(this.app).resolve(name, this.#middleware, this.middlewareGroups)) + .flatten() + .values() + .all() + + const middlewares = (new Collection(middleware)) + .map((name) => MiddlewareResolver.setApp(this.app).resolve(name, this.#middleware, this.middlewareGroups)) + .flatten() + + middlewares.when( + excluded.length > 0, + (collection) => { + collection.reject((name: any) => { + if (typeof name === 'function') { + return false + } + + if (excluded.includes(name)) { + return true + } + + if (!isClass(name)) { + return false + } + + const instance = this.app.make(name) + + return (new Collection(excluded)).contains( + (exclude: any) => isClass(exclude) && instance instanceof exclude + ) + }) + return collection + }, + ).values() + + return this.sortMiddleware(middlewares) + } + + /** + * Sort the given middleware by priority. + * + * @param \Illuminate\Support\Collection $middlewares + * @return array + */ + protected sortMiddleware (middlewares: Collection) { + return middlewares.all() + // TODO: Implement middleware sorting logic + // return (new SortedMiddleware(this.middlewarePriority, middlewares)).all() + } + + /** + * Register a group of middleware. + * + * @param name + * @param middleware + */ + middlewareGroup (name: string, middleware: MiddlewareList): this { + this.middlewareGroups[name] = middleware + + return this + } + + /** + * Create a response instance from the given value. + * + * @param request + * @param response + */ + async prepareResponse (request: IRequest, response: ResponsableType) { + this.events.dispatch(new PreparingResponse(request, response)) + + return tap(Router.toResponse(request, response), (response) => { + this.events.dispatch(new ResponsePrepared(request, response)) + }) + } + + /** + * Static version of prepareResponse. + * + * @param request + * @param response + */ + static toResponse (request: IRequest, response: ResponsableType) { + if (response instanceof IResponsable) { + response = response.toResponse(request) + } + + // if (response instanceof Model && response.wasRecentlyCreated) { + // response = new JsonResponse(response, 201) + // } + if (response instanceof Stringable || typeof response === 'string') { + response = new Response(request.app, response.toString(), 200, { 'Content-Type': 'text/html' }) + } else if (!(response instanceof IResponse) && !(response instanceof Response)) { + // TODO: Implement a universal feature to convert classes to string or at least extract stringable content from them + response = new Response(request.app, 'UNIMPLEMENTED', 200, { 'Content-Type': 'text/html' }) + } + + if (response.getStatusCode() === Response.codes.HTTP_NOT_MODIFIED) { + response.setNotModified() + } + + return response.prepare(request) + } + + /** + * Remove any duplicate middleware from the given array. + * + * @param middleware + */ + static uniqueMiddleware (middleware: MiddlewareList) { + return Array.from(new Set(middleware)) + } + /** * Registers a route that responds to HTTP GET requests. * @@ -243,7 +663,7 @@ export class Router implements IRouter { */ get any> ( path: string, - definition: RouteEventHandler | [C, methodName: ExtractControllerMethods>], + definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], name?: string, middleware: IMiddleware[] = [] ): Omit { @@ -276,7 +696,7 @@ export class Router implements IRouter { */ post any> ( path: string, - definition: RouteEventHandler | [C, methodName: ExtractControllerMethods>], + definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], name?: string, middleware: IMiddleware[] = [] ): Omit { @@ -309,7 +729,7 @@ export class Router implements IRouter { */ put any> ( path: string, - definition: RouteEventHandler | [C, methodName: ExtractControllerMethods>], + definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], name?: string, middleware: IMiddleware[] = [] ): Omit { @@ -341,7 +761,7 @@ export class Router implements IRouter { */ patch any> ( path: string, - definition: RouteEventHandler | [C, methodName: ExtractControllerMethods>], + definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], name?: string, middleware: IMiddleware[] = [] ): Omit { @@ -374,7 +794,7 @@ export class Router implements IRouter { */ delete any> ( path: string, - definition: RouteEventHandler | [C, methodName: ExtractControllerMethods>], + definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], name?: string, middleware: IMiddleware[] = [] ): Omit { @@ -422,6 +842,20 @@ export class Router implements IRouter { return this } + /** + * Registers a route the matches the provided methods. + * @param methods - The route methods to match. + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + */ + match ( + methods: Lowercase[], + uri: string, + action: ActionInput, + ): Route { + return this.#addRoute(Arr.wrap(methods).map(e => e.toUpperCase() as RouteMethod), uri, action) + } + /** * Named route URL generator * @@ -446,40 +880,144 @@ export class Router implements IRouter { * @param options * @param callback */ - group (options: { prefix?: string; middleware?: EventHandler[] }, callback: (_e: this) => void) { - const prevPrefix = this.groupPrefix - const prevMiddleware = [...this.groupMiddleware] + // group (options: { prefix?: string; middleware?: EventHandler[] }, callback: (_e: this) => void) { + // const prevPrefix = this.groupPrefix + // const prevMiddleware = [...this.groupMiddleware] - this.groupPrefix += options.prefix || '' - this.groupMiddleware.push(...(options.middleware || [])) + // this.groupPrefix += options.prefix || '' + // this.groupMiddleware.push(...(options.middleware || [])) - callback(this) + // callback(this) + + // /** + // * Restore state after group + // */ + // this.groupPrefix = prevPrefix + // this.groupMiddleware = prevMiddleware + // return this + // } + + /** + * Create a route group with shared attributes. + * + * @param attributes + * @param routes + * @return $this + */ + public group void) | string> (attributes: RouteActions, routes: C | C[]) { + for (const groupRoutes of Arr.wrap(routes)) { + this.updateGroupStack(attributes) + + // Once we have updated the group stack, we'll load the provided routes and + // merge in the group's attributes when the routes are created. After we + // have created the routes, we will pop the attributes off the stack. + this.loadRoutes(groupRoutes) + + this.groupStack.pop() + } - /** - * Restore state after group - */ - this.groupPrefix = prevPrefix - this.groupMiddleware = prevMiddleware return this } + /** + * Update the group stack with the given attributes. + * + * @param attributes + */ + protected updateGroupStack (attributes: RouteActions) { + if (this.hasGroupStack()) { + attributes = this.mergeWithLastGroup(attributes) + } + + this.groupStack.push(attributes) + } + + /** + * Merge the given array with the last group stack. + * + * @param newItems + * @param prependExistingPrefix + */ + public mergeWithLastGroup (newItems: RouteActions, prependExistingPrefix = true) { + return RouteGroup.merge(newItems, Arr.last(this.groupStack, true)[0], prependExistingPrefix) + } + + /** + * Load the provided routes. + * + * @param routes + */ + protected async loadRoutes (routes: string | ((_e: this) => void)) { + if (typeof routes === 'function') { + routes(this) + } else if (await FileSystem.fileExists(routes)) { + const { default: route } = await import(routes) + route(this) + } + } + + /** + * Get the prefix from the last group on the stack. + */ + public getLastGroupPrefix () { + if (this.hasGroupStack()) { + const last = Arr.last(this.groupStack, true)[0] + + return last.prefix ?? '' + } + + return '' + } + + /** + * Merge the group stack with the controller action. + * + * @param route + */ + protected mergeGroupAttributesIntoRoute (route: Route) { + route.setAction(this.mergeWithLastGroup( + route.getAction(), + false + )) + } + + /** + * Determine if the router currently has a group stack. + */ + public hasGroupStack () { + return this.groupStack.length > 0 + } + /** * Set the name of the current route * * @param name */ name (name: string) { - this.nameMap.push(name) + this.routeNames.push(name) return this } + /** + * Prefix the given URI with the last prefix. + * + * @param uri + */ + protected prefix (uri: string) { + return Str.trim(Str.trim(this.getLastGroupPrefix(), '/') + '/' + Str.trim(uri, '/'), '/') || '/' + } + /** * Registers middleware for a specific path. * @param path - The path to apply the middleware. * @param handler - The middleware handler. * @param opts - Optional middleware options. */ - middleware (path: string | IMiddleware[] | Middleware, handler: Middleware | MiddlewareOptions, opts?: MiddlewareOptions) { + middleware ( + path: string | IMiddleware[] | Middleware, + handler: Middleware | MiddlewareOptions, + opts?: MiddlewareOptions + ): this { opts = typeof handler === 'object' ? handler : (typeof opts === 'function' ? opts : {}) handler = typeof path === 'function' ? path : (typeof handler === 'function' ? handler : () => { }) diff --git a/packages/router/src/TraitLike/FiltersControllerMiddleware.ts b/packages/router/src/TraitLike/FiltersControllerMiddleware.ts new file mode 100644 index 00000000..47f91c61 --- /dev/null +++ b/packages/router/src/TraitLike/FiltersControllerMiddleware.ts @@ -0,0 +1,14 @@ +import { IMiddleware, RouteMethod } from '@h3ravel/contracts' + +export class FiltersControllerMiddleware { + /** + * Determine if the given options exclude a particular method. + * + * @param method + * @param options + */ + static methodExcludedByOptions (method: RouteMethod, options: IMiddleware['options']) { + return (typeof options.only !== 'undefined' && !options.only.includes(method)) || + (!!options.except && options.except.length > 0 && options.except.includes(method)) + } +} diff --git a/packages/router/src/TraitLike/RouteDependencyResolver.ts b/packages/router/src/TraitLike/RouteDependencyResolver.ts new file mode 100644 index 00000000..403fa7eb --- /dev/null +++ b/packages/router/src/TraitLike/RouteDependencyResolver.ts @@ -0,0 +1,72 @@ +import 'reflect-metadata' + +import { ControllerMethod, IController } from '@h3ravel/contracts' + +import { Application } from '@h3ravel/core' +import { LogicException } from '@h3ravel/foundation' + +export class RouteDependencyResolver { + constructor(protected container: Application) { } + + /** + * Resolve the object method's type-hinted dependencies. + * + * @param parameters + * @param instance + * @param method + */ + public async resolveClassMethodDependencies (parameters: Record, instance: IController, method: ControllerMethod) { + if (!Object.prototype.hasOwnProperty.call(instance, method)) { + return parameters + } + + /** + * Ensure the method exists on the controller + */ + if (typeof instance[method] !== 'function') { + throw new LogicException(`Method "${method}" not found on controller ${instance.constructor.name}`) + } + + /** + * Get param types for the controller method + */ + const paramTypes: [] = Reflect.getMetadata('design:paramtypes', instance, method) || [] + + /** + * Resolve the bound dependencies + */ + let args = await Promise.all( + paramTypes.map(async (paramType: any) => { + const inst = this.container.make(paramType) + // if (inst instanceof Model) { + // Route model binding returns a Promise + // return await Helpers.resolveRouteModelBinding(path ?? '', ctx, inst) + return inst + }) + ) + + /** + * Ensure that the HttpContext is always available + */ + if (args.length < 1) { + args = [this.container.make('http.context')] + } + + /** + * Call the controller method, passing all resolved dependencies + */ + return this.resolveMethodDependencies([...args, ...parameters]) + } + + /** + * Resolve the given method's type-hinted dependencies. + * + * @param parameters + */ + public resolveMethodDependencies (parameters: Record) { + /** + * Call the route callback handler + */ + return parameters + } +} diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 0eb4beca..71b26c63 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -1,6 +1,30 @@ +export * from './AbstractRouteCollection' +export * from './CallableDispatcher' export * from './Commands/RouteListCommand' +export * from './CompiledRoute' +export * from './Contracts/Pipeline' export * from './Controller' +export * from './ControllerDispatcher' +export * from './Events/PreparingResponse' +export * from './Events/ResponsePrepared' +export * from './Events/RouteMatched' +export * from './Events/Routing' export * from './Helpers' +export * from './Matchers/HostValidator' +export * from './Matchers/MethodValidator' +export * from './Matchers/SchemeValidator' +export * from './Matchers/UriValidator' +export * from './Middleware/SubstituteBindings' +export * from './MiddlewareResolver' +export * from './Pipeline' export * from './Providers/AssetsServiceProvider' export * from './Providers/RouteServiceProvider' +export * from './Route' +export * from './RouteAction' +export * from './RouteCollection' +export * from './RouteGroup' +export * from './RouteParameterBinder' export * from './Router' +export * from './RouteUri' +export * from './TraitLike/FiltersControllerMiddleware' +export * from './TraitLike/RouteDependencyResolver' diff --git a/packages/router/tests/router.test.ts b/packages/router/tests/router.test.ts new file mode 100644 index 00000000..9ff8cd1a --- /dev/null +++ b/packages/router/tests/router.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, it } from 'vitest' + +import { Application } from '@h3ravel/core' +import { h3ravel } from '@h3ravel/core' + +let app: Application + +class Cont { + index () { } + show () { } +} + +// function makeEvent (overides: Record = {}) { +// globalThis.dump = () => { } +// return { +// res: { headers: new Headers(), statusCode: 200 }, +// req: { headers: new Headers(), url: overides.url ?? 'http://localhost/test', method: 'get' }, +// } as any +// } + +describe('Router', async () => { + beforeEach(async () => { + const { EventsServiceProvider } = await import(('@h3ravel/events')) + const { HttpServiceProvider } = await import(('@h3ravel/http')) + const { RouteServiceProvider } = await import(('@h3ravel/router')) + app = await h3ravel([EventsServiceProvider, HttpServiceProvider, RouteServiceProvider]) + }) + + it('can load routes before server is fired', async () => { + const router = app.make('router') + + router.match(['get'], 'path/{user}/{name}', [Cont, 'index']).name('path') + router.match(['get'], 'path3/{user:name}/{name}', [Cont, 'show']).name('path.3').prefix('---john') + router.match(['put'], 'path4/{user}/{name?}', () => { }).name('path.4') + router.match(['post'], 'path5/{user:name}/{name}', () => { }) + + router.getRoutes().refreshActionLookups() + router.getRoutes().refreshNameLookups() + }) +}) \ No newline at end of file diff --git a/packages/session/package.json b/packages/session/package.json index 816c89ba..26926f4b 100644 --- a/packages/session/package.json +++ b/packages/session/package.json @@ -58,11 +58,12 @@ "version-patch": "pnpm version patch" }, "peerDependencies": { - "@h3ravel/core": "workspace:^", "@h3ravel/database": "workspace:^", + "@h3ravel/foundation": "workspace:^", "@h3ravel/shared": "workspace:^" }, "devDependencies": { + "@h3ravel/contracts": "workspace:^", "typescript": "^5.4.0" } } \ No newline at end of file diff --git a/packages/session/src/Encryption.ts b/packages/session/src/Encryption.ts index 2f263bd7..f15bb2b7 100644 --- a/packages/session/src/Encryption.ts +++ b/packages/session/src/Encryption.ts @@ -1,6 +1,6 @@ import crypto, { createHash } from 'crypto' -import { ConfigException } from 'packages/core/dist' +import { ConfigException } from '@h3ravel/foundation' export class Encryption { private key: Buffer diff --git a/packages/session/src/Providers/SessionServiceProvider.ts b/packages/session/src/Providers/SessionServiceProvider.ts index 99de6ec4..373d93bc 100644 --- a/packages/session/src/Providers/SessionServiceProvider.ts +++ b/packages/session/src/Providers/SessionServiceProvider.ts @@ -1,15 +1,13 @@ import { dbBuilder, fileBuilder, memoryBuilder, redisBuilder } from '../adapters' import { MakeSessionTableCommand } from '../Commands/MakeSessionTableCommand' +import { ServiceProvider } from '@h3ravel/foundation' import { SessionStore } from '../SessionStore' -export class SessionServiceProvider { - public registeredCommands?: (new (app: any, kernel: any) => any)[] +export class SessionServiceProvider extends ServiceProvider { public static priority = 895 public static order = 'before:HttpServiceProvider' - constructor(private app: any) { } - register (): void { /** * Register default drivers. @@ -21,7 +19,4 @@ export class SessionServiceProvider { this.registeredCommands = [MakeSessionTableCommand] } - - boot (): void { - } } diff --git a/packages/session/src/SessionManager.ts b/packages/session/src/SessionManager.ts index 32a6bf0d..50947502 100644 --- a/packages/session/src/SessionManager.ts +++ b/packages/session/src/SessionManager.ts @@ -1,5 +1,5 @@ import { DriverOption, SessionDriver } from './Contracts/SessionContract' -import { HttpContext, IRequest, ISessionManager } from '@h3ravel/shared' +import type { IHttpContext, IRequest, ISessionManager } from '@h3ravel/contracts' import { createHash, createHmac, randomBytes } from 'crypto' import { getCookie, setCookie } from 'h3' @@ -24,7 +24,7 @@ export class SessionManager implements ISessionManager { * @param driverName - registered driver key ('file' | 'database' | 'memory' | 'redis') * @param driverOptions - optional bag for driver-specific options */ - constructor(private ctx: HttpContext, driverName: 'file' | 'memory' | 'database' | 'redis' = 'file', driverOptions: DriverOption = {}) { + constructor(private ctx: IHttpContext, driverName: 'file' | 'memory' | 'database' | 'redis' = 'file', driverOptions: DriverOption = {}) { this.appKey = process.env.APP_KEY! this.request = ctx.request diff --git a/packages/session/tests/file.spec.ts b/packages/session/tests/file.spec.ts index f9cae097..8aeb000a 100644 --- a/packages/session/tests/file.spec.ts +++ b/packages/session/tests/file.spec.ts @@ -56,7 +56,7 @@ describe('@h3ravel/session FileDriver', () => { ctx = HttpContext.init({ app, request: await Request.create(event, app), - response: new Response(event, app), + response: new Response(app, event), }, event) process.env.APP_KEY = appKey diff --git a/packages/session/tests/memory.spec.ts b/packages/session/tests/memory.spec.ts index f76e0717..35008ad6 100644 --- a/packages/session/tests/memory.spec.ts +++ b/packages/session/tests/memory.spec.ts @@ -52,7 +52,7 @@ describe('@h3ravel/session MemoryDriver', () => { ctx = HttpContext.init({ app, request: await Request.create(event, app), - response: new Response(event, app), + response: new Response(app, event), }, event) process.env.APP_KEY = appKey diff --git a/packages/shared/package.json b/packages/shared/package.json index 15702b8f..7d59f1f7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -71,7 +71,8 @@ "preferred-pm": "catalog:" }, "devDependencies": { + "@h3ravel/contracts": "workspace:^", "fetchdts": "^0.1.6", "pnpm": "^10.14.0" } -} +} \ No newline at end of file diff --git a/packages/shared/src/Contracts/IApplication.ts b/packages/shared/src/Contracts/IApplication.ts deleted file mode 100644 index f53d884e..00000000 --- a/packages/shared/src/Contracts/IApplication.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { IContainer } from './IContainer' -import { IServiceProvider } from './IServiceProvider' -// import { IServiceProvider } from './IServiceProvider' - -export type IPathName = - | 'views' | 'routes' | 'assets' | 'base' | 'public' - | 'storage' | 'config' | 'database' - -export interface IApplication extends IContainer { - /** - * Registers configured service providers. - */ - registerConfiguredProviders (): Promise; - - /** - * Registers an array of external service provider classes. - * @param providers - Array of service provider constructor functions. - */ - registerProviders (providers: Array(app: A) => S>): void; - - /** - * Registers a single service provider. - * @param provider - The service provider instance to register. - */ - // register (provider: IServiceProvider): Promise; - - /** - * Boots all registered providers. - */ - boot (): Promise; - - /** - * Gets the base path of the application. - * @returns The base path as a string. - */ - getBasePath (): string; - - /** - * Retrieves a path by name, optionally appending a sub-path. - * @param name - The name of the path property. - * @param pth - Optional sub-path to append. - * @returns The resolved path as a string. - */ - getPath (name: string, pth?: string): string; - - /** - * Sets a path for a given name. - * @param name - The name of the path property. - * @param path - The path to set. - * @returns - */ - setPath (name: IPathName, path: string): void; - - /** - * Gets the version of the application or TypeScript. - * @param key - The key to retrieve ('app' or 'ts'). - * @returns The version string or undefined. - */ - getVersion (key: string): string | undefined; -} diff --git a/packages/shared/src/Contracts/IContainer.ts b/packages/shared/src/Contracts/IContainer.ts deleted file mode 100644 index 9ced7251..00000000 --- a/packages/shared/src/Contracts/IContainer.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Bindings, UseKey } from './BindingsContract' -import { IExceptionHandler } from './IExceptionHandler' -import { IMiddlewareHandler } from './IMiddlewareHandler' - -export type IContainerBinding = UseKey | (new (..._args: any[]) => unknown) - -/** - * Interface for the Container contract, defining methods for dependency injection and service resolution. - */ -export interface IContainer { - bindings: Map unknown> - singletons: Map - exceptionHandler?: IExceptionHandler - middlewareHandler?: IMiddlewareHandler - - /** - * Binds a transient service to the container. - * - * @param key - The key or constructor for the service. - * @param factory - The factory function to create the service instance. - */ - bind (key: new (...args: any[]) => T, factory: () => T): void; - bind (key: T, factory: () => Bindings[T]): void; - - /** - * Remove one or more transient services from the container - * - * @param key - */ - unbind (key: T | T[]): void - - /** - * Binds a singleton service to the container. - * @param key - The key or constructor for the service. - * @param factory - The factory function to create the singleton instance. - */ - singleton ( - key: T | (new (..._args: any[]) => Bindings[T]), - factory: (app: this) => Bindings[T] - ): void; - - /** - * Resolves a service from the container. - * - * @param key - The key or constructor for the service. - * @returns The resolved service instance. - */ - make (key: T): Bindings[T] - make any> (key: C): InstanceType - make any> (key: F): ReturnType/** - - * Register a callback to be executed after a service is resolved - * - * @param key - * @param callback - */ - afterResolving ( - key: T | (new (..._args: any[]) => Bindings[T]), - callback: (resolved: Bindings[T], app: this) => void - ): void - - /** - * Checks if a service is registered in the container. - * @param key - The key to check. - * @returns True if the service is registered, false otherwise. - */ - has any> (key: C): boolean - has any> (key: F): boolean - has (key: T): boolean -} diff --git a/packages/shared/src/Contracts/IExceptionHandler.ts b/packages/shared/src/Contracts/IExceptionHandler.ts deleted file mode 100644 index ff0242cf..00000000 --- a/packages/shared/src/Contracts/IExceptionHandler.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { HttpContext } from './IHttp' -import { IRequest } from './IRequest' -import { IResponse } from './IResponse' - -export type ExceptionLimitSpec = { - key?: string; - maxAttempts: number; - decaySeconds: number; -}; -export type ExceptionLUnlimited = { - unlimited: true; -}; -/** - * Rate Limiter Adapter Interface - */ -export interface RateLimiterAdapter { - /** - * Attempt a key with a maxAttempts and decaySeconds. - * - * Return true if this is allowed (i.e., *not* throttled), - * false if the limit is reached. - */ - attempt (key: string, maxAttempts: number, allowCallback: () => boolean | Promise, decaySeconds: number): Promise; -} - -export type ExceptionConstructor = new (...args: any[]) => T -export type ExceptionConditionCallback = (error: any) => boolean; -export type RenderExceptionCallback = (error: any, ctx: HttpContext) => IResponse | Promise | undefined | null; -export type ReportExceptionCallback = (error: any) => boolean | void | Promise; -export type ThrottleExceptionCallback = (error: any) => ExceptionLimitSpec | ExceptionLUnlimited | null | undefined; - -export declare abstract class IExceptionHandler { - /** - * The exception handler method - * - * @param error - * @param ctx - */ - handle?(error: Error, ctx: HttpContext): Promise; - /** - * Register a reportable callback handler - * - * @param cb - * @returns - */ - reportable (cb: ReportExceptionCallback): this; - renderable (cb: RenderExceptionCallback): this; - dontReport (exceptions: ExceptionConstructor | ExceptionConstructor[]): this; - stopIgnoring (exceptions: ExceptionConstructor | ExceptionConstructor[]): this; - dontReportWhen (cb: ExceptionConditionCallback): this; - dontReportDuplicates (): this; - map (from: ExceptionConstructor, mapper: (error: any) => any): this; - throttleUsing (cb: ThrottleExceptionCallback): this; - buildContextUsing (cb: (e: any, current?: Record) => Record): this; - setRateLimiter (adapter: RateLimiterAdapter): this; - respondUsing (cb: (response: IResponse, error: any, request: IRequest) => IResponse | Promise): this; - shouldRenderJsonWhen (cb: (request: IRequest, error: any) => boolean): this; - /** - * Entry point to reporting an exception. - * - * @param error - * @returns - */ - report (error: any): Promise; - /** - * Render an exception into an HTTP Response. - * - * @param ctx - * @param error - * @returns - */ - render (ctx: HttpContext, error: any): Promise; - /** - * getResponse - */ - getResponse ({ - request - }: HttpContext, payload: Record, e: any): IResponse | Promise; - /** - * Not implemented in core. Subclass can implement and call RequestException helpers. - * - * @param _length - */ - truncateRequestExceptionsAt (_length: number): this; - /** - * Set the log level - * - * @param _attributes - */ - level (type: string, level: string): { - level: string; - type: string; - }; - /** - * Not implemented here; applicable to validation pipeline/UI. - * - * @param _attributes - */ - dontFlash (_attributes: string | string[]): this; -} \ No newline at end of file diff --git a/packages/shared/src/Contracts/IHttp.ts b/packages/shared/src/Contracts/IHttp.ts deleted file mode 100644 index f1e527c0..00000000 --- a/packages/shared/src/Contracts/IHttp.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { H3Event, Middleware, MiddlewareOptions } from 'h3' - -import { IApplication } from './IApplication' -import { IRequest } from './IRequest' -import { IResponse } from './IResponse' - -export type RouterEnd = 'get' | 'delete' | 'put' | 'post' | 'patch' | 'apiResource' | 'group' | 'route'; -export type RequestMethod = 'HEAD' | 'GET' | 'PUT' | 'DELETE' | 'TRACE' | 'OPTIONS' | 'PURGE' | 'POST' | 'CONNECT' | 'PATCH'; -export type RequestObject = Record; -export type ResponseObject = Record; - -export type ExtractControllerMethods = { - [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never -}[keyof T]; - -/** - * Interface for the Router contract, defining methods for HTTP routing. - */ -export declare class IRouter { - /** - * Registers a GET route. - * @param path - The route path. - * @param definition - The handler function or [controller class, method] array. - * @param name - Optional route name. - * @param middleware - Optional middleware array. - */ - get any> ( - path: string, - definition: EventHandler | [C, methodName: ExtractControllerMethods>], - name?: string, - middleware?: IMiddleware[] - ): Omit; - - /** - * Registers a POST route. - * @param path - The route path. - * @param definition - The handler function or [controller class, method] array. - * @param name - Optional route name. - * @param middleware - Optional middleware array. - */ - post any> ( - path: string, - definition: EventHandler | [C, methodName: ExtractControllerMethods>], - name?: string, - middleware?: IMiddleware[] - ): Omit; - - /** - * Registers a PUT route. - * @param path - The route path. - * @param definition - The handler function or [controller class, method] array. - * @param name - Optional route name. - * @param middleware - Optional middleware array. - */ - put any> ( - path: string, - definition: EventHandler | [C, methodName: ExtractControllerMethods>], - name?: string, - middleware?: IMiddleware[] - ): Omit; - - /** - * Registers a route that responds to HTTP PATCH requests. - * - * @param path The URL pattern to match (can include parameters, e.g., '/users/:id'). - * @param definition Either: - * - An EventHandler function - * - A tuple: [ControllerClass, methodName] - * @param name Optional route name (for URL generation or referencing). - * @param middleware Optional array of middleware functions to execute before the handler. - */ - patch any> ( - path: string, - definition: EventHandler | [C, methodName: ExtractControllerMethods>], - name?: string, - middleware?: IMiddleware[] - ): Omit; - - /** - * Registers a DELETE route. - * @param path - The route path. - * @param definition - The handler function or [controller class, method] array. - * @param name - Optional route name. - * @param middleware - Optional middleware array. - */ - delete any> ( - path: string, - definition: EventHandler | [C, methodName: ExtractControllerMethods>], - name?: string, - middleware?: IMiddleware[] - ): Omit; - - /** - * Registers an API resource with standard CRUD routes. - * @param path - The base path for the resource. - * @param controller - The controller class handling the resource. - * @param middleware - Optional middleware array. - */ - apiResource ( - path: string, - controller: new (app: IApplication) => IController, - middleware?: IMiddleware[] - ): Omit; - - /** - * Generates a URL for a named route. - * @param name - The name of the route. - * @param params - Optional parameters to replace in the route path. - * @returns The generated URL or undefined if the route is not found. - */ - route (name: string, params?: Record): string | undefined; - - - /** - * Set the name of the current route - * - * @param name - */ - name (name: string): this - - /** - * Groups routes with shared prefix or middleware. - * @param options - Configuration for prefix or middleware. - * @param callback - Callback function defining grouped routes. - */ - group (options: { prefix?: string; middleware?: EventHandler[] }, callback: () => this): this; - - /** - * Registers middleware for a specific path. - * @param path - The path to apply the middleware. - * @param handler - The middleware handler. - * @param opts - Optional middleware options. - */ - middleware (path: Middleware, opts?: Middleware | MiddlewareOptions): this - middleware (path: string | IMiddleware[] | Middleware, handler: Middleware | MiddlewareOptions, opts?: MiddlewareOptions): this; -} - -/** - * Represents the HTTP context for a single request lifecycle. - * Encapsulates the application instance, request, and response objects. - */ -export declare class HttpContext { - app: IApplication - event: H3Event - request: IRequest - response: IResponse - private static contexts: WeakMap - constructor(app: IApplication, request: IRequest, response: IResponse); - /** - * Factory method to create a new HttpContext instance from a context object. - * @param ctx - Object containing app, request, and response - * @returns A new HttpContext instance - */ - static init (ctx: { - app: IApplication; - request: IRequest; - response: IResponse; - }, event?: unknown): HttpContext; - /** - * Retrieve an existing HttpContext instance for an event, if any. - */ - static get (event: unknown): HttpContext | undefined; - /** - * Delete the cached context for a given event (optional cleanup). - */ - static forget (event: unknown): void; -} - -/** - * Type for EventHandler, representing a function that handles an H3 event. - */ -export type EventHandler = (ctx: HttpContext) => any -export type RouteEventHandler = (...args: any[]) => any - -/** - * Defines the contract for all controllers. - * Any controller implementing this must define these methods. - */ -export declare class IController { - show?(...ctx: any[]): any - index?(...ctx: any[]): any - store?(...ctx: any[]): any - update?(...ctx: any[]): any - destroy?(...ctx: any[]): any -} - -/** - * Defines the contract for all middlewares. - * Any middleware implementing this must define these methods. - */ -export declare class IMiddleware { - handle (context: HttpContext, next: () => Promise): Promise -} diff --git a/packages/shared/src/Contracts/IResponse.ts b/packages/shared/src/Contracts/IResponse.ts deleted file mode 100644 index 0e7485f0..00000000 --- a/packages/shared/src/Contracts/IResponse.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { DotNestedKeys, DotNestedValue } from '@h3ravel/shared' -import type { H3Event, HTTPResponse } from 'h3' - -import { HttpContext } from './IHttp' -import type { IApplication } from './IApplication' -import { IHttpResponse } from './IHttpResponse' - -/** - * Interface for the Response contract, defining methods for handling HTTP responses. - */ -export interface IResponse extends IHttpResponse { - /** - * The current app instance - */ - app: IApplication; - /** - * The current Http Context - */ - context: HttpContext - /** - * Sends content for the current web response. - */ - sendContent (type?: 'html' | 'json' | 'text' | 'xml', parse?: boolean): unknown; - /** - * Sends content for the current web response. - */ - send (type?: 'html' | 'json' | 'text' | 'xml'): unknown; - - /** - * Use an edge view as content - * - * @param viewPath The path to the view file - * @param send if set to true, the content will be returned, instead of the Response instance - * @returns - */ - view (viewPath: string, data?: Record | undefined): Promise - view (viewPath: string, data: Record | undefined, parse: boolean): Promise - - /** - * - * Parse content as edge view - * - * @param content The content to serve - * @param send if set to true, the content will be returned, instead of the Response instance - * @returns - */ - viewTemplate (content: string, data?: Record | undefined): Promise - viewTemplate (content: string, data: Record | undefined, parse: boolean): Promise - /** - * - * @param content The content to serve - * @param send if set to true, the content will be returned, instead of the Response instance - * @returns - */ - html (content?: string): this; - html (content: string, parse: boolean): HTTPResponse; - /** - * Send a JSON response. - */ - json (data?: T): this; - json (data: T, parse: boolean): T; - /** - * Send plain text. - */ - text (content?: string): this; - text (content: string, parse: boolean): HTTPResponse; - /** - * Send plain xml. - */ - xml (data?: string): this; - xml (data: string, parse: boolean): HTTPResponse; - /** - * Redirect to another URL. - */ - redirect (location: string, status?: number, statusText?: string | undefined): this; - /** - * Dump the response. - */ - dump (): this; - /** - * Get the base event - */ - getEvent (): H3Event; - getEvent> (key: K): DotNestedValue; -} diff --git a/packages/shared/src/Contracts/IServiceProvider.ts b/packages/shared/src/Contracts/IServiceProvider.ts deleted file mode 100644 index 81e3bccc..00000000 --- a/packages/shared/src/Contracts/IServiceProvider.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { IApplication } from './IApplication' - -export interface IServiceProvider { - /** - * Unique Identifier for service providers - */ - uid?: number; - - /** - * Sort order - */ - order?: `before:${string}` | `after:${string}` | string | undefined - - /** - * Sort priority - */ - priority?: number; - - /** - * Indicate that this service provider only runs in console - */ - runsInConsole?: boolean; - - /** - * List of registered console commands - */ - registeredCommands?: (new (app: IApplication, kernel: any) => any)[]; - - /** - * An array of console commands to register. - */ - commands?(commands: (new (app: IApplication, kernel: any) => any)[]): void - - /** - * Register bindings to the container. - * Runs before boot(). - */ - register?(...app: unknown[]): void | Promise - - /** - * Perform post-registration booting of services. - * Runs after all providers have been registered. - */ - boot?(...app: unknown[]): void | Promise -} diff --git a/packages/shared/src/Contracts/IUploadedFile.ts b/packages/shared/src/Contracts/IUploadedFile.ts deleted file mode 100644 index bfbd49a0..00000000 --- a/packages/shared/src/Contracts/IUploadedFile.ts +++ /dev/null @@ -1,12 +0,0 @@ -export declare class IUploadedFile { - originalName: string - mimeType: string - size: number - content: File - constructor(originalName: string, mimeType: string, size: number, content: File); - static createFromBase (file: File): IUploadedFile; - /** - * Save to disk (Node environment only) - */ - moveTo (destination: string): Promise; -} \ No newline at end of file diff --git a/packages/shared/src/Contracts/Router.ts b/packages/shared/src/Contracts/Router.ts deleted file mode 100644 index 91b949e6..00000000 --- a/packages/shared/src/Contracts/Router.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { EventHandler } from './IHttp' - -export type RouteMethod = 'get' | 'head' | 'put' | 'patch' | 'post' | 'delete' - -export interface RouteDefinition { - method: RouteMethod; - path: string; - name?: string | undefined; - handler: EventHandler; - signature: [string, string | undefined] -} diff --git a/packages/shared/src/Utils/Logger.ts b/packages/shared/src/Utils/Logger.ts index acf08768..97d9a119 100644 --- a/packages/shared/src/Utils/Logger.ts +++ b/packages/shared/src/Utils/Logger.ts @@ -255,8 +255,10 @@ export class Logger { if (typeof config === 'string') { const conf = [[config, joiner]] as [string, keyof ChalkInstance][] return this.parse(conf, '', log as false, sc) - } else if (config) { + } else if (Array.isArray(config)) { return this.parse(config, String(joiner), log as false, sc) + } else if (log && !this.shouldSuppressOutput('line')) { + return console.log(this.textFormat(config, Logger.chalker(['blue']))) } return this diff --git a/packages/shared/src/Utils/PathLoader.ts b/packages/shared/src/Utils/PathLoader.ts index 0e29110d..ae2b8e39 100644 --- a/packages/shared/src/Utils/PathLoader.ts +++ b/packages/shared/src/Utils/PathLoader.ts @@ -1,4 +1,4 @@ -import { IPathName } from '../Contracts/IApplication' +import { IPathName } from '@h3ravel/contracts' import nodepath from 'path' export class PathLoader { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0f1d096c..4b846e01 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,19 +1,5 @@ -export * from './Contracts/BindingsContract' -export * from './Contracts/IApplication' -export * from './Contracts/IContainer' -export * from './Contracts/IExceptionHandler' -export * from './Contracts/IHttp' -export * from './Contracts/IHttpResponse' -export * from './Contracts/IMiddlewareHandler' -export * from './Contracts/IParamBag' -export * from './Contracts/IRequest' -export * from './Contracts/IResponse' -export * from './Contracts/IServiceProvider' -export * from './Contracts/ISessionManager' -export * from './Contracts/IUploadedFile' export * from './Contracts/ObjContract' export * from './Contracts/PromptsContract' -export * from './Contracts/Router' export * from './Contracts/Utils' export * from './Utils/EnvParser' export * from './Utils/FileSystem' diff --git a/packages/support/package.json b/packages/support/package.json index 8687279e..afc33b3b 100644 --- a/packages/support/package.json +++ b/packages/support/package.json @@ -52,6 +52,7 @@ "version-patch": "pnpm version patch" }, "devDependencies": { + "@h3ravel/collect.js": "catalog:prod", "@h3ravel/shared": "workspace:^", "@types/luxon": "catalog:", "typescript": "^5.4.0" @@ -60,4 +61,4 @@ "dayjs": "catalog:", "luxon": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/support/src/Collection.ts b/packages/support/src/Collection.ts new file mode 100644 index 00000000..2f11e657 --- /dev/null +++ b/packages/support/src/Collection.ts @@ -0,0 +1,20 @@ +import { Collection as BaseCollection } from '@h3ravel/collect.js' + +export class Collection extends BaseCollection { + /** + * + * @param collection + */ + constructor(collection?: Item[] | Item) { + super(collection) + } +} + +/** + * + * @param collection + * @returns + */ +export const collect = (collection?: T | T[] | undefined): Collection => { + return new Collection(collection) +} \ No newline at end of file diff --git a/packages/support/src/Exceptions/RuntimeException.ts b/packages/support/src/Exceptions/RuntimeException.ts index c7256dde..26bc5f62 100644 --- a/packages/support/src/Exceptions/RuntimeException.ts +++ b/packages/support/src/Exceptions/RuntimeException.ts @@ -1,8 +1,8 @@ /** - * Custom error for invalid type coercion + * Exception thrown if an error which can only be found on runtime occurs. */ export class RuntimeException extends Error { - constructor(message: string) { + constructor(message: string = '') { super(message) this.name = 'RuntimeException' } diff --git a/packages/support/src/Helpers.ts b/packages/support/src/Helpers.ts new file mode 100644 index 00000000..dbde67c9 --- /dev/null +++ b/packages/support/src/Helpers.ts @@ -0,0 +1,36 @@ +import { HigherOrderTapProxy } from './HigherOrderTapProxy' + +/** + * Call the given Closure with the given value then return the value. + * + * @param value + * @param callback + */ +interface Tap { + > (value: X): HigherOrderTapProxy + > (value: X, callback?: (val: X) => void): X +} + +export const tap: Tap = (value: any, callback?: (val: X) => void) => { + if (!callback) { + return new HigherOrderTapProxy(value) + } + + callback(value) + + return value +} + +export const isClass = (C: any): C is new (...args: any[]) => any => { + return typeof C === 'function' && + C.prototype !== undefined && + Object.toString.call(C).substring(0, 5) === 'class' +} + +export const isAbstract = (C: any): C is new (...args: any[]) => any => { + return isClass(C) && C.name.startsWith('I') +} + +export const isCallable = (C: any): C is (...args: any[]) => any => { + return typeof C === 'function' && !isClass(C) +} \ No newline at end of file diff --git a/packages/support/src/Helpers/Arr.ts b/packages/support/src/Helpers/Arr.ts index b449ef61..2e92d5f3 100644 --- a/packages/support/src/Helpers/Arr.ts +++ b/packages/support/src/Helpers/Arr.ts @@ -731,7 +731,7 @@ export class Arr { * @param value * @returns */ - static wrap (value: T | T[] | null | undefined): T[] { + static wrap (value: T | T[] | null | undefined): T[] { if (value === null || value === undefined) return [] return Array.isArray(value) ? value : [value] } diff --git a/packages/support/src/Helpers/Str.ts b/packages/support/src/Helpers/Str.ts index aa61fcd4..f9afeb28 100644 --- a/packages/support/src/Helpers/Str.ts +++ b/packages/support/src/Helpers/Str.ts @@ -1,6 +1,7 @@ import type { Callback, ExcerptOptions, Fallback, Function, HtmlStringType, Value } from '../Contracts/StrContract' import { dot } from './Obj' +import { isIP } from 'node:net' export enum Mode { MB_CASE_UPPER = 0, @@ -293,19 +294,6 @@ export class Str { return result } - /** - * Determine if a given string doesn't contain a given substring. - * - * @param { string } haystack - * @param { string | string[] } needles - * @param { boolean } ignoreCase - * - * @return { boolean } - */ - static doesntContain (haystack: string, needles: string | string[], ignoreCase: boolean = false): boolean { - return !this.contains(haystack, needles, ignoreCase) - } - /** * Convert the case of a string. * @@ -361,6 +349,62 @@ export class Str { return string } + /** + * Detect content type + * + * @param content + * @returns + */ + static detectContentType (content: any) { + if (typeof content !== 'string') { + return 'json' + } + + const trimmed = content.trim() + + /** + * JSON check + */ + if (/^[[{]/.test(trimmed)) { + try { + JSON.parse(trimmed) + return 'json' + } catch {/** */ } + } + + /** + * XML check + */ + if (/^<\?xml/i.test(trimmed) || /^<[A-Za-z]+[^>]*>/.test(trimmed)) { + // If it looks like XML but not HTML + if (!/^/i.test(trimmed) && !/]/i.test(trimmed)) { + return 'xml' + } + } + + /** + * HTML check + */ + if (/<(html|head|body|div|span|p|!DOCTYPE)/i.test(trimmed)) { + return 'html' + } + + return 'text' + } + + /** + * Determine if a given string doesn't contain a given substring. + * + * @param { string } haystack + * @param { string | string[] } needles + * @param { boolean } ignoreCase + * + * @return { boolean } + */ + static doesntContain (haystack: string, needles: string | string[], ignoreCase: boolean = false): boolean { + return !this.contains(haystack, needles, ignoreCase) + } + /** * Replace consecutive instances of a given character with a single character in the given string. * @@ -724,6 +768,27 @@ export class Str { return value.toLowerCase() } + /** + * Parse a Class[@]method style callback into class and method. + * + * @param callback + * @param defaultValue + */ + static parseCallback (callback: string, defaultValue?: string) { + if (this.contains(callback, 'anonymous')) { + if (this.substrCount(callback, '@') > 1) { + return [ + this.beforeLast(callback, '@'), + this.afterLast(callback, '@'), + ] + } + + return [callback, defaultValue] + } + + return this.contains(callback, '@') ? callback.split('@', 2) : [callback, defaultValue] + } + /** * Get substring by start/stop indexes. * @@ -2585,23 +2650,31 @@ export class Str { * @return { string } */ static trim (value: string, characters: string | null = null): string { + // Default whitespace trim if (characters === null) { return value.trim() } + // Nothing to trim if (characters === '') { return value } - if (characters === ' ') { - return value.replaceAll(' ', '') + // Trim start + for (const char of characters) { + while (value.startsWith(char)) { + value = value.substring(char.length) + } } - characters = characters.split('').join('|') - - const regex: RegExp = new RegExp(`${characters}+`, 'g') + // Trim end + for (const char of characters) { + while (value.endsWith(char)) { + value = value.substring(0, value.length - char.length) + } + } - return value.replace(regex, '') ?? value + return value } /** @@ -2613,20 +2686,23 @@ export class Str { * @return { string } */ static ltrim (value: string, characters: string | null = null): string { + // Trim default whitespace if no custom characters are provided if (characters === null) { return value.trimStart() } + // No characters to trim if (characters === '') { return value } - if (characters === ' ') { - return this.replaceStart(' ', '', value) + // Loop through each character and strip it ONLY from the start + for (const char of characters) { + while (value.startsWith(char)) { + value = value.substring(char.length) + } } - characters.split('').forEach((character: string): string => value = this.replaceStart(character, '', value)) - return value } @@ -2639,23 +2715,27 @@ export class Str { * @return { string } */ static rtrim (value: string, characters: string | null = null): string { + // Trim default whitespace if no custom characters are provided if (characters === null) { return value.trimEnd() } + // No characters to trim if (characters === '') { return value } - if (characters === ' ') { - return this.replaceEnd(' ', '', value) + // Loop through each character and strip it ONLY from the end + for (const char of characters) { + while (value.endsWith(char)) { + value = value.substring(0, value.length - char.length) + } } - characters.split('').forEach((character: string): string => value = this.replaceEnd(character, '', value)) - return value } + /** * Remove all "extra" blank space from the given string. * @@ -3090,6 +3170,28 @@ export class Str { return result } + /** + * Validate an IP address + * + * @param host + * @param type + * + * @return { boolean } + */ + static validateIp (host: string, type?: 'ipv4' | 'ipv6'): boolean { + const code = { + ipv4: 4, + ipv6: 6, + } as const + + const result = isIP(host) + + if (type) + return result === code[type] + + return result > 0 + } + /** * Generate a time-ordered UUID (version 4). * @@ -3553,6 +3655,17 @@ export class Stringable { return Str.containsAll(this.#value, needles, ignoreCase) } + /** + * Convert the case of a string. + * + * @param { Mode | number } mode + * + * @return { Stringable } + */ + convertCase (mode: Mode | number = Mode.MB_CASE_FOLD): Stringable { + return new Stringable(Str.convertCase(this.#value, mode)) + } + /** * Determine if a given string doesn't contain a given substring. * @@ -3566,14 +3679,13 @@ export class Stringable { } /** - * Convert the case of a string. - * - * @param { Mode | number } mode - * - * @return { Stringable } + * Detect content type + * + * @param content + * @returns */ - convertCase (mode: Mode | number = Mode.MB_CASE_FOLD): Stringable { - return new Stringable(Str.convertCase(this.#value, mode)) + static detectContentType (content: any) { + return Str.detectContentType(content) } /** @@ -4505,6 +4617,17 @@ export class Stringable { return this } + /** + * Validate an IP address + * + * @param host + * @param type + * @returns + */ + public validateIp (type: 'ipv4' | 'ipv6' = 'ipv4') { + return Str.validateIp(this.#value, type) + } + /** * Execute the given callback if the string contains a given substring. * diff --git a/packages/support/src/HigherOrderTapProxy.ts b/packages/support/src/HigherOrderTapProxy.ts new file mode 100644 index 00000000..f5993e54 --- /dev/null +++ b/packages/support/src/HigherOrderTapProxy.ts @@ -0,0 +1,25 @@ +export class HigherOrderTapProxy any>> { + /** + * The target being tapped. + */ + public target: Target + + /** + * Create a new tap proxy instance. + */ + public constructor(target: Target) { + this.target = target + } + + /** + * Dynamically pass method calls to the target. + * + * @param method + * @param parameters + */ + public __call (method: string, parameters: any[]) { + this.target[method](...parameters) + + return this.target + } +} diff --git a/packages/support/src/index.ts b/packages/support/src/index.ts index 27c6ef78..86b6b910 100644 --- a/packages/support/src/index.ts +++ b/packages/support/src/index.ts @@ -1,9 +1,11 @@ +export * from './Collection' export * from './Contracts/ObjContract' export * from './Contracts/StrContract' export * from './Contracts/TypeCast' export * from './Exceptions/InvalidArgumentException' export * from './Exceptions/RuntimeException' export * from './GlobalBootstrap' +export * from './Helpers' export { Arr } from './Helpers/Arr' export * as Crypto from './Helpers/Crypto' export { uuid, random, randomSecure, hash, hmac, base64Encode, base64Decode, xor, randomColor, randomPassword, secureToken, checksum, verifyChecksum, caesarCipher } from './Helpers/Crypto' @@ -13,3 +15,4 @@ export { abbreviate, humanize, toBytes, toHumanTime } from './Helpers/Number' export { Obj, dot, extractProperties, getValue, modObj, safeDot, setNested, slugifyKeys, toCssClasses, toCssStyles, undot, data_get, data_set, data_fill, data_forget, isPlainObject } from './Helpers/Obj' export { Str, Mode, Stringable, HtmlString, str } from './Helpers/Str' export * from './Helpers/Time' +export * from './HigherOrderTapProxy' diff --git a/packages/support/tests/collection.test.ts b/packages/support/tests/collection.test.ts new file mode 100644 index 00000000..57e744e6 --- /dev/null +++ b/packages/support/tests/collection.test.ts @@ -0,0 +1,12 @@ +import { Collection, collection } from '../src/Collection' +import { describe, expect, test } from 'vitest' + +describe('Collection', () => { + test('test collection', () => { + // console.log(new Collection([{ name: 'james', age: 12, page: 12, gage: 12, sage: 12, fage: 12 }]).chunk(3).all()) + // console.log(new Collection({ name: 'james' }), new Collection([1, 2, 3]), collection('Men')) + + expect(new Collection({ name: 'james' }).get('name')).toBe('james') + expect(collection([1, 2, 3]).all()).toEqual([1, 2, 3]) + }) +}) diff --git a/packages/url/package.json b/packages/url/package.json index 45299e6a..6151b85d 100644 --- a/packages/url/package.json +++ b/packages/url/package.json @@ -61,18 +61,11 @@ }, "dependencies": { "@h3ravel/support": "workspace:^", + "@h3ravel/contracts": "workspace:^", + "@h3ravel/foundation": "workspace:^", "@h3ravel/shared": "workspace:^" }, - "peerDependencies": { - "@h3ravel/core": "workspace:^", - "@h3ravel/config": "workspace:^" - }, "devDependencies": { "typescript": "^5.4.0" - }, - "peerDependenciesMeta": { - "@h3ravel/config": { - "optional": true - } } -} +} \ No newline at end of file diff --git a/packages/url/src/Contracts/UrlContract.ts b/packages/url/src/Contracts/UrlContract.ts deleted file mode 100644 index f1567448..00000000 --- a/packages/url/src/Contracts/UrlContract.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { ExtractControllerMethods } from '@h3ravel/shared' -import { RequestAwareHelpers } from '../RequestAwareHelpers' -import { Url } from '../Url' - -export type RouteParams = Record - -/** - * Contract for URL manipulation and generation - */ -export interface UrlContract { - /** - * Set the scheme (protocol) of the URL - */ - withScheme (scheme: string): this - - /** - * Set the host of the URL - */ - withHost (host: string): this - - /** - * Set the port of the URL - */ - withPort (port: number): this - - /** - * Set the path of the URL - */ - withPath (path: string): this - - /** - * Set the query parameters of the URL - */ - withQuery (query: Record): this - - /** - * Set the fragment (hash) of the URL - */ - withFragment (fragment: string): this - - /** - * Convert the URL to its string representation - */ - toString (): string -} - -/** - * Contract for request-aware URL helpers - */ -export interface RequestAwareUrlContract { - /** - * Get the current request URL - */ - current (): string - - /** - * Get the full current URL with query string - */ - full (): string - - /** - * Get the previous request URL - */ - previous (): string - - /** - * Get the previous request path (without query string) - */ - previousPath (): string - - /** - * Get the current query parameters - */ - query (): Record -} - -/** - * The Url Helper Contract - */ -export interface HelpersContract { - /** - * Create a URL from a path relative to the app URL - */ - to: (path: string) => Url - - /** - * Create a URL from a named route - */ - route: (name: string, params?: Record) => string - - /** - * Create a signed URL from a named route - * - * @param name - * @param params - * @returns - */ - signedRoute: (name: string, params?: Record) => Url - - /** - * Create a temporary signed URL from a named route - * - * @param name - * @param params - * @param expiration - * @returns - */ - temporarySignedRoute: (name: string, params: Record | undefined, expiration: number) => Url - - /** - * Create a URL from a controller action - */ - action: any>( - controller: string | [C, methodName: ExtractControllerMethods>], - params?: Record - ) => string - - /** - * Get request-aware URL helpers - */ - url: { - (): RequestAwareHelpers - (path: string): string - } -} diff --git a/packages/url/src/Helpers.ts b/packages/url/src/Helpers.ts index f0caeb1c..025fc572 100644 --- a/packages/url/src/Helpers.ts +++ b/packages/url/src/Helpers.ts @@ -1,8 +1,6 @@ -import { Application } from '@h3ravel/core' -import { HelpersContract } from './Contracts/UrlContract' import { RequestAwareHelpers } from './RequestAwareHelpers' import { Url } from './Url' -import { ExtractControllerMethods } from '@h3ravel/shared' +import { ExtractClassMethods, IApplication, IUrlHelpers } from '@h3ravel/contracts' /** * Global helper functions for URL manipulation @@ -13,7 +11,7 @@ import { ExtractControllerMethods } from '@h3ravel/shared' */ export function to ( path: string, - app?: Application + app?: IApplication ): Url { return Url.to(path, app) } @@ -24,7 +22,7 @@ export function to ( export function route = Record> ( name: TName, params: TParams = {} as TParams, - app?: Application + app?: IApplication ): Url { return Url.route(name, params, app) } @@ -35,7 +33,7 @@ export function route = Record> ( name: TName, params: TParams = {} as TParams, - app?: Application + app?: IApplication ): Url { return Url.signedRoute(name, params, app) } @@ -47,7 +45,7 @@ export function temporarySignedRoute ( name: string, params: Record = {}, expiration: number, - app?: Application + app?: IApplication ): Url { return Url.temporarySignedRoute(name, params, expiration, app) } @@ -57,7 +55,7 @@ export function temporarySignedRoute ( */ export function action ( controller: string, - app?: Application + app?: IApplication ): Url { return Url.action(controller, app) } @@ -65,7 +63,7 @@ export function action ( /** * Get request-aware URL helpers */ -export function url (app?: Application): RequestAwareHelpers { +export function url (app?: IApplication): RequestAwareHelpers { if (!app) throw new Error('Application instance required for request-aware URL helpers') return new RequestAwareHelpers(app) } @@ -73,7 +71,7 @@ export function url (app?: Application): RequestAwareHelpers { /** * Create URL helpers that are bound to an application instance */ -export function createUrlHelpers (app: Application): HelpersContract { +export function createUrlHelpers (app: IApplication): IUrlHelpers { return { /** * Create a URL from a path relative to the app URL @@ -109,7 +107,7 @@ export function createUrlHelpers (app: Application): HelpersContract { * Create a URL from a controller action */ action: any> ( - controller: string | [C, methodName: ExtractControllerMethods>], + controller: string | [C, methodName: ExtractClassMethods>], params?: Record ) => Url.action(controller, params, app).toString(), diff --git a/packages/url/src/Providers/UrlServiceProvider.ts b/packages/url/src/Providers/UrlServiceProvider.ts index 4f24b52a..fe723606 100644 --- a/packages/url/src/Providers/UrlServiceProvider.ts +++ b/packages/url/src/Providers/UrlServiceProvider.ts @@ -1,5 +1,5 @@ /// -import { ServiceProvider } from '@h3ravel/core' +import { ServiceProvider } from '@h3ravel/foundation' import { Url } from '../Url' import { createUrlHelper } from '../RequestAwareHelpers' import { createUrlHelpers } from '../Helpers' @@ -14,6 +14,7 @@ export class UrlServiceProvider extends ServiceProvider { * Register URL services in the container */ register (): void { + this.app.setUriResolver(() => Url) } /** @@ -28,7 +29,6 @@ export class UrlServiceProvider extends ServiceProvider { // Register bound URL helpers this.app.singleton('app.url.helpers', () => createUrlHelpers(this.app)) - // Make url() globally available if (typeof globalThis !== 'undefined') { const helpers = createUrlHelpers(this.app) diff --git a/packages/url/src/RequestAwareHelpers.ts b/packages/url/src/RequestAwareHelpers.ts index f88fa9b9..20595c69 100644 --- a/packages/url/src/RequestAwareHelpers.ts +++ b/packages/url/src/RequestAwareHelpers.ts @@ -1,6 +1,4 @@ -import type { Application } from '@h3ravel/core' -import type { IRequest } from '@h3ravel/shared' -import { RouteParams } from './Contracts/UrlContract' +import { IApplication, IRequest, RouteParams } from '@h3ravel/contracts' /** * Request-aware URL helper class @@ -8,7 +6,7 @@ import { RouteParams } from './Contracts/UrlContract' export class RequestAwareHelpers { private readonly baseUrl: string = '' - constructor(private app: Application) { + constructor(private app: IApplication) { try { this.baseUrl = config('app.url', 'http://localhost:3000') } catch {/** */ } @@ -103,6 +101,6 @@ export class RequestAwareHelpers { /** * Global helper function factory */ -export function createUrlHelper (app: Application): () => RequestAwareHelpers { +export function createUrlHelper (app: IApplication): () => RequestAwareHelpers { return () => new RequestAwareHelpers(app) } diff --git a/packages/url/src/Url.ts b/packages/url/src/Url.ts index d223632f..0296444e 100644 --- a/packages/url/src/Url.ts +++ b/packages/url/src/Url.ts @@ -1,8 +1,7 @@ -import { ConfigException, type Application } from '@h3ravel/core' -import { RouteParams } from './Contracts/UrlContract' +import { ConfigException } from '@h3ravel/foundation' import { hmac } from '@h3ravel/support' -import { RouteDefinition, ExtractControllerMethods } from '@h3ravel/shared' import path from 'node:path' +import { ClassicRouteDefinition, ExtractClassMethods, IApplication, RouteParams } from '@h3ravel/contracts' /** * URL builder class with fluent API and request-aware helpers @@ -14,10 +13,10 @@ export class Url { private readonly _path: string private readonly _query: Record private readonly _fragment?: string - private readonly app?: Application + private readonly app?: IApplication private constructor( - app?: Application, + app?: IApplication, scheme?: string, host?: string, port?: number, @@ -37,7 +36,7 @@ export class Url { /** * Create a URL from a full URL string */ - static of (url: string, app?: Application): Url { + static of (url: string, app?: IApplication): Url { try { const parsed = new URL(url) const query: Record = {} @@ -64,7 +63,7 @@ export class Url { /** * Create a URL from a path relative to the app URL */ - static to (path: string, app?: Application): Url { + static to (path: string, app?: IApplication): Url { let baseUrl = '' try { baseUrl = config('app.url', 'http://localhost:3000') @@ -82,7 +81,7 @@ export class Url { static route ( name: TName, params: TParams = {} as TParams, - app?: Application + app?: IApplication ): Url { if (!app) { throw new Error('Application instance required for route generation') @@ -112,7 +111,7 @@ export class Url { static signedRoute ( name: TName, params: TParams = {} as TParams, - app?: Application + app?: IApplication ): Url { const url = Url.route(name, params, app) return url.withSignature(app) @@ -125,7 +124,7 @@ export class Url { name: TName, params: TParams = {} as TParams, expiration: number, - app?: Application + app?: IApplication ): Url { const url = Url.route(name, params, app) return url.withSignature(app, expiration) @@ -135,9 +134,9 @@ export class Url { * Create a URL from a controller action */ static action any> ( - controller: string | [C, methodName: ExtractControllerMethods>], + controller: string | [C, methodName: ExtractClassMethods>], params?: Record, - app?: Application + app?: IApplication ): Url { if (!app) throw new Error('Application instance required for action URL generation') @@ -147,7 +146,7 @@ export class Url { const cname = typeof controllerName === 'string' ? controllerName : controllerName.name - const routes: RouteDefinition[] = app.make('app.routes') + const routes: ClassicRouteDefinition[] = app.make('app.routes') if (!Array.isArray(routes)) { // Backward-compatible message expected by existing tests @@ -281,7 +280,7 @@ export class Url { /** * Add a signature to the URL for security */ - withSignature (app?: Application, expiration?: number): Url { + withSignature (app?: IApplication, expiration?: number): Url { const appInstance = app || this.app if (!appInstance) { throw new Error('Application instance required for URL signing') @@ -316,7 +315,7 @@ export class Url { /** * Verify if a URL signature is valid */ - hasValidSignature (app?: Application): boolean { + hasValidSignature (app?: IApplication): boolean { const appInstance = app || this.app if (!appInstance) { return false diff --git a/packages/url/src/app.globals.d.ts b/packages/url/src/app.globals.d.ts index 38422eb7..6ff84127 100644 --- a/packages/url/src/app.globals.d.ts +++ b/packages/url/src/app.globals.d.ts @@ -1,5 +1,5 @@ -import { ExtractControllerMethods } from '@h3ravel/shared' -import { RequestAwareHelpers, Url } from '.' +import { ExtractClassMethods } from '@h3ravel/shared' +import { RequestAwareHelpers } from '.' export { } @@ -13,7 +13,7 @@ declare global { * Create a URL from a controller action */ function action any> ( - controller: string | [C, methodName: ExtractControllerMethods>], + controller: string | [C, methodName: ExtractClassMethods>], params?: Record ): string; diff --git a/packages/url/src/index.ts b/packages/url/src/index.ts index 93218417..d86c3f19 100644 --- a/packages/url/src/index.ts +++ b/packages/url/src/index.ts @@ -1,4 +1,3 @@ -export * from './Contracts/UrlContract' export * from './Helpers' export * from './Providers/UrlServiceProvider' export * from './RequestAwareHelpers' diff --git a/packages/url/tests/Url.spec.ts b/packages/url/tests/Url.spec.ts index e60f836b..83317680 100644 --- a/packages/url/tests/Url.spec.ts +++ b/packages/url/tests/Url.spec.ts @@ -5,11 +5,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { EnvLoader } from '@h3ravel/config' import { Url } from '../src/Url' -const HttpServiceProvider = (await import(String('@h3ravel/http'))).HttpServiceProvider -const RouteServiceProvider = (await import(String('@h3ravel/router'))).RouteServiceProvider - -console.log = vi.fn(() => 0) - const globalThat = { config: vi.fn((key: string) => { if (key === 'app.url') return 'https://example.com' @@ -45,9 +40,13 @@ describe('Url', () => { beforeAll(async () => { + const { EventsServiceProvider } = await import(('@h3ravel/events')) + const { HttpServiceProvider } = await import(String('@h3ravel/http')) + const { RouteServiceProvider } = await import(String('@h3ravel/router')) + globalThis.env = new EnvLoader().get console.info() - app = await h3ravel([HttpServiceProvider, RouteServiceProvider, UrlServiceProvider], process.cwd()) + app = await h3ravel([EventsServiceProvider, HttpServiceProvider, RouteServiceProvider, UrlServiceProvider], process.cwd()) Object.assign(mockApp, app) Object.assign(globalThis, globalThat) app.make('router').get('path', () => ({ success: true }), 'path') diff --git a/packages/validation/package.json b/packages/validation/package.json index d374c742..1635ebc2 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -63,13 +63,14 @@ "dependencies": { "@h3ravel/shared": "workspace:^", "@h3ravel/support": "workspace:^", - "simple-body-validator": "^1.3.9" + "simple-body-validator": "catalog:", + "@h3ravel/foundation": "workspace:^" }, "peerDependencies": { "@h3ravel/core": "workspace:^", "@h3ravel/config": "workspace:^", "@h3ravel/database": "workspace:^", - "@h3ravel/foundation": "workspace:^" + "@h3ravel/contracts": "workspace:^" }, "devDependencies": { "typescript": "^5.4.0" diff --git a/packages/validation/src/Contracts/Exports.ts b/packages/validation/src/Contracts/Exports.ts new file mode 100644 index 00000000..e6549def --- /dev/null +++ b/packages/validation/src/Contracts/Exports.ts @@ -0,0 +1,2 @@ + +export type { MessagesForRules, RulesForData } from '@h3ravel/contracts' \ No newline at end of file diff --git a/packages/validation/src/Contracts/RuleBuilder.ts b/packages/validation/src/Contracts/RuleBuilder.ts deleted file mode 100644 index cabd2079..00000000 --- a/packages/validation/src/Contracts/RuleBuilder.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ValidationRule } from '../ValidationRule' - -export interface RuleCallable { - name: string; - validator: (value: any, parameters?: string[], attribute?: string) => boolean | Promise; - message?: string -} - -export type CustomRules = ValidationRule | RuleCallable - -export declare class BaseClass { } \ No newline at end of file diff --git a/packages/validation/src/ImplicitRule.ts b/packages/validation/src/ImplicitRule.ts index c7306f8c..06631fb8 100644 --- a/packages/validation/src/ImplicitRule.ts +++ b/packages/validation/src/ImplicitRule.ts @@ -1,9 +1,9 @@ import { ImplicitRule as Rule } from 'simple-body-validator' -import type { RuleCallable } from './Contracts/RuleBuilder' -import { Validator } from './Validator' +import type { ValidationRuleCallable } from '@h3ravel/contracts' +import type { Validator } from './Validator' export abstract class ImplicitRule extends Rule { - rules: RuleCallable[] = [] + rules: ValidationRuleCallable[] = [] /** * Run the validation rule. diff --git a/packages/validation/src/Rules/ExtendedRules.ts b/packages/validation/src/Rules/ExtendedRules.ts index ab18dc33..dd83bf5c 100644 --- a/packages/validation/src/Rules/ExtendedRules.ts +++ b/packages/validation/src/Rules/ExtendedRules.ts @@ -1,7 +1,7 @@ import { DateTime } from '@h3ravel/support' -import type { RuleCallable } from '../Contracts/RuleBuilder' import { ValidationRule } from '../ValidationRule' -import { Validator } from '../Validator' +import type { ValidationRuleCallable } from '@h3ravel/contracts' +import type { Validator } from '../Validator' export class ExtendedRules extends ValidationRule { /** @@ -14,7 +14,7 @@ export class ExtendedRules extends ValidationRule { return this } - rules: RuleCallable[] = [ + rules: ValidationRuleCallable[] = [ { name: 'hex', diff --git a/packages/validation/src/ValidationException.ts b/packages/validation/src/ValidationException.ts index 4f45329f..c7d9e683 100644 --- a/packages/validation/src/ValidationException.ts +++ b/packages/validation/src/ValidationException.ts @@ -1,4 +1,4 @@ -import { IRequest } from '@h3ravel/shared' +import { IRequest } from '@h3ravel/contracts' import { MessageBag } from './utilities/MessageBag' import { Str } from '@h3ravel/support' import { UnprocessableEntityHttpException } from '@h3ravel/foundation' @@ -30,6 +30,9 @@ export class ValidationException extends UnprocessableEntityHttpException { */ public toResponse (request: IRequest) { if (!request.expectsJson()) { + session().flash('_errors', this.errors()) + session().flash('_old', request.all()) + return response() .setCharset('utf-8') .redirect(request.getHeader('referer') || '/', 302) diff --git a/packages/validation/src/ValidationRule.ts b/packages/validation/src/ValidationRule.ts index d04d8b0b..f0928c11 100644 --- a/packages/validation/src/ValidationRule.ts +++ b/packages/validation/src/ValidationRule.ts @@ -1,9 +1,13 @@ +import type { IValidationRule, RulesForData, ValidationRuleCallable } from '@h3ravel/contracts' + import { Rule } from 'simple-body-validator' -import type { RuleCallable } from './Contracts/RuleBuilder' -import { Validator } from './Validator' +import type { Validator } from './Validator' -export abstract class ValidationRule extends Rule { - rules: RuleCallable[] = [] +export abstract class ValidationRule< + D extends Record = any, + R extends RulesForData = any +> extends Rule implements IValidationRule { + rules: ValidationRuleCallable[] = [] private passing: boolean = false /** @@ -13,7 +17,7 @@ export abstract class ValidationRule extends Rule { /** * Set the current validator. */ - public setValidator?(validator: Validator): this + public setValidator?(validator: Validator): this /** * Set the data under validation. */ diff --git a/packages/validation/src/Validator.ts b/packages/validation/src/Validator.ts index eb2c9469..a1953295 100644 --- a/packages/validation/src/Validator.ts +++ b/packages/validation/src/Validator.ts @@ -1,10 +1,9 @@ -import { BaseClass, CustomRules } from './Contracts/RuleBuilder' -import { DotPaths, MessagesForRules, RulesForData } from './Contracts/ValidatorContracts' -import { Validator as SimpleBodyValidator, make, register, setTranslationObject } from 'simple-body-validator' +import type { BaseValidationRuleClass, CustomValidationRules, DotPaths } from '@h3ravel/contracts' +import type { IValidator, MessagesForRules, RulesForData, ValidationRuleSet } from '@h3ravel/contracts' +import { type Validator as SimpleBodyValidator, make, register, setTranslationObject } from 'simple-body-validator' import { ExtendedRules } from './Rules/ExtendedRules' import { MessageBag } from './utilities/MessageBag' -import { RuleSet } from './Contracts/ValidationRuleName' import { ValidationException } from './ValidationException' import { ValidationRule } from './ValidationRule' @@ -13,9 +12,9 @@ register('telephone', function (value) { }) export class Validator< - D extends Record, - R extends RulesForData -> { + D extends Record = any, + R extends RulesForData = RulesForData +> implements IValidator { #messages: Partial, string>> #after: (() => void)[] = [] @@ -26,7 +25,7 @@ export class Validator< private executed: boolean = false private instance?: SimpleBodyValidator private errorBagName = 'default' - private registeredCustomRules: CustomRules[] = [ + private registeredCustomRules: CustomValidationRules[] = [ new ExtendedRules() ] private shouldStopOnFirstFailure = false @@ -166,7 +165,7 @@ export class Validator< * * @param callback */ - public after) => void) | BaseClass> (callback: C | C[]) { + public after) => void) | BaseValidationRuleClass> (callback: C | C[]) { if (Array.isArray(callback)) { for (const rule of callback as any[]) { @@ -212,7 +211,7 @@ export class Validator< /** * Add a single rule to existing rules. */ - public addRule (key: DotPaths, rule: RuleSet): this { + public addRule (key: DotPaths, rule: ValidationRuleSet): this { this.rules[key as never] = rule as never return this } diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 21e006a6..0dd2398a 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1,6 +1,4 @@ -export * from './Contracts/RuleBuilder' -export * from './Contracts/ValidationRuleName' -export * from './Contracts/ValidatorContracts' +export * from './Contracts/Exports' export * from './ImplicitRule' export * from './Providers/ValidationServiceProvider' export * from './Rules/ExtendedRules' diff --git a/packages/validation/src/utilities/MessageBag.ts b/packages/validation/src/utilities/MessageBag.ts index b120fdd0..25a6b061 100644 --- a/packages/validation/src/utilities/MessageBag.ts +++ b/packages/validation/src/utilities/MessageBag.ts @@ -1,16 +1,6 @@ -export interface MessageProvider { - getMessageBag (): MessageBag; -} - -export interface MessageBagContract { - add (key: string, message: string): this; - has (key: string | string[]): boolean; - all (format?: string): string[]; - first (key?: string | null, format?: string | null): string; - getMessages (): Record; -} - -export class MessageBag implements MessageBagContract, MessageProvider { +import type { IMessageBag, ValidationMessageProvider } from '@h3ravel/contracts' + +export class MessageBag implements IMessageBag { /** * All of the registered messages. */ @@ -66,9 +56,9 @@ export class MessageBag implements MessageBagContract, MessageProvider { /** * Merge another message source into this one. */ - merge (messages: Record | MessageProvider): this { + merge (messages: Record | ValidationMessageProvider): this { const incoming = - (messages as MessageProvider).getMessageBag?.()?.getMessages?.() ?? + (messages as ValidationMessageProvider).getMessageBag?.()?.getMessages?.() ?? (messages as Record) for (const [key, list] of Object.entries(incoming)) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da3d405a..89ff1dbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ catalogs: semver: specifier: ^7.7.2 version: 7.7.3 + simple-body-validator: + specifier: ^1.3.9 + version: 1.3.9 source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -120,6 +123,9 @@ catalogs: tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 + tsdown: + specifier: ^0.16.8 + version: 0.16.8 tslib: specifier: ^2.8.1 version: 2.8.1 @@ -139,9 +145,12 @@ catalogs: '@h3ravel/arquebus': specifier: ^0.6.17 version: 0.6.17 + '@h3ravel/collect.js': + specifier: ^5.1.1 + version: 5.1.1 '@h3ravel/musket': - specifier: ^0.3.12 - version: 0.3.12 + specifier: ^0.4.0 + version: 0.4.0 h3: specifier: 2.0.1-rc.5 version: 2.0.1-rc.5 @@ -241,8 +250,8 @@ importers: specifier: 'catalog:' version: 4.2.0 tsdown: - specifier: ^0.16.0 - version: 0.16.0(typescript@5.9.3) + specifier: 'catalog:' + version: 0.16.8(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -279,6 +288,9 @@ importers: '@h3ravel/database': specifier: workspace:^ version: link:../../packages/database + '@h3ravel/events': + specifier: workspace:^ + version: link:../../packages/events '@h3ravel/filesystem': specifier: workspace:^ version: link:../../packages/filesystem @@ -296,7 +308,7 @@ importers: version: link:../../packages/mail '@h3ravel/musket': specifier: catalog:prod - version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.9.2) + version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.9.2) '@h3ravel/queue': specifier: workspace:^ version: link:../../packages/queue @@ -347,8 +359,8 @@ importers: specifier: ^24.9.2 version: 24.9.2 tsdown: - specifier: ^0.15.12 - version: 0.15.12(typescript@5.9.3) + specifier: 'catalog:' + version: 0.16.8(typescript@5.9.3) tsx: specifier: 'catalog:' version: 4.20.6 @@ -358,9 +370,9 @@ importers: packages/cache: dependencies: - '@h3ravel/core': + '@h3ravel/foundation': specifier: workspace:^ - version: link:../core + version: link:../foundation devDependencies: typescript: specifier: ^5.4.0 @@ -370,7 +382,7 @@ importers: dependencies: '@h3ravel/musket': specifier: catalog:prod - version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -378,6 +390,9 @@ importers: specifier: workspace:^ version: link:../support devDependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts '@h3ravel/core': specifier: workspace:^ version: link:../core @@ -395,7 +410,7 @@ importers: version: link:../core '@h3ravel/musket': specifier: catalog:prod - version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -434,6 +449,19 @@ importers: specifier: ^5.9.2 version: 5.9.2 + packages/contracts: + dependencies: + h3: + specifier: catalog:prod + version: 2.0.1-rc.5 + devDependencies: + edge.js: + specifier: 'catalog:' + version: 6.3.0 + simple-body-validator: + specifier: 'catalog:' + version: 1.3.9 + packages/core: dependencies: '@h3ravel/foundation': @@ -479,6 +507,9 @@ importers: specifier: 'catalog:' version: 2.8.1 devDependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts '@types/semver': specifier: 'catalog:' version: 7.7.1 @@ -499,7 +530,7 @@ importers: version: link:../filesystem '@h3ravel/musket': specifier: catalog:prod - version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -514,6 +545,16 @@ importers: specifier: ^5.9.2 version: 5.9.2 + packages/events: + dependencies: + '@h3ravel/core': + specifier: workspace:^ + version: link:../core + devDependencies: + typescript: + specifier: ^5.4.0 + version: 5.9.3 + packages/filesystem: dependencies: '@h3ravel/core': @@ -521,7 +562,7 @@ importers: version: link:../core '@h3ravel/musket': specifier: catalog:prod - version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -541,16 +582,28 @@ importers: '@h3ravel/support': specifier: workspace:^ version: link:../support - devDependencies: h3: specifier: catalog:prod version: 2.0.1-rc.5 + devDependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 + supertest: + specifier: ^7.1.4 + version: 7.1.4 packages/hashing: dependencies: '@h3ravel/core': specifier: workspace:^ version: link:../core + '@h3ravel/foundation': + specifier: workspace:^ + version: link:../foundation '@h3ravel/support': specifier: workspace:^ version: link:../support @@ -567,12 +620,15 @@ importers: packages/http: dependencies: - '@h3ravel/core': + '@h3ravel/contracts': specifier: workspace:^ - version: link:../core + version: link:../contracts + '@h3ravel/foundation': + specifier: workspace:^ + version: link:../foundation '@h3ravel/musket': specifier: catalog:prod - version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/session': specifier: workspace:^ version: link:../session @@ -582,9 +638,6 @@ importers: '@h3ravel/support': specifier: workspace:^ version: link:../support - '@h3ravel/url': - specifier: workspace:^ - version: link:../url '@h3ravel/validation': specifier: workspace:^ version: link:../validation @@ -620,6 +673,9 @@ importers: packages/queue: dependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts '@h3ravel/core': specifier: workspace:^ version: link:../core @@ -630,12 +686,18 @@ importers: packages/router: dependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts '@h3ravel/core': specifier: workspace:^ version: link:../core '@h3ravel/database': specifier: workspace:^ version: link:../database + '@h3ravel/events': + specifier: workspace:^ + version: link:../events '@h3ravel/foundation': specifier: workspace:^ version: link:../foundation @@ -644,7 +706,7 @@ importers: version: link:../http '@h3ravel/musket': specifier: catalog:prod - version: 0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -660,16 +722,19 @@ importers: packages/session: dependencies: - '@h3ravel/core': - specifier: workspace:^ - version: link:../core '@h3ravel/database': specifier: workspace:^ version: link:../database + '@h3ravel/foundation': + specifier: workspace:^ + version: link:../foundation '@h3ravel/shared': specifier: workspace:^ version: link:../shared devDependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts typescript: specifier: ^5.4.0 version: 5.9.3 @@ -698,6 +763,9 @@ importers: specifier: 'catalog:' version: 4.1.1 devDependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts fetchdts: specifier: ^0.1.6 version: 0.1.6 @@ -714,6 +782,9 @@ importers: specifier: 'catalog:' version: 3.7.2 devDependencies: + '@h3ravel/collect.js': + specifier: catalog:prod + version: 5.1.1 '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -726,12 +797,12 @@ importers: packages/url: dependencies: - '@h3ravel/config': + '@h3ravel/contracts': specifier: workspace:^ - version: link:../config - '@h3ravel/core': + version: link:../contracts + '@h3ravel/foundation': specifier: workspace:^ - version: link:../core + version: link:../foundation '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -748,6 +819,9 @@ importers: '@h3ravel/config': specifier: workspace:^ version: link:../config + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts '@h3ravel/core': specifier: workspace:^ version: link:../core @@ -764,7 +838,7 @@ importers: specifier: workspace:^ version: link:../support simple-body-validator: - specifier: ^1.3.9 + specifier: 'catalog:' version: 1.3.9 devDependencies: typescript: @@ -781,7 +855,7 @@ importers: version: link:../http '@h3ravel/musket': specifier: catalog:prod - version: 0.3.12(@h3ravel/support@0.15.6)(@types/node@24.10.0) + version: 0.4.0(@h3ravel/support@0.15.6)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:* version: link:../shared @@ -1543,8 +1617,11 @@ packages: engines: {node: '>=14', pnpm: '>=4'} hasBin: true - '@h3ravel/musket@0.3.12': - resolution: {integrity: sha512-vg6tnihXjchqnIcqyHE7htApl1Z0MACshlNP9Euw0P35aRGWMvNL9O6dtgLyqaDkIXeEQEBeGoxlmom5jfcoZw==} + '@h3ravel/collect.js@5.1.1': + resolution: {integrity: sha512-ypP6ugFOYR94A5J2YAoBsyvHLZ8Z5dRl22i0WodMK8sWYAzvgM4UjSMS8k58fP8fkwJtYxxScnyXpAY0y4Y/Hw==} + + '@h3ravel/musket@0.4.0': + resolution: {integrity: sha512-avecKyX+jDNm5AFUBLARoCSNYUJm8mda6MvykoGhaMhULaKUQFzxu9wwzvI+HKY+VOFjyErXbO2aOYe+kvbC2g==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@h3ravel/support': ^0.15.6 @@ -1761,6 +1838,10 @@ packages: '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1781,11 +1862,15 @@ packages: engines: {node: '>=10'} deprecated: This functionality has been moved to @npmcli/fs - '@oxc-project/types@0.95.0': - resolution: {integrity: sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==} + '@oxc-project/runtime@0.99.0': + resolution: {integrity: sha512-8iE5/4OK0SLHqWzRxSvI1gjFPmIH6718s8iwkuco95rBZsCZIHq+5wy4lYsASxnH+8FOhbGndiUrcwsVG5i2zw==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.99.0': + resolution: {integrity: sha512-LLDEhXB7g1m5J+woRSgfKsFPS3LhR9xRhTeIoEBm5WrkwMxn6eZ0Ld0c0K5eHB57ChZX6I3uSmmLjZ8pcjlRcw==} - '@oxc-project/types@0.96.0': - resolution: {integrity: sha512-r/xkmoXA0xEpU6UGtn18CNVjXH6erU3KCpCDbpLmbVxBFor1U9MqN5Z2uMmCHJuXjJzlnDR+hWY+yPoLo8oHDw==} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} '@phc/format@1.0.0': resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} @@ -1818,177 +1903,91 @@ packages: '@quansync/fs@0.1.5': resolution: {integrity: sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA==} - '@rolldown/binding-android-arm64@1.0.0-beta.45': - resolution: {integrity: sha512-bfgKYhFiXJALeA/riil908+2vlyWGdwa7Ju5S+JgWZYdR4jtiPOGdM6WLfso1dojCh+4ZWeiTwPeV9IKQEX+4g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-android-arm64@1.0.0-beta.46': - resolution: {integrity: sha512-1nfXUqZ227uKuLw9S12OQZU5z+h+cUOXLW5orntWVxHWvt20pt1PGUcVoIU8ssngKABu0vzHY268kAxuYX24BQ==} + '@rolldown/binding-android-arm64@1.0.0-beta.52': + resolution: {integrity: sha512-MBGIgysimZPqTDcLXI+i9VveijkP5C3EAncEogXhqfax6YXj1Tr2LY3DVuEOMIjWfMPMhtQSPup4fSTAmgjqIw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-beta.45': - resolution: {integrity: sha512-xjCv4CRVsSnnIxTuyH1RDJl5OEQ1c9JYOwfDAHddjJDxCw46ZX9q80+xq7Eok7KC4bRSZudMJllkvOKv0T9SeA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-arm64@1.0.0-beta.46': - resolution: {integrity: sha512-w4IyumCQkpA3ezZ37COG3mMusFYxjEE8zqCfXZU/qb5k1JMD2kVl0fgJafIbGli27tgelYMweXkJGnlrxSGT9Q==} + '@rolldown/binding-darwin-arm64@1.0.0-beta.52': + resolution: {integrity: sha512-MmKeoLnKu1d9j6r19K8B+prJnIZ7u+zQ+zGQ3YHXGnr41rzE3eqQLovlkvoZnRoxDGPA4ps0pGiwXy6YE3lJyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-beta.45': - resolution: {integrity: sha512-ddcO9TD3D/CLUa/l8GO8LHzBOaZqWg5ClMy3jICoxwCuoz47h9dtqPsIeTiB6yR501LQTeDsjA4lIFd7u3Ljfw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.0-beta.46': - resolution: {integrity: sha512-9QqaRHPbdAnv306+7nzltq4CktJ49Z4W9ybHLWYxSeDSoOGL4l1QmxjDWoRHrqYEkNr+DWHqqoD4NNHgOk7lKw==} + '@rolldown/binding-darwin-x64@1.0.0-beta.52': + resolution: {integrity: sha512-qpHedvQBmIjT8zdnjN3nWPR2qjQyJttbXniCEKKdHeAbZG9HyNPBUzQF7AZZGwmS9coQKL+hWg9FhWzh2dZ2IA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-beta.45': - resolution: {integrity: sha512-MBTWdrzW9w+UMYDUvnEuh0pQvLENkl2Sis15fHTfHVW7ClbGuez+RWopZudIDEGkpZXdeI4CkRXk+vdIIebrmg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-freebsd-x64@1.0.0-beta.46': - resolution: {integrity: sha512-Cuk5opdEMb+Evi7QcGArc4hWVoHSGz/qyUUWLTpFJWjylb8wH1u4f+HZE6gVGACuf4w/5P/VhAIamHyweAbBVQ==} + '@rolldown/binding-freebsd-x64@1.0.0-beta.52': + resolution: {integrity: sha512-dDp7WbPapj/NVW0LSiH/CLwMhmLwwKb3R7mh2kWX+QW85X1DGVnIEyKh9PmNJjB/+suG1dJygdtdNPVXK1hylg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': - resolution: {integrity: sha512-4YgoCFiki1HR6oSg+GxxfzfnVCesQxLF1LEnw9uXS/MpBmuog0EOO2rYfy69rWP4tFZL9IWp6KEfGZLrZ7aUog==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.46': - resolution: {integrity: sha512-BPWDxEnxb4JNMXrSmPuc5ywI6cHOELofmT0e/WGkbL1MwKYRVvqTf+gMcGLF6zAV+OF5hLYMAEk8XKfao6xmDQ==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.52': + resolution: {integrity: sha512-9e4l6vy5qNSliDPqNfR6CkBOAx6PH7iDV4OJiEJzajajGrVy8gc/IKKJUsoE52G8ud8MX6r3PMl97NfwgOzB7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': - resolution: {integrity: sha512-LE1gjAwQRrbCOorJJ7LFr10s5vqYf5a00V5Ea9wXcT2+56n5YosJkcp8eQ12FxRBv2YX8dsdQJb+ZTtYJwb6XQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.46': - resolution: {integrity: sha512-CDQSVlryuRC955EwgbBK1h/6xQyttSxQG8+6/PeOfvUlfKGPMbBdcsOEHzGve5ED1Y7Ovh2UFjY/eT106aQqig==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': - resolution: {integrity: sha512-tdy8ThO/fPp40B81v0YK3QC+KODOmzJzSUOO37DinQxzlTJ026gqUSOM8tzlVixRbQJltgVDCTYF8HNPRErQTA==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.52': + resolution: {integrity: sha512-V48oDR84feRU2KRuzpALp594Uqlx27+zFsT6+BgTcXOtu7dWy350J1G28ydoCwKB+oxwsRPx2e7aeQnmd3YJbQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.46': - resolution: {integrity: sha512-6IZHycZetmVaC9zwcl1aA9fPYPuxLa5apALjJRoJu/2BZdER3zBWxDnCzlEh4SUlo++cwdfV9ZQRK9JS8cLNuA==} + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.52': + resolution: {integrity: sha512-ENLmSQCWqSA/+YN45V2FqTIemg7QspaiTjlm327eUAMeOLdqmSOVVyrQexJGNTQ5M8sDYCgVAig2Kk01Ggmqaw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': - resolution: {integrity: sha512-lS082ROBWdmOyVY/0YB3JmsiClaWoxvC+dA8/rbhyB9VLkvVEaihLEOr4CYmrMse151C4+S6hCw6oa1iewox7g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.46': - resolution: {integrity: sha512-R/kI8fMnsxXvWzcMv5A408hfvrwtAwD/HdQKIE1HKWmfxdSHB11Y3PVwlnt7RVo7I++6mWCIxxj5o3gut4ibEw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': - resolution: {integrity: sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA==} + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.52': + resolution: {integrity: sha512-klahlb2EIFltSUubn/VLjuc3qxp1E7th8ukayPfdkcKvvYcQ5rJztgx8JsJSuAKVzKtNTqUGOhy4On71BuyV8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-beta.46': - resolution: {integrity: sha512-vGUXKuHGUlG2XBwvN4A8KIegeaVVxN2ZxdGG9thycwRkzUvZ9ccKvqUVZM8cVRyNRWgVgsGCS18qLUefVplwKw==} + '@rolldown/binding-linux-x64-musl@1.0.0-beta.52': + resolution: {integrity: sha512-UuA+JqQIgqtkgGN2c/AQ5wi8M6mJHrahz/wciENPTeI6zEIbbLGoth5XN+sQe2pJDejEVofN9aOAp0kaazwnVg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': - resolution: {integrity: sha512-fljEqbO7RHHogNDxYtTzr+GNjlfOx21RUyGmF+NrkebZ8emYYiIqzPxsaMZuRx0rgZmVmliOzEp86/CQFDKhJQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-openharmony-arm64@1.0.0-beta.46': - resolution: {integrity: sha512-6SpDGH+0Dud3/RFDoC6fva6+Cm/0COnMRKR8kI4ssHWlCXPymlM59kYFCIBLZZqwURpNVVMPln4rWjxXuwD23w==} + '@rolldown/binding-openharmony-arm64@1.0.0-beta.52': + resolution: {integrity: sha512-1BNQW8u4ro8bsN1+tgKENJiqmvc+WfuaUhXzMImOVSMw28pkBKdfZtX2qJPADV3terx+vNJtlsgSGeb3+W6Jiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-beta.45': - resolution: {integrity: sha512-ZJDB7lkuZE9XUnWQSYrBObZxczut+8FZ5pdanm8nNS1DAo8zsrPuvGwn+U3fwU98WaiFsNrA4XHngesCGr8tEQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@rolldown/binding-wasm32-wasi@1.0.0-beta.46': - resolution: {integrity: sha512-peWDGp8YUAbTw5RJzr9AuPlTuf2adr+TBNIGF6ysMbobBKuQL41wYfGQlcerXJfLmjnQLf6DU2zTPBTfrS2Y8A==} + '@rolldown/binding-wasm32-wasi@1.0.0-beta.52': + resolution: {integrity: sha512-K/p7clhCqJOQpXGykrFaBX2Dp9AUVIDHGc+PtFGBwg7V+mvBTv/tsm3LC3aUmH02H2y3gz4y+nUTQ0MLpofEEg==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': - resolution: {integrity: sha512-zyzAjItHPUmxg6Z8SyRhLdXlJn3/D9KL5b9mObUrBHhWS/GwRH4665xCiFqeuktAhhWutqfc+rOV2LjK4VYQGQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.46': - resolution: {integrity: sha512-Ydbwg1JCnVbTAuDyKtu3dOuBLgZ6iZsy8p1jMPX/r7LMPnpXnS15GNcmMwa11nyl/M2VjGE1i/MORUTMt8mnRQ==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.52': + resolution: {integrity: sha512-a4EkXBtnYYsKipjS7QOhEBM4bU5IlR9N1hU+JcVEVeuTiaslIyhWVKsvf7K2YkQHyVAJ+7/A9BtrGqORFcTgng==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': - resolution: {integrity: sha512-wODcGzlfxqS6D7BR0srkJk3drPwXYLu7jPHN27ce2c4PUnVVmJnp9mJzUQGT4LpmHmmVdMZ+P6hKvyTGBzc1CA==} + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.52': + resolution: {integrity: sha512-5ZXcYyd4GxPA6QfbGrNcQjmjbuLGvfz6728pZMsQvGHI+06LT06M6TPtXvFvLgXtexc+OqvFe1yAIXJU1gob/w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.46': - resolution: {integrity: sha512-XcPZG2uDxEn6G3takXQvi7xWgDiJqdC0N6mubL/giKD4I65zgQtbadwlIR8oDB/erOahZr5IX8cRBVcK3xcvpg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45': - resolution: {integrity: sha512-wiU40G1nQo9rtfvF9jLbl79lUgjfaD/LTyUEw2Wg/gdF5OhjzpKMVugZQngO+RNdwYaNj+Fs+kWBWfp4VXPMHA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.46': - resolution: {integrity: sha512-VPC+F9S6nllv02aGG+gxHRgpOaOlYBPn94kDe9DCFSLOztf4uYIAkN+tLDlg5OcsOC8XNR5rP49zOfI0PfnHYw==} + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.52': + resolution: {integrity: sha512-tzpnRQXJrSzb8Z9sm97UD3cY0toKOImx+xRKsDLX4zHaAlRXWh7jbaKBePJXEN7gNw7Nm03PBNwphdtA8KSUYQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-beta.45': - resolution: {integrity: sha512-Le9ulGCrD8ggInzWw/k2J8QcbPz7eGIOWqfJ2L+1R0Opm7n6J37s2hiDWlh6LJN0Lk9L5sUzMvRHKW7UxBZsQA==} - - '@rolldown/pluginutils@1.0.0-beta.46': - resolution: {integrity: sha512-xMNwJo/pHkEP/mhNVnW+zUiJDle6/hxrwO0mfSJuEVRbBfgrJFuUSRoZx/nYUw5pCjrysl9OkNXCkAdih8GCnA==} + '@rolldown/pluginutils@1.0.0-beta.52': + resolution: {integrity: sha512-/L0htLJZbaZFL1g9OHOblTxbCYIGefErJjtYOwgl9ZqNx27P3L0SDfjhhHIss32gu5NWgnxuT2a2Hnnv6QGHKA==} '@rollup/plugin-run@3.1.0': resolution: {integrity: sha512-k2daijcVA8RAG1PXUFtIAOmb9ifiMv6Kth3Y9OhZ8/W+j8eTgZkVsOmBQD11HaeY1rYqRb0aLjX4e2V9bpS01Q==} @@ -2670,6 +2669,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2682,6 +2684,9 @@ packages: '@types/luxon@3.7.1': resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/mute-stream@0.0.1': resolution: {integrity: sha512-0yQLzYhCqGz7CQPE3iDmYjhb7KMBFOP+tBkyw+/Y2YyDI5wpS7itXXxneN1zSsUwWx3Ji6YiVYrhAnpQGS/vkw==} @@ -2712,6 +2717,12 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} @@ -2980,12 +2991,15 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-kit@2.1.3: - resolution: {integrity: sha512-TH+b3Lv6pUjy/Nu0m6A2JULtdzLpmqF9x1Dhj00ZoEiML8qvVA9j1flkzTKNYgdEhWrjDwtWNpyyCUbfQe514g==} + ast-kit@2.2.0: + resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} engines: {node: '>=20.19.0'} ast-module-types@6.0.1: @@ -2999,6 +3013,9 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + aws-ssl-profiles@1.1.2: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} @@ -3029,8 +3046,8 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - birpc@2.7.0: - resolution: {integrity: sha512-tub/wFGH49vNCm0xraykcY3TcRgX/3JsALYq/Lwrtti+bTyFHkCUAWF5wgYoie8P41wYwig2mIKiqoocr1EkEQ==} + birpc@2.8.0: + resolution: {integrity: sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==} bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -3082,6 +3099,14 @@ packages: resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==} engines: {node: '>= 10'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -3117,6 +3142,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -3189,6 +3218,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -3212,6 +3245,9 @@ packages: commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -3221,6 +3257,9 @@ packages: cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -3284,8 +3323,9 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} - defu@6.1.4: - resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} @@ -3355,6 +3395,9 @@ packages: peerDependencies: typescript: ^5.4.4 + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -3379,15 +3422,19 @@ packages: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} - dts-resolver@2.1.2: - resolution: {integrity: sha512-xeXHBQkn2ISSXxbJWD828PFjtyg+/UrMDo7W4Ffcs7+YWCquxU8YjV1KoxuiL+eJ5pg3ll+bC6flVv61L3LKZg==} - engines: {node: '>=20.18.0'} + dts-resolver@2.1.3: + resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} + engines: {node: '>=20.19.0'} peerDependencies: oxc-resolver: '>=11.0.0' peerDependenciesMeta: oxc-resolver: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -3455,9 +3502,25 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -3589,6 +3652,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-xml-parser@5.2.5: resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} hasBin: true @@ -3677,6 +3743,14 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -3726,6 +3800,10 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-own-enumerable-property-symbols@3.0.2: resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} @@ -3733,6 +3811,10 @@ packages: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@9.0.1: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} @@ -3783,6 +3865,10 @@ packages: engines: {node: '>=0.6.0'} hasBin: true + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -3806,6 +3892,14 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + has-unicode@2.0.1: resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} @@ -4254,14 +4348,35 @@ packages: resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==} engines: {node: '>= 10'} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -4436,6 +4551,13 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This package is no longer supported. + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4742,6 +4864,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -4779,6 +4905,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + rechoir@0.8.0: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} @@ -4848,13 +4978,13 @@ packages: engines: {node: 20 || >=22} hasBin: true - rolldown-plugin-dts@0.17.3: - resolution: {integrity: sha512-8mGnNUVNrqEdTnrlcaDxs4sAZg0No6njO+FuhQd4L56nUbJO1tHxOoKDH3mmMJg7f/BhEj/1KjU5W9kZ9zM/kQ==} - engines: {node: '>=20.18.0'} + rolldown-plugin-dts@0.18.1: + resolution: {integrity: sha512-uIgNMix6OI+6bSkw0nw6O+G/ydPRCWKwvvcEyL6gWkVkSFVGWWO23DX4ZYVOqC7w5u2c8uPY9Q74U0QCKvegFA==} + engines: {node: '>=20.19.0'} peerDependencies: '@ts-macro/tsc': ^0.3.6 '@typescript/native-preview': '>=7.0.0-dev.20250601.1' - rolldown: ^1.0.0-beta.44 + rolldown: ^1.0.0-beta.51 typescript: ^5.0.0 vue-tsc: ~3.1.0 peerDependenciesMeta: @@ -4867,13 +4997,8 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-beta.45: - resolution: {integrity: sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - rolldown@1.0.0-beta.46: - resolution: {integrity: sha512-FYUbq0StVHOjkR/hEJ667Pup3ugeB9odBcbmxU5il9QfT9X2t/FPhkqFYQthbYxD2bKnQyO+2vHTgnmOHwZdeA==} + rolldown@1.0.0-beta.52: + resolution: {integrity: sha512-Hbnpljue+JhMJrlOjQ1ixp9me7sUec7OjFvS+A1Qm8k8Xyxmw3ZhxFu7LlSXW1s9AX3POE9W9o2oqCEeR5uDmg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -4938,6 +5063,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -5112,6 +5253,14 @@ packages: engines: {node: '>=18'} hasBin: true + superagent@10.2.3: + resolution: {integrity: sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==} + engines: {node: '>=14.18.0'} + + supertest@7.1.4: + resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==} + engines: {node: '>=14.18.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -5242,43 +5391,17 @@ packages: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} - tsdown@0.15.12: - resolution: {integrity: sha512-c8VLlQm8/lFrOAg5VMVeN4NAbejZyVQkzd+ErjuaQgJFI/9MhR9ivr0H/CM7UlOF1+ELlF6YaI7sU/4itgGQ8w==} - engines: {node: '>=20.19.0'} - hasBin: true - peerDependencies: - '@arethetypeswrong/core': ^0.18.1 - publint: ^0.3.0 - typescript: ^5.0.0 - unplugin-lightningcss: ^0.4.0 - unplugin-unused: ^0.5.0 - unrun: ^0.2.1 - peerDependenciesMeta: - '@arethetypeswrong/core': - optional: true - publint: - optional: true - typescript: - optional: true - unplugin-lightningcss: - optional: true - unplugin-unused: - optional: true - unrun: - optional: true - - tsdown@0.16.0: - resolution: {integrity: sha512-VCqqxT5FbjCmxmLNlOLHiNhu1MBtdvCsk43murvUFloQzQzr/C0FRauWtAw7lAPmS40rZlgocCoTNFqX72WSTg==} + tsdown@0.16.8: + resolution: {integrity: sha512-6ANw9mgU9kk7SvTBKvpDu/DVJeAFECiLUSeL5M7f5Nm5H97E7ybxmXT4PQ23FySYn32y6OzjoAH/lsWCbGzfLA==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: '@arethetypeswrong/core': ^0.18.1 - '@vitejs/devtools': ^0.0.0-alpha.10 + '@vitejs/devtools': ^0.0.0-alpha.18 publint: ^0.3.0 typescript: ^5.0.0 unplugin-lightningcss: ^0.4.0 unplugin-unused: ^0.5.0 - unrun: ^0.2.1 peerDependenciesMeta: '@arethetypeswrong/core': optional: true @@ -5292,8 +5415,6 @@ packages: optional: true unplugin-unused: optional: true - unrun: - optional: true tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -5336,8 +5457,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - unconfig@7.3.3: - resolution: {integrity: sha512-QCkQoOnJF8L107gxfHL0uavn7WD9b3dpBcFX6HtfQYmjw2YzWxGuFQ0N0J6tE9oguCBJn9KOvfqYDCMPHIZrBA==} + unconfig-core@7.4.1: + resolution: {integrity: sha512-Bp/bPZjV2Vl/fofoA2OYLSnw1Z0MOhCX7zHnVCYrazpfZvseBbGhwcNQMxsg185Mqh7VZQqK3C8hFG/Dyng+yA==} undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -5359,6 +5480,16 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + unrun@0.2.15: + resolution: {integrity: sha512-UZ653WcLSK33meAX3nHXgD1JJ+t4RGa8WIzv9Dr4Y5ahhILZ5UIvObkVauKmtwwZ8Lsin3hUfso2UlzIwOiCNA==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -6958,7 +7089,9 @@ snapshots: - sqlite3 - supports-color - '@h3ravel/musket@0.3.12(@h3ravel/support@0.15.6)(@types/node@24.10.0)': + '@h3ravel/collect.js@5.1.1': {} + + '@h3ravel/musket@0.4.0(@h3ravel/support@0.15.6)(@types/node@24.10.0)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.10.0) '@h3ravel/support': 0.15.6 @@ -6975,7 +7108,7 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.3.12(@h3ravel/support@packages+support)(@types/node@24.10.0)': + '@h3ravel/musket@0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.10.0) '@h3ravel/support': link:packages/support @@ -6992,7 +7125,7 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.3.12(@h3ravel/support@packages+support)(@types/node@24.9.2)': + '@h3ravel/musket@0.4.0(@h3ravel/support@packages+support)(@types/node@24.9.2)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.9.2) '@h3ravel/support': link:packages/support @@ -7380,6 +7513,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7404,9 +7539,13 @@ snapshots: rimraf: 3.0.2 optional: true - '@oxc-project/types@0.95.0': {} + '@oxc-project/runtime@0.99.0': {} - '@oxc-project/types@0.96.0': {} + '@oxc-project/types@0.99.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 '@phc/format@1.0.0': {} @@ -7445,97 +7584,51 @@ snapshots: dependencies: quansync: 0.2.11 - '@rolldown/binding-android-arm64@1.0.0-beta.45': - optional: true - - '@rolldown/binding-android-arm64@1.0.0-beta.46': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.0-beta.45': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.0-beta.46': - optional: true - - '@rolldown/binding-darwin-x64@1.0.0-beta.45': - optional: true - - '@rolldown/binding-darwin-x64@1.0.0-beta.46': + '@rolldown/binding-android-arm64@1.0.0-beta.52': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.45': + '@rolldown/binding-darwin-arm64@1.0.0-beta.52': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.46': + '@rolldown/binding-darwin-x64@1.0.0-beta.52': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': + '@rolldown/binding-freebsd-x64@1.0.0-beta.52': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.46': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.52': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.52': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.46': + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.52': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.52': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.46': + '@rolldown/binding-linux-x64-musl@1.0.0-beta.52': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': + '@rolldown/binding-openharmony-arm64@1.0.0-beta.52': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.46': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.0-beta.46': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.0-beta.46': - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.0-beta.45': - dependencies: - '@napi-rs/wasm-runtime': 1.0.7 - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.0-beta.46': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.52': dependencies: '@napi-rs/wasm-runtime': 1.0.7 optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.46': - optional: true - - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.52': optional: true - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.46': + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.52': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45': + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.52': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.46': - optional: true - - '@rolldown/pluginutils@1.0.0-beta.45': {} - - '@rolldown/pluginutils@1.0.0-beta.46': {} + '@rolldown/pluginutils@1.0.0-beta.52': {} '@rollup/plugin-run@3.1.0(rollup@4.52.5)': dependencies: @@ -8301,6 +8394,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cookiejar@2.1.5': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -8309,6 +8404,8 @@ snapshots: '@types/luxon@3.7.1': {} + '@types/methods@1.1.4': {} + '@types/mute-stream@0.0.1': dependencies: '@types/node': 20.19.24 @@ -8341,6 +8438,18 @@ snapshots: '@types/semver@7.7.1': {} + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 24.10.0 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + '@types/uuid@9.0.8': {} '@types/wrap-ansi@3.0.0': {} @@ -8687,9 +8796,11 @@ snapshots: array-union@2.1.0: {} + asap@2.0.6: {} + assertion-error@2.0.1: {} - ast-kit@2.1.3: + ast-kit@2.2.0: dependencies: '@babel/parser': 7.28.5 pathe: 2.0.3 @@ -8704,6 +8815,8 @@ snapshots: astring@1.9.0: {} + asynckit@0.4.0: {} + aws-ssl-profiles@1.1.2: {} balanced-match@1.0.2: {} @@ -8728,7 +8841,7 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 - birpc@2.7.0: {} + birpc@2.8.0: {} bl@4.1.0: dependencies: @@ -8806,6 +8919,16 @@ snapshots: - bluebird optional: true + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} case-anything@3.1.2: {} @@ -8839,6 +8962,10 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + chownr@1.1.4: {} chownr@2.0.0: {} @@ -8896,6 +9023,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@10.0.1: {} commander@12.1.0: {} @@ -8908,6 +9039,8 @@ snapshots: commondir@1.0.1: {} + component-emitter@1.3.1: {} + concat-map@0.0.1: {} console-control-strings@1.1.0: @@ -8915,6 +9048,8 @@ snapshots: cookie-es@2.0.0: {} + cookiejar@2.1.4: {} + create-require@1.1.1: {} cross-env@10.1.0: @@ -8961,7 +9096,7 @@ snapshots: define-lazy-prop@3.0.0: {} - defu@6.1.4: {} + delayed-stream@1.0.0: {} delegates@1.0.0: optional: true @@ -9041,6 +9176,11 @@ snapshots: transitivePeerDependencies: - supports-color + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + diff@4.0.2: {} diff@8.0.2: {} @@ -9057,7 +9197,13 @@ snapshots: dotenv@17.2.3: {} - dts-resolver@2.1.2: {} + dts-resolver@2.1.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 eastasianwidth@0.2.0: {} @@ -9135,8 +9281,23 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -9329,6 +9490,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fast-xml-parser@5.2.5: dependencies: strnum: 2.1.1 @@ -9421,6 +9584,20 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + fs-constants@1.0.0: {} fs-extra@7.0.1: @@ -9473,10 +9650,28 @@ snapshots: get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + get-own-enumerable-property-symbols@3.0.2: {} get-package-type@0.1.0: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 @@ -9542,6 +9737,8 @@ snapshots: dependencies: minimist: 1.2.8 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} graphemer@1.4.0: {} @@ -9555,6 +9752,12 @@ snapshots: has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + has-unicode@2.0.1: optional: true @@ -9757,7 +9960,8 @@ snapshots: dependencies: '@isaacs/cliui': 8.0.2 - jiti@2.6.1: {} + jiti@2.6.1: + optional: true js-md4@0.3.2: {} @@ -10007,13 +10211,25 @@ snapshots: - supports-color optional: true + math-intrinsics@1.1.0: {} + merge2@1.4.1: {} + methods@1.1.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@2.6.0: {} + mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -10184,6 +10400,10 @@ snapshots: set-blocking: 2.0.0 optional: true + object-inspect@1.13.4: {} + + obug@2.1.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -10479,6 +10699,10 @@ snapshots: punycode@2.3.1: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + quansync@0.2.11: {} queue-microtask@1.2.3: {} @@ -10519,6 +10743,8 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.0.0: {} + rechoir@0.8.0: dependencies: resolve: 1.22.11 @@ -10575,81 +10801,42 @@ snapshots: glob: 11.0.3 package-json-from-dist: 1.0.1 - rolldown-plugin-dts@0.17.3(rolldown@1.0.0-beta.45)(typescript@5.9.3): + rolldown-plugin-dts@0.18.1(rolldown@1.0.0-beta.52)(typescript@5.9.3): dependencies: '@babel/generator': 7.28.5 '@babel/parser': 7.28.5 '@babel/types': 7.28.5 - ast-kit: 2.1.3 - birpc: 2.7.0 - debug: 4.4.3 - dts-resolver: 2.1.2 + ast-kit: 2.2.0 + birpc: 2.8.0 + dts-resolver: 2.1.3 get-tsconfig: 4.13.0 magic-string: 0.30.21 - rolldown: 1.0.0-beta.45 + obug: 2.1.1 + rolldown: 1.0.0-beta.52 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver - - supports-color - rolldown-plugin-dts@0.17.3(rolldown@1.0.0-beta.46)(typescript@5.9.3): + rolldown@1.0.0-beta.52: dependencies: - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - ast-kit: 2.1.3 - birpc: 2.7.0 - debug: 4.4.3 - dts-resolver: 2.1.2 - get-tsconfig: 4.13.0 - magic-string: 0.30.21 - rolldown: 1.0.0-beta.46 + '@oxc-project/types': 0.99.0 + '@rolldown/pluginutils': 1.0.0-beta.52 optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - oxc-resolver - - supports-color - - rolldown@1.0.0-beta.45: - dependencies: - '@oxc-project/types': 0.95.0 - '@rolldown/pluginutils': 1.0.0-beta.45 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.45 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.45 - '@rolldown/binding-darwin-x64': 1.0.0-beta.45 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.45 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.45 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.45 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.45 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.45 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.45 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.45 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.45 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.45 - '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.45 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.45 - - rolldown@1.0.0-beta.46: - dependencies: - '@oxc-project/types': 0.96.0 - '@rolldown/pluginutils': 1.0.0-beta.46 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.46 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.46 - '@rolldown/binding-darwin-x64': 1.0.0-beta.46 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.46 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.46 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.46 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.46 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.46 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.46 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.46 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.46 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.46 - '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.46 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.46 + '@rolldown/binding-android-arm64': 1.0.0-beta.52 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.52 + '@rolldown/binding-darwin-x64': 1.0.0-beta.52 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.52 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.52 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.52 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.52 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.52 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.52 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.52 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.52 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.52 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.52 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.52 rollup@4.52.3: dependencies: @@ -10743,6 +10930,34 @@ snapshots: shebang-regex@3.0.0: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -10916,6 +11131,27 @@ snapshots: dependencies: commander: 12.1.0 + superagent@10.2.3: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.0 + transitivePeerDependencies: + - supports-color + + supertest@7.1.4: + dependencies: + methods: 1.1.2 + superagent: 10.2.3 + transitivePeerDependencies: + - supports-color + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -11049,54 +11285,30 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tsdown@0.15.12(typescript@5.9.3): + tsdown@0.16.8(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 - chokidar: 4.0.3 - debug: 4.4.3 + chokidar: 5.0.0 diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-beta.45 - rolldown-plugin-dts: 0.17.3(rolldown@1.0.0-beta.45)(typescript@5.9.3) + obug: 2.1.1 + rolldown: 1.0.0-beta.52 + rolldown-plugin-dts: 0.18.1(rolldown@1.0.0-beta.52)(typescript@5.9.3) semver: 7.7.3 tinyexec: 1.0.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 - unconfig: 7.3.3 + unconfig-core: 7.4.1 + unrun: 0.2.15 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - '@ts-macro/tsc' - '@typescript/native-preview' - oxc-resolver - - supports-color - - vue-tsc - - tsdown@0.16.0(typescript@5.9.3): - dependencies: - ansis: 4.2.0 - cac: 6.7.14 - chokidar: 4.0.3 - debug: 4.4.3 - diff: 8.0.2 - empathic: 2.0.0 - hookable: 5.5.3 - rolldown: 1.0.0-beta.46 - rolldown-plugin-dts: 0.17.3(rolldown@1.0.0-beta.46)(typescript@5.9.3) - semver: 7.7.3 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tree-kill: 1.2.2 - unconfig: 7.3.3 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@ts-macro/tsc' - - '@typescript/native-preview' - - oxc-resolver - - supports-color + - synckit - vue-tsc tslib@2.8.1: {} @@ -11135,11 +11347,9 @@ snapshots: typescript@5.9.3: {} - unconfig@7.3.3: + unconfig-core@7.4.1: dependencies: '@quansync/fs': 0.1.5 - defu: 6.1.4 - jiti: 2.6.1 quansync: 0.2.11 undici-types@6.21.0: {} @@ -11160,6 +11370,11 @@ snapshots: universalify@0.1.2: {} + unrun@0.2.15: + dependencies: + '@oxc-project/runtime': 0.99.0 + rolldown: 1.0.0-beta.52 + uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c878d320..b44720b7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ packages: - packages/database - packages/http - packages/cache + - packages/events - packages/config - packages/hashing - packages/queue @@ -17,6 +18,7 @@ packages: - packages/session - packages/validation - packages/foundation + - packages/contracts - examples/* - docs @@ -64,11 +66,14 @@ catalog: typescript-eslint: ^8.46.3 utility-types: ^3.11.0 vite-tsconfig-paths: ^5.1.4 + tsdown: ^0.16.8 + simple-body-validator: ^1.3.9 catalogs: prod: '@h3ravel/arquebus': ^0.6.17 - '@h3ravel/musket': ^0.3.12 + '@h3ravel/musket': ^0.4.0 + '@h3ravel/collect.js': ^5.1.1 h3: 2.0.1-rc.5 ignoredBuiltDependencies: diff --git a/tsconfig.base.json b/tsconfig.base.json index 4f4af95b..d9711a19 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -3,6 +3,7 @@ "baseUrl": ".", "paths": { "@h3ravel/cache": ["packages/cache/src/index.ts"], + "@h3ravel/events": ["packages/events/src/index.ts"], "@h3ravel/config": ["packages/config/src/index.ts"], "@h3ravel/console": ["packages/console/src/index.ts"], "@h3ravel/core": ["packages/core/src/index.ts"], @@ -19,7 +20,8 @@ "@h3ravel/view": ["packages/view/src/index.ts"], "@h3ravel/session": ["packages/session/src/index.ts"], "@h3ravel/foundation": ["packages/foundation/src/index.ts"], - "@h3ravel/validation": ["packages/validation/src/index.ts"] + "@h3ravel/validation": ["packages/validation/src/index.ts"], + "@h3ravel/contracts": ["packages/contracts/src/index.ts"] }, "experimentalDecorators": true, "emitDecoratorMetadata": true, diff --git a/tsconfig.json b/tsconfig.json index 7b1d30d8..41e2e30f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,24 +2,7 @@ "extends": "./tsconfig.base.json", "compilerOptions": { "baseUrl": ".", - "outDir": "./dist", - "paths": { - "@h3ravel/cache": ["packages/cache/src/index.ts"], - "@h3ravel/config": ["packages/config/src/index.ts"], - "@h3ravel/console": ["packages/console/src/index.ts"], - "@h3ravel/core": ["packages/core/src/index.ts"], - "@h3ravel/database": ["packages/database/src/index.ts"], - "@h3ravel/filesystem": ["packages/filesystem/src/index.ts"], - "@h3ravel/hashing": ["packages/hashing/src/index.ts"], - "@h3ravel/http": ["packages/http/src/index.ts"], - "@h3ravel/mail": ["packages/mail/src/index.ts"], - "@h3ravel/queue": ["packages/queue/src/index.ts"], - "@h3ravel/router": ["packages/router/src/index.ts"], - "@h3ravel/shared": ["packages/shared/src/index.ts"], - "@h3ravel/support": ["packages/support/src/index.ts"], - "@h3ravel/url": ["packages/url/src/index.ts"], - "@h3ravel/view": ["packages/view/src/index.ts"] - } + "outDir": "./dist" }, "exclude": [ "**/console/bin", From dce2fbf8d25f2c11b7ea7ad92be2e2a4334b25f9 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Tue, 30 Dec 2025 23:28:11 +0100 Subject: [PATCH 12/28] feat(router): refactor route validation and pipeline structure - Removed Pipeline.ts contract and moved Pipe type to Utilities.ts. - Introduced IRouteValidator interface for route validation. - Updated HostValidator, MethodValidator, SchemeValidator, and UriValidator to extend IRouteValidator. - Enhanced Route class to include validators and fallback route functionality. - Improved RouteCollection to match routes based on request methods. - Added JsonResponse class for handling JSON responses. - Introduced Console utility for logging. - Updated Logger to handle error formatting and console-like output. - Updated dependencies in pnpm-lock.yaml and pnpm-workspace.yaml. --- examples/basic-app/src/bootstrap/app.ts | 2 +- examples/basic-app/src/routes/web.ts | 7 +- packages/contracts/src/Core/IContainer.ts | 2 - .../src/Exceptions/IExceptionHandler.ts | 5 +- .../src/Http}/HttpContract.ts | 0 packages/contracts/src/Http/IResponse.ts | 4 +- .../src/Routing/IAbstractRouteCollection.ts | 3 +- packages/contracts/src/Routing/IRoute.ts | 18 ++ packages/contracts/src/Utilities/Utilities.ts | 4 +- packages/contracts/src/index.ts | 1 + packages/core/src/Application.ts | 5 +- packages/core/src/Container.ts | 9 +- .../src/Configuration/AppBuilder.ts | 10 +- .../src/Exceptions/Base/Exceptions.ts | 6 +- .../foundation/src/Exceptions/Base/Handler.ts | 98 ++++++----- packages/foundation/src/Http/Kernel.ts | 8 +- .../src/Http}/ResponseUtilities.ts | 2 +- packages/foundation/src/index.ts | 1 + .../foundation/src/views/errors/error.edge | 2 +- packages/http/src/JsonResponse.ts | 164 ++++++++++++++++++ packages/http/src/Response.ts | 14 +- packages/http/src/Utilities/HttpResponse.ts | 8 +- packages/http/src/index.ts | 3 +- .../router/src/AbstractRouteCollection.ts | 42 ++--- packages/router/src/CompiledRoute.ts | 102 ++++++++++- .../router/src/Contracts/IRouteValidator.ts | 5 + packages/router/src/Contracts/Pipeline.ts | 3 - packages/router/src/Contracts/Utilities.ts | 7 + packages/router/src/Matchers/HostValidator.ts | 3 +- .../router/src/Matchers/MethodValidator.ts | 5 +- .../router/src/Matchers/SchemeValidator.ts | 3 +- packages/router/src/Matchers/UriValidator.ts | 8 +- packages/router/src/Pipeline.ts | 5 +- packages/router/src/Route.ts | 134 ++++++++++++-- packages/router/src/RouteCollection.ts | 7 +- packages/router/src/RouteParameterBinder.ts | 3 +- packages/router/src/Router.ts | 24 +-- packages/router/src/index.ts | 3 +- packages/shared/src/Utils/Console.ts | 9 + packages/shared/src/Utils/Logger.ts | 47 ++++- packages/shared/src/index.ts | 1 + packages/support/src/Collection.ts | 4 +- .../view/src/Providers/ViewServiceProvider.ts | 8 +- pnpm-lock.yaml | 12 +- pnpm-workspace.yaml | 2 +- 45 files changed, 618 insertions(+), 195 deletions(-) rename packages/{http/src/Contracts => contracts/src/Http}/HttpContract.ts (100%) rename packages/{http/src/Utilities => foundation/src/Http}/ResponseUtilities.ts (99%) create mode 100644 packages/http/src/JsonResponse.ts create mode 100644 packages/router/src/Contracts/IRouteValidator.ts delete mode 100644 packages/router/src/Contracts/Pipeline.ts create mode 100644 packages/router/src/Contracts/Utilities.ts create mode 100644 packages/shared/src/Utils/Console.ts diff --git a/examples/basic-app/src/bootstrap/app.ts b/examples/basic-app/src/bootstrap/app.ts index fbfdb670..0cab7c78 100644 --- a/examples/basic-app/src/bootstrap/app.ts +++ b/examples/basic-app/src/bootstrap/app.ts @@ -13,7 +13,7 @@ export default class { * Register global reporters here */ .report((error) => { - console.error('Unhandled Exception:', error) + console.error('Unhandled Exception:', error.message, '(Reported at src/bootstrap/app.ts)') }) /** * Prevent some exceptions from being reported diff --git a/examples/basic-app/src/routes/web.ts b/examples/basic-app/src/routes/web.ts index 369a92f5..b83bd35d 100644 --- a/examples/basic-app/src/routes/web.ts +++ b/examples/basic-app/src/routes/web.ts @@ -12,11 +12,10 @@ export default (Route: Router) => { Route.get('/url-examples', [UrlExampleController, 'index'], 'url.examples') Route.get('/url-signing', [UrlExampleController, 'signing'], 'url.signing') Route.get('/url-manipulation', [UrlExampleController, 'manipulation'], 'url.manipulation') - Route.match(['post', 'get'], 'path5/{user:name}/{name}', () => { }).name('path5') + Route.match(['post', 'get'], 'path5/{user:username}/{name?}', () => ({ name: 2 })).name('path5') Route.match(['get'], '/', [HomeController, 'index']).name('index').middleware('web') - Route.match(['get'], '/test/{user:name}', (request, free) => { - console.log(free) - return '{ Test Result }' + Route.match(['get'], '/test/{user:username}', (_, user) => { + return `{ Test Result: ${user} }` }).name('index') Route.get('/app', async function () { diff --git a/packages/contracts/src/Core/IContainer.ts b/packages/contracts/src/Core/IContainer.ts index 851337da..e83f2904 100644 --- a/packages/contracts/src/Core/IContainer.ts +++ b/packages/contracts/src/Core/IContainer.ts @@ -1,6 +1,5 @@ import type { Bindings, UseKey } from '../Utilities/BindingsContract' import type { IMiddlewareHandler } from '../Routing/IMiddlewareHandler' -import { IExceptionHandler } from '../Exceptions/IExceptionHandler' import { ClassConstructor, CallableConstructor, ExtractClassMethods, ConcreteConstructor } from '../Utilities/Utilities' import { IMiddleware } from '../Routing/IMiddleware' @@ -8,7 +7,6 @@ import { IMiddleware } from '../Routing/IMiddleware' * Interface for the Container contract, defining methods for dependency injection and service resolution. */ export abstract class IContainer { - abstract exceptionHandler?: IExceptionHandler abstract middlewareHandler?: IMiddlewareHandler /** diff --git a/packages/contracts/src/Exceptions/IExceptionHandler.ts b/packages/contracts/src/Exceptions/IExceptionHandler.ts index 04355e3e..10d4bfae 100644 --- a/packages/contracts/src/Exceptions/IExceptionHandler.ts +++ b/packages/contracts/src/Exceptions/IExceptionHandler.ts @@ -62,10 +62,7 @@ export abstract class IExceptionHandler { * * @param _attributes */ - abstract level (type: string, level: string): { - level: string; - type: string; - }; + abstract level (type: string | Error, level: 'log' | 'debug' | 'warn' | 'info' | 'error'): this /** * Not implemented here; applicable to validation pipeline/UI. * diff --git a/packages/http/src/Contracts/HttpContract.ts b/packages/contracts/src/Http/HttpContract.ts similarity index 100% rename from packages/http/src/Contracts/HttpContract.ts rename to packages/contracts/src/Http/HttpContract.ts diff --git a/packages/contracts/src/Http/IResponse.ts b/packages/contracts/src/Http/IResponse.ts index 509d3b02..2348c36d 100644 --- a/packages/contracts/src/Http/IResponse.ts +++ b/packages/contracts/src/Http/IResponse.ts @@ -22,11 +22,11 @@ export abstract class IResponse extends IHttpResponse { /** * Sends content for the current web response. */ - abstract sendContent (type?: 'html' | 'json' | 'text' | 'xml', parse?: boolean): unknown; + abstract sendContent (type?: 'html' | 'json' | 'text' | 'xml', parse?: boolean): IResponsable; /** * Sends content for the current web response. */ - abstract send (type?: 'html' | 'json' | 'text' | 'xml'): unknown; + abstract send (type?: 'html' | 'json' | 'text' | 'xml'): IResponsable; /** * Use an edge view as content diff --git a/packages/contracts/src/Routing/IAbstractRouteCollection.ts b/packages/contracts/src/Routing/IAbstractRouteCollection.ts index d82625a8..e70d63d5 100644 --- a/packages/contracts/src/Routing/IAbstractRouteCollection.ts +++ b/packages/contracts/src/Routing/IAbstractRouteCollection.ts @@ -3,6 +3,7 @@ import type { RouteMethod } from '../Utilities/Utilities' export declare abstract class IAbstractRouteCollection { static verbs: RouteMethod[] - abstract get (method?: string): Record | IRoute[]; + abstract get (): IRoute[]; + abstract get (method: string): Record; abstract getRoutes (): IRoute[]; } \ No newline at end of file diff --git a/packages/contracts/src/Routing/IRoute.ts b/packages/contracts/src/Routing/IRoute.ts index 42730dcf..9ac03c5c 100644 --- a/packages/contracts/src/Routing/IRoute.ts +++ b/packages/contracts/src/Routing/IRoute.ts @@ -99,10 +99,28 @@ export abstract class IRoute { * @param key */ abstract getAction (key?: string): any; + /** + * Mark this route as a fallback route. + */ + abstract fallback (): this + /** + * Set the fallback value. + * + * @param sFallback + */ + abstract setFallback (isFallback: boolean): this + /** + * Get the HTTP verbs the route responds to. + */ + abstract getMethods (): RouteMethod[] /** * Determine if the route only responds to HTTP requests. */ abstract httpOnly (): boolean; + /** + * Determine if the route only responds to HTTPS requests. + */ + abstract httpsOnly (): boolean /** * Get or set the middlewares attached to the route. * diff --git a/packages/contracts/src/Utilities/Utilities.ts b/packages/contracts/src/Utilities/Utilities.ts index d189fdfd..8a2ad024 100644 --- a/packages/contracts/src/Utilities/Utilities.ts +++ b/packages/contracts/src/Utilities/Utilities.ts @@ -9,6 +9,7 @@ export type RouterEnd = 'get' | 'delete' | 'put' | 'post' | 'patch' | 'apiResour export type RouteMethod = 'GET' | 'HEAD' | 'PUT' | 'PATCH' | 'POST' | 'DELETE' | 'OPTIONS'; export type RequestMethod = 'HEAD' | 'GET' | 'PUT' | 'DELETE' | 'TRACE' | 'OPTIONS' | 'PURGE' | 'POST' | 'CONNECT' | 'PATCH'; export type ControllerMethod = 'index' | 'show' | 'update' | 'destroy'; +export type GenericObject = Record; export type RequestObject = Record; export type ResponseObject = Record; @@ -22,12 +23,12 @@ export type ExtractClassMethods = { export type EventHandler = (ctx: IHttpContext) => any export type ClassConstructor = abstract new (...args: any[]) => T +export type RouteEventHandler = (ctx: IHttpContext, ...args: any[]) => any export type MergedConstructor = (new (...args: any[]) => T) & Record export type AbstractConstructor = (abstract new (...args: any[]) => T) & Record export type CallableConstructor = (...args: Y[]) => X export type AppEvent = CallableConstructor export type AppListener = CallableConstructor -export type RouteEventHandler = CallableConstructor export type ConcreteConstructor = new (...args: any[]) => Required export interface RouteActions { @@ -42,6 +43,7 @@ export interface RouteActions { controller?: RouteEventHandler | IController | string missing?: CallableConstructor uses?: any + http?: boolean https?: boolean middleware?: MiddlewareList namespace?: string diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 288e2d5d..8bdc3211 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -8,6 +8,7 @@ export * from './Exceptions/IExceptionHandler' export * from './Foundation/IKernel' export * from './Foundation/MiddlewareContract' export * from './Foundation/RateLimiterAdapter' +export * from './Http/HttpContract' export * from './Http/IFileBag' export * from './Http/IHeaderBag' export * from './Http/IHttpContext' diff --git a/packages/core/src/Application.ts b/packages/core/src/Application.ts index d00a8d9b..0f6b6af0 100644 --- a/packages/core/src/Application.ts +++ b/packages/core/src/Application.ts @@ -330,9 +330,8 @@ export class Application extends Container implements IApplication { kernel.terminate(context.request, response!) - if (typeof response?.prepare !== 'undefined') { - const content = response.prepare(context.request).send() - return content + if (response && ['Response', 'JsonResponse'].includes(response.constructor.name)) { + return response.prepare(context.request).send() } else { return response } diff --git a/packages/core/src/Container.ts b/packages/core/src/Container.ts index dafc7df9..f22147ed 100644 --- a/packages/core/src/Container.ts +++ b/packages/core/src/Container.ts @@ -8,7 +8,6 @@ type IBinding = UseKey | (new (...args: any[]) => unknown) export class Container extends IContainer { public bindings = new Map unknown>() public singletons = new Map() - public exceptionHandler?: Handler public middlewareHandler?: MiddlewareHandler /** * All of the before resolving callbacks by class type. @@ -133,18 +132,12 @@ export class Container extends IContainer { factory: any ): void { - const alias = ContainerResolver.isAbstract(key) ? key.name.toLowerCase().substring(1) as T : undefined - this.bindings.set(key, () => { if (!this.singletons.has(key)) { this.singletons.set(key, this.call(factory)) } - if (alias && !this.singletons.has(alias)) { - this.singletons.set(alias, this.call(factory)) - } - - return this.singletons.get(alias ?? key) + return this.singletons.get(key) }) } diff --git a/packages/foundation/src/Configuration/AppBuilder.ts b/packages/foundation/src/Configuration/AppBuilder.ts index 33b02fee..b171c11c 100644 --- a/packages/foundation/src/Configuration/AppBuilder.ts +++ b/packages/foundation/src/Configuration/AppBuilder.ts @@ -1,5 +1,5 @@ import { ExceptionHandler, Exceptions, Kernel, Middleware, MiddlewareList } from '..' -import { IApplication, IKernel } from '@h3ravel/contracts' +import { IApplication, IExceptionHandler, IKernel } from '@h3ravel/contracts' export class AppBuilder { @@ -28,13 +28,17 @@ export class AppBuilder { **/ public withExceptions (using: (exceptions: Exceptions) => void) { // Register the ExceptionHandler as a singleton - this.app.singleton(ExceptionHandler, () => new ExceptionHandler()) + this.app.singleton(IExceptionHandler, () => new ExceptionHandler()) + this.app.alias([ + [ExceptionHandler, IExceptionHandler], + ['app.ExceptionHandler', IExceptionHandler] + ]) // Default to a no-op callback if none provided using ??= () => true // Hook into the lifecycle to initialize Exceptions once the handler is resolved - this.app.afterResolving(ExceptionHandler, (handler) => { + this.app.afterResolving(IExceptionHandler, (handler) => { using(new Exceptions(handler)) }) diff --git a/packages/foundation/src/Exceptions/Base/Exceptions.ts b/packages/foundation/src/Exceptions/Base/Exceptions.ts index 3ae1ba29..b0cb48de 100644 --- a/packages/foundation/src/Exceptions/Base/Exceptions.ts +++ b/packages/foundation/src/Exceptions/Base/Exceptions.ts @@ -1,12 +1,12 @@ import { Arr } from '@h3ravel/support' -import { Handler } from './Handler' +import { IExceptionHandler } from '@h3ravel/contracts' import { RequestException } from './RequestException' export class Exceptions { /** * Create a new exception handling configuration instance. */ - constructor(public handler: Handler) { } + constructor(public handler: IExceptionHandler) { } /** * Register a reportable callback. @@ -65,7 +65,7 @@ export class Exceptions { /** * Set the log level for the given exception type. */ - public level (type: string, level: string) { + public level (type: string | Error, level: 'log' | 'debug' | 'warn' | 'info' | 'error') { this.handler.level(type, level) return this } diff --git a/packages/foundation/src/Exceptions/Base/Handler.ts b/packages/foundation/src/Exceptions/Base/Handler.ts index 17cd81ca..c8b5ea0e 100644 --- a/packages/foundation/src/Exceptions/Base/Handler.ts +++ b/packages/foundation/src/Exceptions/Base/Handler.ts @@ -4,9 +4,11 @@ import type { ExceptionConditionCallback, ExceptionConstructor, IHttpContext, IR import { LimitSpec, RateLimiterAdapter } from '../../Contracts/RateLimiterAdapter' import { IExceptionHandler, type RenderExceptionCallback, type ReportExceptionCallback, type ThrottleExceptionCallback } from '@h3ravel/contracts' -import { FileSystem } from '@h3ravel/shared' +import { FileSystem, Console } from '@h3ravel/shared' import { InMemoryRateLimiter } from '../../Adapters/InMemoryRateLimiter' import { readFileSync } from 'node:fs' +import { HttpExceptionFactory } from './HttpExceptionFactory' +import { statusTexts } from '../../Http/ResponseUtilities' /** * @@ -21,9 +23,9 @@ export abstract class Handler extends IExceptionHandler { protected dontReportList: ExceptionConstructor[] = [] /** - * Log Level + * A map of exceptions with their corresponding custom log levels. */ - protected logLevel: { type?: string, level?: string } = {} + protected levels = new Map>() /** * Internal exceptions that are not reported by default. Subclasses may expand. @@ -86,7 +88,7 @@ export abstract class Handler extends IExceptionHandler { * @param error * @param ctx */ - public handle?(error: Error, ctx: IHttpContext): Promise + handle?(error: Error, ctx: IHttpContext): Promise /** * Finalize response callback (respondUsing) @@ -111,65 +113,65 @@ export abstract class Handler extends IExceptionHandler { * @param cb * @returns */ - public reportable (cb: ReportExceptionCallback) { + reportable (cb: ReportExceptionCallback) { this.reportCallbacks.push(cb) return this } - public renderable (cb: RenderExceptionCallback) { + renderable (cb: RenderExceptionCallback) { this.renderCallbacks.push(cb) return this } - public dontReport (exceptions: ExceptionConstructor | ExceptionConstructor[]) { + dontReport (exceptions: ExceptionConstructor | ExceptionConstructor[]) { const arr = Array.isArray(exceptions) ? exceptions : [exceptions] this.dontReportList = Array.from(new Set([...this.dontReportList, ...arr])) return this } - public stopIgnoring (exceptions: ExceptionConstructor | ExceptionConstructor[]) { + stopIgnoring (exceptions: ExceptionConstructor | ExceptionConstructor[]) { const arr = Array.isArray(exceptions) ? exceptions : [exceptions] this.dontReportList = this.dontReportList.filter((c) => !arr.includes(c)) this.internalDontReport = this.internalDontReport.filter((c) => !arr.includes(c)) return this } - public dontReportWhen (cb: ExceptionConditionCallback) { + dontReportWhen (cb: ExceptionConditionCallback) { this.dontReportCallbacks.push(cb) return this } - public dontReportDuplicates () { + dontReportDuplicates () { this.withoutDuplicates = true return this } - public map (from: ExceptionConstructor, mapper: (error: any) => any) { + map (from: ExceptionConstructor, mapper: (error: any) => any) { this.exceptionMap.set(from, mapper) return this } - public throttleUsing (cb: ThrottleExceptionCallback) { + throttleUsing (cb: ThrottleExceptionCallback) { this.throttleCallbacks.push(cb) return this } - public buildContextUsing (cb: (e: any, current?: Record) => Record) { + buildContextUsing (cb: (e: any, current?: Record) => Record) { this.contextCallbacks.push(cb) return this } - public setRateLimiter (adapter: RateLimiterAdapter) { + setRateLimiter (adapter: RateLimiterAdapter) { this.rateLimiter = adapter return this } - public respondUsing (cb: (response: IResponse, error: any, request: IRequest) => IResponse | Promise) { + respondUsing (cb: (response: IResponse, error: any, request: IRequest) => IResponse | Promise) { this.finalizeResponseCallback = cb return this } - public shouldRenderJsonWhen (cb: (request: IRequest, error: any) => boolean) { + shouldRenderJsonWhen (cb: (request: IRequest, error: any) => boolean) { this.shouldRenderJsonWhenCallback = cb return this } @@ -180,7 +182,7 @@ export abstract class Handler extends IExceptionHandler { * @param error * @returns */ - public async report (error: any): Promise { + async report (error: Error): Promise { const e = this.mapException(error) if (this.shouldntReport(e)) { @@ -238,14 +240,12 @@ export abstract class Handler extends IExceptionHandler { const context = this.buildExceptionContext(e) - if (typeof (logger as any)[level] === 'function') { - ; (logger as any)[level](e?.message ?? String(e), context) + if (typeof logger[level] === 'function') { + logger[level](context) } else if (typeof logger.log === 'function') { - logger.log(level, e?.message ?? String(e), context) + logger.log(level, context) } else { - /* Fallback */ - - console.error(`[${level}]`, e, context) + Console.error(`[${level}]`, context) } } catch { /* If logger fails, rethrow original exception to avoid silent failure in critical systems. */ @@ -357,7 +357,7 @@ export abstract class Handler extends IExceptionHandler { * @param error * @returns */ - public async render (request: IRequest, error: any): Promise { + async render (request: IRequest, error: any): Promise { const e = this.mapException(error) const { Response } = await import('@h3ravel/http') @@ -409,17 +409,17 @@ export abstract class Handler extends IExceptionHandler { return this.finalizeRenderedResponse(request, this.prepareJsonResponse(request, e), e) } - return this.finalizeRenderedResponse(request, await this.prepareResponse(request, e), e) + return await this.finalizeRenderedResponse(request, await this.prepareResponse(request, e), e) } /** * getResponse */ - public getResponse (request: IRequest, payload: Record, e: any): IResponse | Promise { + getResponse (request: IRequest, payload: Record, e: any): IResponse | Promise { if (this.shouldReturnJson(request, e)) { return response() .setCharset('utf-8') - .setStatusCode(this.isHttpException(e) ? (e.status as number) : 500) + .setStatusCode(this.isHttpException(e) ? e.getStatusCode() : 500) .json(payload) } @@ -432,9 +432,10 @@ export abstract class Handler extends IExceptionHandler { return response() .setCharset('utf-8') - .setStatusCode(this.isHttpException(e) ? (e.status as number) : 500) + .setStatusCode(this.isHttpException(e) ? e.getStatusCode() : 500) .viewTemplate(readFileSync(view, { encoding: 'utf-8' }), { - statusCode: this.isHttpException(e) ? (e.status as number) : 500, + statusCode: this.isHttpException(e) ? e.getStatusCode() : 500, + statusText: statusTexts[this.isHttpException(e) ? e.getStatusCode() : 500], message: body, exception: e, debug: this.appDebug() @@ -444,11 +445,13 @@ export abstract class Handler extends IExceptionHandler { /** * Default non-JSON response (simple string). Subclass to integrate templating. * - * @param _request + * @param request * @param e * @returns */ - protected prepareResponse (_request: IRequest, e: any): IResponse | Promise { + protected prepareResponse (request: IRequest, e: any): IResponse | Promise { + void request + const body = this.isHttpException(e) ? (e.message ?? 'Error') : 'Internal Server Error' const view = FileSystem.resolveModulePath('@h3ravel/foundation', [ @@ -458,9 +461,10 @@ export abstract class Handler extends IExceptionHandler { return response() .setCharset('utf-8') - .setStatusCode(this.isHttpException(e) ? (e.status as number) : 500) + .setStatusCode(this.isHttpException(e) ? e.getStatusCode() : 500) .viewTemplate(readFileSync(view, { encoding: 'utf-8' }), { - statusCode: this.isHttpException(e) ? (e.status as number) : 500, + statusCode: this.isHttpException(e) ? e.getStatusCode() : 500, + statusText: statusTexts[this.isHttpException(e) ? e.getStatusCode() : 500], message: body, exception: e, debug: this.appDebug() @@ -527,7 +531,7 @@ export abstract class Handler extends IExceptionHandler { const payload = this.convertExceptionToArray(e) return response() .setCharset('utf-8') - .setStatusCode(this.isHttpException(e) ? (e.status as number) : 500) + .setStatusCode(this.isHttpException(e) ? e.getStatusCode() : 500) .json(payload) } @@ -601,7 +605,10 @@ export abstract class Handler extends IExceptionHandler { */ protected context (): Record { try { - /* Example: if you have an Auth module, fetch user id here */ + /** + * TODO: To be implemented + * Example: if we have an Auth module, we canfetch user id here + */ return {} } catch { return {} @@ -633,8 +640,8 @@ export abstract class Handler extends IExceptionHandler { * @param e * @returns */ - protected isHttpException (e: any): e is { status: number; headers?: Record, message?: string } { - return e && typeof e.status === 'number' + protected isHttpException (e: any): e is HttpExceptionFactory { + return e instanceof HttpExceptionFactory } /** @@ -642,15 +649,15 @@ export abstract class Handler extends IExceptionHandler { * * @param _e */ - protected mapLogLevel (_e: any): string { - return 'error' + protected mapLogLevel (e: string | Error): Exclude { + return this.levels.get(e) ?? 'error' } /** * Subclasses should return PSR-like logger (object with methods like error, warn, info or a `log` fn) */ - protected newLogger (): any { - return console + protected newLogger () { + return Console } /** @@ -683,7 +690,7 @@ export abstract class Handler extends IExceptionHandler { * * @param _length */ - public truncateRequestExceptionsAt (_length: number) { + truncateRequestExceptionsAt (_length: number) { return this } @@ -692,8 +699,9 @@ export abstract class Handler extends IExceptionHandler { * * @param _attributes */ - public level (type: string, level: string) { - return this.logLevel = { level, type } + level (type: string | Error, level: Exclude) { + this.levels.set(type, level) + return this } /** @@ -701,7 +709,7 @@ export abstract class Handler extends IExceptionHandler { * * @param _attributes */ - public dontFlash (_attributes: string | string[]) { + dontFlash (_attributes: string | string[]) { return this } } \ No newline at end of file diff --git a/packages/foundation/src/Http/Kernel.ts b/packages/foundation/src/Http/Kernel.ts index c0ae5d5a..03aed431 100644 --- a/packages/foundation/src/Http/Kernel.ts +++ b/packages/foundation/src/Http/Kernel.ts @@ -1,7 +1,7 @@ // namespace Illuminate\Foundation\Http; import { Arr, DateTime, InvalidArgumentException } from '@h3ravel/support' -import { IApplication, IKernel, IMiddleware, IRequest, IResponse, IRouter } from '@h3ravel/contracts' +import { IApplication, IExceptionHandler, IKernel, IMiddleware, IRequest, IResponse, IRouter } from '@h3ravel/contracts' import { MiddlewareIdentifier, MiddlewareList } from '../Contracts/MiddlewareContract' import { Injectable } from '..' @@ -75,7 +75,7 @@ export class Kernel extends IKernel { let response: IResponse | undefined try { - // request.constructor.prototype.enableHttpMethodParameterOverride() + (request.constructor as any).enableHttpMethodParameterOverride() response = await this.sendRequestThroughRouter(request) } catch (e) { @@ -456,7 +456,7 @@ export class Kernel extends IKernel { * @param e */ protected reportException (e: Error) { - this.app.exceptionHandler?.report(e) + this.app.make(IExceptionHandler).report(e) } /** @@ -466,7 +466,7 @@ export class Kernel extends IKernel { * @param e */ protected renderException (request: IRequest, e: Error) { - return this.app.exceptionHandler?.render(request, e) + return this.app.make(IExceptionHandler).render(request, e) } /** diff --git a/packages/http/src/Utilities/ResponseUtilities.ts b/packages/foundation/src/Http/ResponseUtilities.ts similarity index 99% rename from packages/http/src/Utilities/ResponseUtilities.ts rename to packages/foundation/src/Http/ResponseUtilities.ts index 14f86e1a..57899a21 100644 --- a/packages/http/src/Utilities/ResponseUtilities.ts +++ b/packages/foundation/src/Http/ResponseUtilities.ts @@ -1,4 +1,4 @@ -import { CacheOptions } from '../Contracts/HttpContract' +import { CacheOptions } from '@h3ravel/contracts' export enum ResponseCodes { HTTP_CONTINUE = 100, diff --git a/packages/foundation/src/index.ts b/packages/foundation/src/index.ts index fa6c0183..69002191 100644 --- a/packages/foundation/src/index.ts +++ b/packages/foundation/src/index.ts @@ -21,6 +21,7 @@ export * from './Exceptions/UnprocessableEntityHttpException' export * from './Exceptions/UnsupportedMediaTypeHttpException' export * from './Http/Kernel' export * from './Http/MiddlewareHandler' +export * from './Http/ResponseUtilities' export * from './Testing/supertestAdapter' export * from './Exceptions/Base/ExceptionHandler' export * from './Exceptions/Base/Exceptions' diff --git a/packages/foundation/src/views/errors/error.edge b/packages/foundation/src/views/errors/error.edge index f67bf701..f667b8d8 100644 --- a/packages/foundation/src/views/errors/error.edge +++ b/packages/foundation/src/views/errors/error.edge @@ -85,7 +85,7 @@
-

{{ statusCode }}

+

{{ statusCode || 500 }}

{{ statusText || 'Something went wrong' }}

@if(!debug) diff --git a/packages/http/src/JsonResponse.ts b/packages/http/src/JsonResponse.ts new file mode 100644 index 00000000..eb1255cf --- /dev/null +++ b/packages/http/src/JsonResponse.ts @@ -0,0 +1,164 @@ +import { ClassConstructor, GenericObject, IApplication } from '@h3ravel/contracts' + +import { InvalidArgumentException } from '@h3ravel/support' +import { Response } from './Response' +import { ResponseCodes } from '@h3ravel/foundation' + +type Data = string | number | GenericObject | ClassConstructor | any[] + +/** + * Response represents an HTTP response in JSON format. + * + * Note that this class does not force the returned JSON content to be an + * object. It is however recommended that you do return an object as it + * protects yourself against XSSI and JSON-JavaScript Hijacking. + * + * @see https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/AJAX_Security_Cheat_Sheet.md#always-return-json-with-an-object-on-the-outside + */ +export class JsonResponse extends Response { + protected data!: Data + + protected callback?: string + + /** + * @param bool $json If the data is already a JSON string + */ + constructor(app: IApplication, data?: Data, status: ResponseCodes = 200, headers: Record = {}, json = false) { + super(app, '', status, headers) + + if (json && typeof data !== 'string' && typeof data !== 'number' && typeof (data as any).toString === 'undefined') { + throw new TypeError(`"${this.constructor.name}": If \`json\` is set to true, argument \`data\` must be a string or object implementing toString(), "${typeof data}" given.`) + } + + data ??= 'new ArrayObject()' + + if (json) this.setJson(data) + else this.setData(data) + } + + /** + * Sets the JSONP callback. + * + * @param callback The JSONP callback or null to use none + * + * @throws {InvalidArgumentException} When the callback name is not valid + */ + setCallback (callback?: string): this { + if (typeof callback !== 'undefined') { + const pattern = /^[$_\p{L}][$_\p{L}\p{Mn}\p{Mc}\p{Nd}\p{Pc}\u200C\u200D]*(?:\[(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\d+)\])*?$/u + + + const reserved = [ + 'break', 'do', 'instanceof', 'typeof', 'case', 'else', 'new', 'var', 'catch', 'finally', 'return', 'void', 'continue', 'for', 'switch', 'while', + 'debugger', 'function', 'this', 'with', 'default', 'if', 'throw', 'delete', 'in', 'try', 'class', 'enum', 'extends', 'super', 'const', 'export', + 'import', 'implements', 'let', 'private', 'public', 'yield', 'interface', 'package', 'protected', 'static', 'null', 'true', 'false', + ] + + const parts = callback.split('.') + + for (const part of parts) { + + if (!pattern.test(part) || reserved.includes(part)) { + throw new InvalidArgumentException('The callback name is not valid.') + } + } + } + + this.callback = callback + + return this.update() + } + + /** + * Factory method for chainability. + * + * @example + * + * return JsonResponse.fromJsonString('{"key": "value"}').setSharedMaxAge(300); + * + * @param data The JSON response string + * @param status The response status code (200 "OK" by default) + * @param headers An array of response headers + */ + static fromJsonString (app: IApplication, data: string, status: ResponseCodes = 200, headers: Record = {}): JsonResponse { + return new JsonResponse(app, data, status, headers, true) + } + + /** + * Sets a raw string containing a JSON document to be sent. + * + * @param json + * @returns + */ + setJson (json: Data): this { + this.data = json + + return this.update() + } + + /** + * Sets the data to be sent as JSON. + * + * @param data + * @returns + */ + setData (data: any = {}): this { + let content: string + + try { + if (data.toJson === 'undefined') { + content = JSON.stringify((data as any).toJson()) + } else if (data.toArray === 'undefined') { + content = JSON.stringify((data as any).toArray()) + } else { + content = JSON.stringify(data) + } + } catch (e: any) { + if (e instanceof Error && e.message.startsWith('Failed calling ')) { + throw (e as any).getPrevious() || e + } + + throw e + } + + return this.setJson(content) + } + + /** + * Get the json_decoded data from the response. + * + * @param assoc + */ + getData () { + return JSON.parse(String(this.data)) + } + + /** + * Sets the JSONP callback. + * + * @param callback + */ + withCallback (callback?: string) { + return this.setCallback(callback) + } + + /** + * Updates the content and headers according to the JSON data and callback. + */ + protected update (): this { + if (typeof this.callback !== 'undefined') { + // Not using application/javascript for compatibility reasons with older browsers. + this.headers.set('Content-Type', 'text/javascript') + + return this.setContent(`/**/${this.callback}(${this.data});`) + } + + // Only set the header when there is none or when it equals 'text/javascript' (from a previous update with callback) + // in order to not overwrite a custom definition. + if (!this.headers.has('Content-Type') || 'text/javascript' === this.headers.get('Content-Type')) { + this.headers.set('Content-Type', 'application/json') + } + + return this.setContent(this.data) + } +} \ No newline at end of file diff --git a/packages/http/src/Response.ts b/packages/http/src/Response.ts index 106f9271..94a307c3 100644 --- a/packages/http/src/Response.ts +++ b/packages/http/src/Response.ts @@ -5,7 +5,7 @@ import { H3Event } from 'h3' import { HttpResponse } from './Utilities/HttpResponse' import { IApplication } from '@h3ravel/contracts' import { Responsable } from './Utilities/Responsable' -import { ResponseCodes } from './Utilities/ResponseUtilities' +import { ResponseCodes } from '@h3ravel/foundation' export class Response extends HttpResponse implements IResponse { static codes = ResponseCodes @@ -45,7 +45,7 @@ export class Response extends HttpResponse implements IResponse { /** * Sends content for the current web response. */ - public sendContent (type?: 'html' | 'json' | 'text' | 'xml', parse?: boolean) { + public sendContent (type?: 'html' | 'json' | 'text' | 'xml', parse?: boolean): Responsable { if (!type) { type = Str.detectContentType(this.content) } @@ -85,8 +85,7 @@ export class Response extends HttpResponse implements IResponse { async viewTemplate (content: string, data?: Record | undefined): Promise async viewTemplate (content: string, data: Record | undefined, parse: boolean): Promise async viewTemplate (content: string, data?: Record | undefined, parse?: boolean): Promise { - const base = this.html(await this.app.make('edge').renderRaw(content, data), parse!) - return new Responsable(base.body!, base) + return this.html(await this.app.make('edge').renderRaw(content, data), parse!) } /** @@ -99,6 +98,9 @@ export class Response extends HttpResponse implements IResponse { html (content: string, parse: boolean): Responsable html (content?: string, parse?: boolean): Responsable | this { const base = this.httpResponse('text/html', content ?? this.content, parse!) + if (base instanceof Response) { + return new Responsable(base.content, { status: base.statusCode, statusText: base.statusText, headers: base.headers }) + } return new Responsable(base.body!, base) } @@ -106,14 +108,14 @@ export class Response extends HttpResponse implements IResponse { * Send a JSON response. */ json (data?: T): this - json (data: T, parse: boolean): T + json (data: T, parse: boolean): Responsable json (data?: T, parse?: boolean): Responsable | this { const content = data ?? this.content return this.httpResponse( 'application/json', typeof content !== 'string' ? JSON.stringify(content) : content, parse! - ) as never + ) } /** diff --git a/packages/http/src/Utilities/HttpResponse.ts b/packages/http/src/Utilities/HttpResponse.ts index f12d98a0..207099e8 100644 --- a/packages/http/src/Utilities/HttpResponse.ts +++ b/packages/http/src/Utilities/HttpResponse.ts @@ -1,15 +1,14 @@ +import { CacheOptions, IHttpResponse, IRequest, ResponseObject } from '@h3ravel/contracts' import { DateTime, InvalidArgumentException } from '@h3ravel/support' -import { HTTP_RESPONSE_CACHE_CONTROL_DIRECTIVES, statusTexts } from '../Utilities/ResponseUtilities' -import { IRequest, ResponseObject } from '@h3ravel/contracts' +import { HTTP_RESPONSE_CACHE_CONTROL_DIRECTIVES, statusTexts } from '@h3ravel/foundation' -import { CacheOptions } from '../Contracts/HttpContract' import { Cookie } from './Cookie' import type { H3Event } from 'h3' import { HeaderBag } from '../Utilities/HeaderBag' import { HttpResponseException } from '../Exceptions/HttpResponseException' import { ResponseHeaderBag } from '../Utilities/ResponseHeaderBag' -export class HttpResponse { +export class HttpResponse extends IHttpResponse { protected statusCode: number = 200 protected headers: ResponseHeaderBag protected content!: any @@ -49,6 +48,7 @@ export class HttpResponse { */ protected readonly event: H3Event, ) { + super() this.headers = new ResponseHeaderBag(this.event) this.setContent() this.setProtocolVersion('1.0') diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index cc60681c..8e8d15ad 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -1,5 +1,4 @@ export * from './Commands/FireCommand' -export * from './Contracts/HttpContract' export * from './Exceptions/BadRequestException' export * from './Exceptions/ConflictingHeadersException' export * from './Exceptions/HttpResponseException' @@ -7,6 +6,7 @@ export * from './Exceptions/SuspiciousOperationException' export * from './Exceptions/UnexpectedValueException' export * from './FormRequest' export * from './HttpContext' +export * from './JsonResponse' export * from './Middleware' export * from './Middleware/FlashDataMiddleware' export * from './Middleware/LogRequests' @@ -28,5 +28,4 @@ export * from './Utilities/IpUtils' export * from './Utilities/ParamBag' export * from './Utilities/Responsable' export * from './Utilities/ResponseHeaderBag' -export * from './Utilities/ResponseUtilities' export * from './Utilities/ServerBag' diff --git a/packages/router/src/AbstractRouteCollection.ts b/packages/router/src/AbstractRouteCollection.ts index a804987c..e453aec5 100644 --- a/packages/router/src/AbstractRouteCollection.ts +++ b/packages/router/src/AbstractRouteCollection.ts @@ -1,5 +1,6 @@ import type { IAbstractRouteCollection, RouteMethod } from '@h3ravel/contracts' +import { Collection } from '@h3ravel/support' import { NotFoundHttpException } from '@h3ravel/foundation' import { Request } from '@h3ravel/http' import { Route } from './Route' @@ -12,44 +13,31 @@ import { Route } from './Route' */ export abstract class AbstractRouteCollection implements IAbstractRouteCollection { public static verbs: RouteMethod[] = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] - abstract get (method?: string): Record | Route[] + abstract get (): Route[] + abstract get (method: string): Record abstract getRoutes (): Route[] - /* + /** * Match a request against a set of routes belonging to one HTTP verb. * * @param routes * @param req + * @param includingMethod * @returns */ protected matchAgainstRoutes ( - routes: Record | Route[], + routes: Record, req: Request, - ): Route | null { - const path = req.path() - const host = req.getHost() - - for (let route of (Array.isArray(routes) ? routes : Object.entries(routes))) { - route = Array.isArray(route) ? route[1] : route - - /* - * Domain match check. - */ - if (route.domain() && !this.matchDomain(route.domain(), host)) { - continue - } + includingMethod = true + ): Route | undefined { - /* - * URI match check (simple or compiled). - */ - if (!this.matchUri(route, path)) { - continue - } - - return route - } + const [fallbacks, routeList] = (new Collection(routes)).partition(function (route) { + return route.isFallback + }) - return null + return new Collection({ ...routeList.all(), ...fallbacks.all() }).first( + (route) => route.matches(req, includingMethod) + ) } /* @@ -63,7 +51,7 @@ export abstract class AbstractRouteCollection implements IAbstractRouteCollectio return route.bind(req) } - throw new NotFoundHttpException(`The route ${req.path()} could not be found.`, undefined, 404) + throw new NotFoundHttpException(`The route "${req.path()}" was not found.`, undefined, 404) } /** diff --git a/packages/router/src/CompiledRoute.ts b/packages/router/src/CompiledRoute.ts index 3a9fd635..0f9b97dc 100644 --- a/packages/router/src/CompiledRoute.ts +++ b/packages/router/src/CompiledRoute.ts @@ -1,17 +1,24 @@ +import { CompiledRouteToken } from './Contracts/Utilities' + export class CompiledRoute { private path: string + private tokens: CompiledRouteToken + private variables: string[] private paramNames: string[] private optionalParams: Record private regex: RegExp private hostPattern?: string private hostRegex?: RegExp - constructor(path: string, paramNames: string[], optionalParams: Record, hostPattern?: string) { + constructor(path: string, optionalParams: Record, hostPattern?: string) { this.path = path - this.paramNames = paramNames + this.variables = this.buildParams() + this.paramNames = this.variables this.optionalParams = optionalParams this.hostPattern = hostPattern + this.tokens = this.tokenizePath() + // Build the main path regex this.regex = this.buildRegex(this.path, this.paramNames, this.optionalParams) @@ -39,7 +46,21 @@ export class CompiledRoute { * Returns list of all param names (including optional) */ public getParamNames (): string[] { - return [...this.paramNames] + return this.paramNames + } + + /** + * Returns list of all path variables + */ + public getVariables (): string[] { + return this.variables + } + + /** + * Returns list of all compiled tokens + */ + public getTokens () { + return this.tokens } /** @@ -50,12 +71,79 @@ export class CompiledRoute { } /** - * Internal: build a regex from a path pattern + * Build the route params + * + * @returns */ - private buildRegex (path: string, paramNames: string[], optionalParams: Record): RegExp { - const regexStr = path.replace(/:([a-zA-Z0-9_]+)\??/g, (_, paramName) => { - return optionalParams[paramName] === null ? '([^/]*)' : '([^/]+)' + private buildParams (): string[] { + const paramNames: string[] = [] + + // Extract all param names in order + this.path.replace(/\{([\w]+)(?:[:][\w]+)?\??\}/g, (_, paramName) => { + paramNames.push(paramName) + return '' }) + + return paramNames + } + + /** + * Build a regex from a path pattern + * + * @param path + * @param paramNames + * @param optionalParams + * @returns + */ + private buildRegex (path: string, paramNames: string[], optionalParams: Record): RegExp { + const regexStr = path.replace( + /\/?\{([a-zA-Z0-9_]+)(\?)?(?::[a-zA-Z0-9_]+)?\}/g, + (_, paramName, optionalMark) => { + // Check if param is optional via '?' or via optionalParams + const isOptional = optionalMark === '?' || optionalParams[paramName] === null + // return isOptional ? '([^/]*)' : '([^/]+)' + if (isOptional) { + // Make both the slash and segment optional + return '(?:/([^/]+))?' + } else { + // Required segment, preserve slash + return '/([^/]+)' + } + } + ) return new RegExp(`^${regexStr}$`) } + + /** + * Tokenize the the path + * + * @param optionalParams + * @returns + */ + private tokenizePath (): CompiledRouteToken { + const tokens: CompiledRouteToken = [] as unknown as CompiledRouteToken + const regex = /(\{([a-zA-Z0-9_]+)(\?)?(?::[a-zA-Z0-9_]+)?\})|([^{}]+)/g + let match: RegExpExecArray | null + + while ((match = regex.exec(this.path)) !== null) { + if (match[1]) { + // It's a variable + const paramName = match[2] + const isOptional = match[3] === '?' || this.optionalParams[paramName] === null + const prefix = match.index === 0 ? '' : '/' + tokens.push([ + 'variable', + prefix, + '[^/]++', + paramName, + !isOptional + ] as never) + } else if (match[4]) { + // It's a text part + tokens.push(['text', match[4]] as never) + } + } + + return tokens + } } diff --git a/packages/router/src/Contracts/IRouteValidator.ts b/packages/router/src/Contracts/IRouteValidator.ts new file mode 100644 index 00000000..4400d0f0 --- /dev/null +++ b/packages/router/src/Contracts/IRouteValidator.ts @@ -0,0 +1,5 @@ +import { IRequest, IRoute } from '@h3ravel/contracts' + +export abstract class IRouteValidator { + abstract matches (route: IRoute, request: IRequest): boolean | undefined +} \ No newline at end of file diff --git a/packages/router/src/Contracts/Pipeline.ts b/packages/router/src/Contracts/Pipeline.ts deleted file mode 100644 index 13cd1ec5..00000000 --- a/packages/router/src/Contracts/Pipeline.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { IMiddleware } from '@h3ravel/contracts' - -export type Pipe = string | (abstract new (...args: any[]) => any) | ((...args: any[]) => any) | IMiddleware \ No newline at end of file diff --git a/packages/router/src/Contracts/Utilities.ts b/packages/router/src/Contracts/Utilities.ts new file mode 100644 index 00000000..dde10c41 --- /dev/null +++ b/packages/router/src/Contracts/Utilities.ts @@ -0,0 +1,7 @@ +import { IMiddleware } from '@h3ravel/contracts' + +export type Pipe = string | (abstract new (...args: any[]) => any) | ((...args: any[]) => any) | IMiddleware + +export type CompiledRouteToken = + | ['variable', string, string, string, boolean] + | ['text', string]; \ No newline at end of file diff --git a/packages/router/src/Matchers/HostValidator.ts b/packages/router/src/Matchers/HostValidator.ts index ec696894..dc73e910 100644 --- a/packages/router/src/Matchers/HostValidator.ts +++ b/packages/router/src/Matchers/HostValidator.ts @@ -1,7 +1,8 @@ +import { IRouteValidator } from '../Contracts/IRouteValidator' import { Request } from '@h3ravel/http' import { Route } from '../Route' -export class HostValidator { +export class HostValidator extends IRouteValidator { /** * Validate a given rule against a route and request. * diff --git a/packages/router/src/Matchers/MethodValidator.ts b/packages/router/src/Matchers/MethodValidator.ts index 2a0eda23..4207435d 100644 --- a/packages/router/src/Matchers/MethodValidator.ts +++ b/packages/router/src/Matchers/MethodValidator.ts @@ -1,7 +1,8 @@ +import { IRouteValidator } from '../Contracts/IRouteValidator' import { Request } from '@h3ravel/http' import { Route } from '../Route' -export class MethodValidator { +export class MethodValidator extends IRouteValidator { /** * Validate a given rule against a route and request. * @@ -9,6 +10,6 @@ export class MethodValidator { * @param request */ public matches (route: Route, request: Request) { - return route.methods.includes(request.getMethod().toLowerCase() as never) + return route.methods.includes(request.getMethod() as never) } } \ No newline at end of file diff --git a/packages/router/src/Matchers/SchemeValidator.ts b/packages/router/src/Matchers/SchemeValidator.ts index 514b25fb..9eaa616c 100644 --- a/packages/router/src/Matchers/SchemeValidator.ts +++ b/packages/router/src/Matchers/SchemeValidator.ts @@ -1,7 +1,8 @@ +import { IRouteValidator } from '../Contracts/IRouteValidator' import { Request } from '@h3ravel/http' import { Route } from '../Route' -export class SchemeValidator { +export class SchemeValidator extends IRouteValidator { /** * Validate a given rule against a route and request. * diff --git a/packages/router/src/Matchers/UriValidator.ts b/packages/router/src/Matchers/UriValidator.ts index 92271587..5f807cf1 100644 --- a/packages/router/src/Matchers/UriValidator.ts +++ b/packages/router/src/Matchers/UriValidator.ts @@ -1,8 +1,9 @@ +import { IRouteValidator } from '../Contracts/IRouteValidator' import { Request } from '@h3ravel/http' import { Route } from '../Route' import { Str } from '@h3ravel/support' -export class UriValidator { +export class UriValidator extends IRouteValidator { /** * Validate a given rule against a route and request. * @@ -10,8 +11,7 @@ export class UriValidator { * @param request */ public matches (route: Route, request: Request) { - const path = Str.rtrim(request.getPathInfo(), '/') || '/' - - return route.getCompiled()?.getRegex().test(decodeURIComponent(path)) + const path = Str.of(request.getPathInfo()).ltrim('/').rtrim('/').toString() || '/' + return route.getCompiled()!.getRegex().test(decodeURIComponent(path)) } } \ No newline at end of file diff --git a/packages/router/src/Pipeline.ts b/packages/router/src/Pipeline.ts index 004190c6..acb05f90 100644 --- a/packages/router/src/Pipeline.ts +++ b/packages/router/src/Pipeline.ts @@ -1,7 +1,7 @@ import { Container, ContainerResolver } from '@h3ravel/core' import { CallableConstructor } from '@h3ravel/contracts' -import { Pipe } from './Contracts/Pipeline' +import { Pipe } from './Contracts/Utilities' import { RuntimeException } from '@h3ravel/support' export class Pipeline { @@ -86,9 +86,6 @@ export class Pipeline { // Normal flow return await pipeline(this.passable) - } catch (e: any) { - console.log('Pipeline Error:', e) - throw e } finally { if (this.finally) { (this.finally)(this.passable) diff --git a/packages/router/src/Route.ts b/packages/router/src/Route.ts index c50508b5..d10b0545 100644 --- a/packages/router/src/Route.ts +++ b/packages/router/src/Route.ts @@ -6,12 +6,17 @@ import { CallableDispatcher } from './CallableDispatcher' import { CompiledRoute } from './CompiledRoute' import { ControllerDispatcher } from './ControllerDispatcher' import { H3 } from 'h3' +import { HostValidator } from './Matchers/HostValidator' +import { IRouteValidator } from './Contracts/IRouteValidator' import { LogicException } from '@h3ravel/foundation' +import { MethodValidator } from './Matchers/MethodValidator' import { Request } from '@h3ravel/http' import { RouteAction } from './RouteAction' import { RouteParameterBinder } from './RouteParameterBinder' import { RouteUri } from './RouteUri' import { Router } from './Router' +import { SchemeValidator } from './Matchers/SchemeValidator' +import { UriValidator } from './Matchers/UriValidator' export class Route extends IRoute { /** @@ -59,6 +64,11 @@ export class Route extends IRoute { */ protected bindingFields!: Record + /** + * Indicates whether the route is a fallback route. + */ + isFallback: boolean = false + /** * The route action array. */ @@ -84,6 +94,11 @@ export class Route extends IRoute { */ controller?: Required + /** + * The validators used by the routes. + */ + static validators: IRouteValidator[] + /** * * @param methods The HTTP methods the route responds to. @@ -254,6 +269,33 @@ export class Route extends IRoute { return Obj.get(this.action, key) } + /** + * Mark this route as a fallback route. + */ + fallback () { + this.isFallback = true + + return this + } + + /** + * Set the fallback value. + * + * @param sFallback + */ + setFallback (isFallback: boolean) { + this.isFallback = isFallback + + return this + } + + /** + * Get the HTTP verbs the route responds to. + */ + getMethods () { + return this.methods + } + /** * Determine if the route only responds to HTTP requests. */ @@ -261,6 +303,13 @@ export class Route extends IRoute { return Obj.has(this.action, 'http') } + /** + * Determine if the route only responds to HTTPS requests. + */ + httpsOnly () { + return this.secure() + } + /** * Get or set the middlewares attached to the route. * @@ -391,9 +440,15 @@ export class Route extends IRoute { } return this.runCallable() - } catch (e) { - console.log(e) - return e.getResponse() + } catch (e: any) { + if (typeof e.getResponse !== 'undefined') { + return e.getResponse() + } + throw e + // return response() + // .setCharset('utf-8') + // .setStatusCode(e.code ?? e.statusCode ?? e.status ?? 500) + // .setContent(e.message) } } @@ -479,12 +534,17 @@ export class Route extends IRoute { * Get the optional parameter names for the route. */ getOptionalParameterNames (): Record { - const matches = [...this.uri().matchAll(/\{([\w:]+?)\??\}/g)] - if (!matches.length) return {} + const pattern = /\{([\w]+)(?:[:][\w]+)?(\?)?\}/g + const matches = [...this.uri().matchAll(pattern)] const result: Record = {} + for (const match of matches) { - result[match[1]] = null + const paramName = match[1] + const isOptional = !!match[2] // true if '?' exists + if (isOptional) { + result[paramName] = null + } } return result @@ -509,8 +569,8 @@ export class Route extends IRoute { } protected compileParameterNames (): string[] { - const pattern = /\{([\w:]+?)\??\}/g - const fullUri = (this.getDomain() ?? '') + this.uri + const pattern = /\{([\w]+)(?:[:][\w]+)?\??\}/g + const fullUri = (this.getDomain() ?? '') + this.uri() const matches = [...fullUri.matchAll(pattern)] return matches.map(m => m[1]) @@ -522,15 +582,8 @@ export class Route extends IRoute { compileRoute (): CompiledRoute { if (!this.compiled) { const optionalParams = this.getOptionalParameterNames() - const paramNames: string[] = [] - // extract all param names in order - this.uri().replace(/\{([\w:]+?)\??\}/g, (_, paramName) => { - paramNames.push(paramName) - return '' - }) - - this.compiled = new CompiledRoute(this.uri(), paramNames, optionalParams) + this.compiled = new CompiledRoute(this.uri(), optionalParams) } return this.compiled @@ -608,6 +661,27 @@ export class Route extends IRoute { return new ControllerDispatcher(this.container) } + /** + * Get the route validators for the instance. + * + * @return array + */ + static getValidators () { + if (typeof this.validators !== 'undefined') { + return this.validators + } + + // To match the route, we will use a chain of responsibility pattern with the + // validator implementations. We will spin through each one making sure it + // passes and then we will know if the route as a whole matches request. + return this.validators = [ + new UriValidator(), + new MethodValidator(), + new SchemeValidator(), + new HostValidator(), + ] + } + /** * Run the route action and return the response. * @@ -657,6 +731,29 @@ export class Route extends IRoute { this.controller = undefined } + /** + * Determine if the route matches a given request. + * + * @param request + * @param includingMethod + */ + matches (request: Request, includingMethod = true): boolean { + this.compileRoute() + + for (const validator of Route.getValidators()) { + + if (!includingMethod && validator instanceof MethodValidator) { + continue + } + + if (!validator.matches(this, request)) { + return false + } + } + + return true + } + /** * Get the controller class used for the route. */ @@ -689,11 +786,14 @@ export class Route extends IRoute { this.getControllerClass(), this.getControllerMethod(), ] + void controllerClass + void controllerMethod } else { // } - console.log(controllerClass, controllerMethod, this.action, 'controllerMiddleware') + // console.log(controllerClass, controllerMethod, this.action, 'controllerMiddleware') + // TODO: Let's finish the below // if (is_a(controllerClass, HasMiddleware.lass, true)) { // return this.staticallyProvidedControllerMiddleware( // controllerClass, diff --git a/packages/router/src/RouteCollection.ts b/packages/router/src/RouteCollection.ts index fa07913d..820d5119 100644 --- a/packages/router/src/RouteCollection.ts +++ b/packages/router/src/RouteCollection.ts @@ -136,15 +136,18 @@ export class RouteCollection extends AbstractRouteCollection implements IRouteCo * May throw framework-specific exceptions (MethodNotAllowed / NotFound). */ public match (request: Request): Route { - const routesByMethod = this.get(request.getMethod()) as Record + const routes = this.get(request.getMethod()) as Record + + const route = this.matchAgainstRoutes(routes, request) - const route = this.matchAgainstRoutes(routesByMethod, request) return this.handleMatchedRoute(request, route) } /** * Get routes from the collection by method. */ + public get (): Route[] + public get (method: string): Record public get (method?: string): Record | Route[] { if (typeof method === 'undefined' || method === null) { return this.getRoutes() diff --git a/packages/router/src/RouteParameterBinder.ts b/packages/router/src/RouteParameterBinder.ts index 42ff62ef..d566d26d 100644 --- a/packages/router/src/RouteParameterBinder.ts +++ b/packages/router/src/RouteParameterBinder.ts @@ -38,7 +38,7 @@ export class RouteParameterBinder { */ protected bindPathParameters (request: Request): Record { // ensure path starts with '/' - const path = '/' + (request.decodedPath().replace(/^\/+/, '')) + const path = request.decodedPath().replace(/^\/+/, '') const pathRegex = this.route.compiled?.getRegex() ?? '' const matches = path.match(pathRegex) ?? [] @@ -72,6 +72,7 @@ export class RouteParameterBinder { */ protected matchToKeys (matches: string[]): Record { const parameterNames = this.route.parameterNames() + if (!parameterNames || parameterNames.length === 0) { return {} } diff --git a/packages/router/src/Router.ts b/packages/router/src/Router.ts index 4a6da58f..80565103 100644 --- a/packages/router/src/Router.ts +++ b/packages/router/src/Router.ts @@ -1,7 +1,7 @@ import 'reflect-metadata' import { H3Event, Middleware, MiddlewareOptions, type H3 } from 'h3' import { Application, Container, Kernel } from '@h3ravel/core' -import { Request, Response, HttpContext } from '@h3ravel/http' +import { Request, Response, HttpContext, JsonResponse } from '@h3ravel/http' import { Arr, Collection, isClass, Str, Stringable, tap } from '@h3ravel/support' import { Dispatcher } from '@h3ravel/events' import { FileSystem } from '@h3ravel/shared' @@ -400,8 +400,8 @@ export class Router implements IRouter { * @returns */ private async handleResponse (handler: (ctx: HttpContext) => Promise, ctx: HttpContext): Promise { - this.app.exceptionHandler ??= this.app.make(ExceptionHandler) - if (!this.app.exceptionHandler) { + const exceptionHandler = this.app.make(ExceptionHandler) + if (!exceptionHandler) { return await handler(ctx) } @@ -411,8 +411,8 @@ export class Router implements IRouter { /** * Handle the exception here. */ - if (this.app.exceptionHandler.handle) { - return await this.app.exceptionHandler.handle?.(error as Error, ctx) + if (typeof exceptionHandler.handle !== 'undefined') { + return await exceptionHandler.handle(error as Error, ctx) as IResponse } /** @@ -470,7 +470,6 @@ export class Router implements IRouter { request.setRouteResolver(() => route) this.events.dispatch(new RouteMatched(route, request)) - // console.log(route.methods, route.getPath(), 'route.methods') const response = await this.prepareResponse(request, await this.runRouteWithinStack(route, request)) return response @@ -534,14 +533,14 @@ export class Router implements IRouter { * @param excluded * @return array */ - resolveMiddleware (middleware: IMiddleware[], excluded: any[] = []) { + resolveMiddleware (middleware: IMiddleware[], excluded: IMiddleware[] = []) { excluded = excluded.length === 0 ? excluded : (new Collection(excluded)) .map((name) => MiddlewareResolver.setApp(this.app).resolve(name, this.#middleware, this.middlewareGroups)) .flatten() .values() - .all() + .all() as never const middlewares = (new Collection(middleware)) .map((name) => MiddlewareResolver.setApp(this.app).resolve(name, this.#middleware, this.middlewareGroups)) @@ -631,8 +630,7 @@ export class Router implements IRouter { if (response instanceof Stringable || typeof response === 'string') { response = new Response(request.app, response.toString(), 200, { 'Content-Type': 'text/html' }) } else if (!(response instanceof IResponse) && !(response instanceof Response)) { - // TODO: Implement a universal feature to convert classes to string or at least extract stringable content from them - response = new Response(request.app, 'UNIMPLEMENTED', 200, { 'Content-Type': 'text/html' }) + response = new JsonResponse(request.app, response) } if (response.getStatusCode() === Response.codes.HTTP_NOT_MODIFIED) { @@ -848,11 +846,7 @@ export class Router implements IRouter { * @param uri - The route uri. * @param action - The handler function or [controller class, method] array. */ - match ( - methods: Lowercase[], - uri: string, - action: ActionInput, - ): Route { + match (methods: Lowercase[], uri: string, action: ActionInput): Route { return this.#addRoute(Arr.wrap(methods).map(e => e.toUpperCase() as RouteMethod), uri, action) } diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 71b26c63..01acf664 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -2,7 +2,8 @@ export * from './AbstractRouteCollection' export * from './CallableDispatcher' export * from './Commands/RouteListCommand' export * from './CompiledRoute' -export * from './Contracts/Pipeline' +export * from './Contracts/IRouteValidator' +export * from './Contracts/Utilities' export * from './Controller' export * from './ControllerDispatcher' export * from './Events/PreparingResponse' diff --git a/packages/shared/src/Utils/Console.ts b/packages/shared/src/Utils/Console.ts new file mode 100644 index 00000000..7fe8a334 --- /dev/null +++ b/packages/shared/src/Utils/Console.ts @@ -0,0 +1,9 @@ +import { Logger } from './Logger' + +export class Console { + static log = (...args: any[]) => Logger.log(args.map(e => [e, 'white'])) + static debug = (...args: any[]) => Logger.debug(args, false, true) + static warn = (...args: any[]) => args.map(e => Logger.warn(e, false, true)) + static info = (...args: any[]) => args.map(e => Logger.info(e, false, true)) + static error = (...args: any[]) => args.map(e => Logger.error(e, false), true) +} \ No newline at end of file diff --git a/packages/shared/src/Utils/Logger.ts b/packages/shared/src/Utils/Logger.ts index 97d9a119..eb639efb 100644 --- a/packages/shared/src/Utils/Logger.ts +++ b/packages/shared/src/Utils/Logger.ts @@ -1,5 +1,6 @@ import chalk, { type ChalkInstance } from 'chalk' import { LoggerChalk, LoggerLog, LoggerParseSignature } from '../Contracts/Utils' +import { Console } from './Console' export class Logger { /** @@ -97,10 +98,37 @@ export class Logger { * @returns */ static textFormat ( - txt: unknown, - color: (txt: string) => string, + txt: unknown | unknown[], + color: (...text: unknown[]) => string, preserveCol = false ): string { + if (txt instanceof Error) { + const err: Error & { code?: number, statusCode?: number } = txt + const code = err.code ?? err.statusCode ? ` (${err.code ?? err.statusCode})` : '' + const output: string[] = [] + + if (err.message) { + output.push(this.textFormat(`${err.constructor.name}${code}: ${err.message}`, chalk.bgRed, preserveCol)) + } + + if (err.stack) { + output.push(' ' + chalk.white(err.stack.replace(`${err.name}: ${err.message}`, '').trim())) + } + return output.join('\n') + } + + if (Array.isArray(txt)) { + return txt.map(e => this.textFormat(e, color, preserveCol)).join('\n') + } + + if (typeof txt === 'object') { + return this.textFormat(Object.values(txt!), color, preserveCol) + } + + if (typeof txt !== 'string') { + return color(txt) + } + const str = String(txt) if (preserveCol) return str @@ -146,13 +174,13 @@ export class Logger { * @param exit * @param preserveCol */ - static error (msg: string | string[] | Error & { detail?: string }, exit = true, preserveCol = false) { + static error (msg: any, exit = true, preserveCol = false) { if (!this.shouldSuppressOutput('error')) { if (msg instanceof Error) { if (msg.message) { console.error(chalk.red('✖'), this.textFormat('ERROR:' + msg.message, chalk.bgRed, preserveCol)) } - console.error(chalk.red(`${msg.detail ? `${msg.detail}\n` : ''}${msg.stack}`)) + console.error(chalk.red(`${(msg as any).detail ? `${(msg as any).detail}\n` : ''}${msg.stack}`)) } else { console.error(chalk.red('✖'), this.textFormat(msg, chalk.bgRed, preserveCol)) @@ -251,7 +279,7 @@ export class Logger { * * @returns */ - public static log: LoggerLog = ((config, joiner, log: boolean = true, sc) => { + static log: LoggerLog = ((config, joiner, log: boolean = true, sc) => { if (typeof config === 'string') { const conf = [[config, joiner]] as [string, keyof ChalkInstance][] return this.parse(conf, '', log as false, sc) @@ -263,4 +291,13 @@ export class Logger { return this }) as LoggerLog + + /** + * A simple console like output logger + * + * @returns + */ + static console () { + return Console + } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4b846e01..a2632914 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,7 @@ export * from './Contracts/ObjContract' export * from './Contracts/PromptsContract' export * from './Contracts/Utils' +export * from './Utils/Console' export * from './Utils/EnvParser' export * from './Utils/FileSystem' export * from './Utils/Logger' diff --git a/packages/support/src/Collection.ts b/packages/support/src/Collection.ts index 2f11e657..8114ca45 100644 --- a/packages/support/src/Collection.ts +++ b/packages/support/src/Collection.ts @@ -5,7 +5,7 @@ export class Collection extends BaseCollection { * * @param collection */ - constructor(collection?: Item[] | Item) { + constructor(collection?: Item[] | Item | Record) { super(collection) } } @@ -15,6 +15,6 @@ export class Collection extends BaseCollection { * @param collection * @returns */ -export const collect = (collection?: T | T[] | undefined): Collection => { +export const collect = (collection?: T | T[] | Record | undefined): Collection => { return new Collection(collection) } \ No newline at end of file diff --git a/packages/view/src/Providers/ViewServiceProvider.ts b/packages/view/src/Providers/ViewServiceProvider.ts index 51881d5a..0e347676 100644 --- a/packages/view/src/Providers/ViewServiceProvider.ts +++ b/packages/view/src/Providers/ViewServiceProvider.ts @@ -1,4 +1,5 @@ import { EdgeViewEngine } from '../EdgeViewEngine' +import { Responsable } from '@h3ravel/http' import { ServiceProvider } from '@h3ravel/core' /** @@ -36,7 +37,12 @@ export class ViewServiceProvider extends ServiceProvider { * @returns */ const view = async (template: string, data?: Record) => { - const response = this.app.make('http.response') + let response = this.app.make('http.response') + const request = this.app.make('http.request') + + if (response instanceof Responsable) { + response = response.toResponse(request) + } return response.html(await this.app.make('edge').render(template, data), true) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89ff1dbe..02275a8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,8 +146,8 @@ catalogs: specifier: ^0.6.17 version: 0.6.17 '@h3ravel/collect.js': - specifier: ^5.1.1 - version: 5.1.1 + specifier: ^5.3.2 + version: 5.3.2 '@h3ravel/musket': specifier: ^0.4.0 version: 0.4.0 @@ -784,7 +784,7 @@ importers: devDependencies: '@h3ravel/collect.js': specifier: catalog:prod - version: 5.1.1 + version: 5.3.2 '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -1617,8 +1617,8 @@ packages: engines: {node: '>=14', pnpm: '>=4'} hasBin: true - '@h3ravel/collect.js@5.1.1': - resolution: {integrity: sha512-ypP6ugFOYR94A5J2YAoBsyvHLZ8Z5dRl22i0WodMK8sWYAzvgM4UjSMS8k58fP8fkwJtYxxScnyXpAY0y4Y/Hw==} + '@h3ravel/collect.js@5.3.2': + resolution: {integrity: sha512-oCI+1cUOE5il+z6OTiE0NuYB4gniqvK161XuepAlqEv1LdI+T86oGGww2s97pket1sYHbHfggvvRnKiSJKiSHQ==} '@h3ravel/musket@0.4.0': resolution: {integrity: sha512-avecKyX+jDNm5AFUBLARoCSNYUJm8mda6MvykoGhaMhULaKUQFzxu9wwzvI+HKY+VOFjyErXbO2aOYe+kvbC2g==} @@ -7089,7 +7089,7 @@ snapshots: - sqlite3 - supports-color - '@h3ravel/collect.js@5.1.1': {} + '@h3ravel/collect.js@5.3.2': {} '@h3ravel/musket@0.4.0(@h3ravel/support@0.15.6)(@types/node@24.10.0)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b44720b7..5e1f7b18 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -73,7 +73,7 @@ catalogs: prod: '@h3ravel/arquebus': ^0.6.17 '@h3ravel/musket': ^0.4.0 - '@h3ravel/collect.js': ^5.1.1 + '@h3ravel/collect.js': ^5.3.2 h3: 2.0.1-rc.5 ignoredBuiltDependencies: From a5e0b426cb6e588b354b0d961934f8040898a4c7 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Tue, 6 Jan 2026 05:26:16 +0100 Subject: [PATCH 13/28] feat: Implement Finalizable and Magic mixins for enhanced class functionality - Added `Finalizable` trait to allow automatic finalization of class instances. - Introduced `UseMagic` mixin to emulate PHP magic methods in TypeScript classes. - Created comprehensive tests for mixins and traits to ensure expected behavior. - Developed `Tappable` and `Macroable` traits to enhance class capabilities with tap and macro functionalities. - Established `AssetsServiceProvider` and `RouteServiceProvider` for handling asset loading and routing registration. - Introduced `BadMethodCallException` for better error handling in macro calls. - Enhanced `Facades` system for improved application instance management. --- .barrelize | 11 +- examples/basic-app/.h3ravel/tsconfig.json | 2 +- .../src/app/Console/Commands/DemoCommand.ts | 61 +- .../Http/Controllers/UrlExampleController.ts | 6 +- examples/basic-app/src/bootstrap/app.ts | 19 +- examples/basic-app/src/routes/api.ts | 19 +- examples/basic-app/src/routes/web.ts | 80 +- examples/basic-app/tsdown.default.config.ts | 2 +- packages/cache/package.json | 2 +- .../src/Providers/CacheServiceProvider.ts | 2 +- .../src/Providers/ConfigServiceProvider.ts | 2 +- packages/console/package.json | 8 +- packages/console/src/IO/app.ts | 27 +- .../src/Providers/ConsoleServiceProvider.ts | 52 -- packages/console/src/fire.ts | 2 +- packages/console/src/index.ts | 2 - packages/contracts/package.json | 3 +- .../src/Configuration/IAppBuilder.ts | 52 ++ packages/contracts/src/Core/IApplication.ts | 26 +- packages/contracts/src/Core/IContainer.ts | 11 +- packages/contracts/src/Core/IController.ts | 9 +- .../contracts/src/Core/IServiceProvider.ts | 2 +- .../src/Exceptions/IExceptionHandler.ts | 7 + packages/contracts/src/Foundation/CKernel.ts | 57 ++ .../contracts/src/Foundation/IBootstraper.ts | 8 + packages/contracts/src/Http/IHttpRequest.ts | 4 +- packages/contracts/src/Http/IRequest.ts | 71 +- .../src/Routing/IControllerDispatcher.ts | 4 +- .../Routing/IPendingResourceRegistration.ts | 105 +++ .../IPendingSingletonResourceRegistration.ts | 93 +++ packages/contracts/src/Routing/IRoute.ts | 25 +- .../contracts/src/Routing/IRouteCollection.ts | 10 +- .../contracts/src/Routing/IRouteRegistrar.ts | 19 + packages/contracts/src/Routing/IRouter.ts | 204 ++--- .../src/Utilities/BindingsContract.ts | 7 +- packages/contracts/src/Utilities/Utilities.ts | 42 +- packages/contracts/src/index.ts | 6 + packages/core/src/Application.ts | 187 +++-- packages/core/src/Container.ts | 36 +- packages/core/src/Controller.ts | 4 +- packages/core/src/H3ravel.ts | 12 +- .../core/src/Manager/ContainerResolver.ts | 3 +- packages/core/src/ProviderRegistry.ts | 42 +- packages/core/src/ServiceProvider.ts | 2 +- packages/database/src/Model.ts | 34 +- .../src/Providers/DatabaseServiceProvider.ts | 2 +- .../src/Providers/EventsServiceProvider.ts | 6 +- packages/foundation/package.json | 1 + .../src/Adapters/InMemoryRateLimiter.ts | 2 +- .../src/Bootstrapers/BootProviders.ts | 10 + .../src/Bootstrapers/RegisterFacades.ts | 14 + .../src/Configuration/AppBuilder.ts | 152 +++- .../src/Configuration/Middleware.ts | 4 +- .../src/Console/Commands/BuildCommand.ts | 97 +++ .../Console/Commands/KeyGenerateCommand.ts | 92 +++ .../src/Console/Commands/MakeCommand.ts | 121 +++ .../Console/Commands/PostinstallCommand.ts | 59 ++ .../foundation/src/Console/ConsoleKernel.ts | 293 +++++++ .../src/Console}/TsdownConfig.ts | 0 packages/foundation/src/Console/logo.ts | 32 + .../Container/{Inject.ts => Decorators.ts} | 14 + .../src/Contracts/MiddlewareContract.ts | 5 - .../src/Contracts/RateLimiterAdapter.ts | 31 - .../foundation/src/Core/Events/Terminating.ts | 3 + .../src/Exceptions/Base/Exceptions.ts | 9 + .../foundation/src/Exceptions/Base/Handler.ts | 41 +- .../Exceptions/CommandNotFoundException.ts | 25 + .../src/Exceptions/RouteNotFoundException.ts | 7 + .../src/Exceptions/UrlGenerationException.ts | 22 + .../src/Http/Events/RequestHandled.ts | 2 +- packages/foundation/src/Http/Kernel.ts | 39 +- .../src/Testing/supertestAdapter.ts | 2 +- packages/foundation/src/index.ts | 18 +- .../foundation/src/views/errors/error.edge | 7 +- packages/http/src/Middleware.ts | 2 +- .../http/src/Providers/HttpServiceProvider.ts | 13 +- packages/http/src/Request.ts | 224 ++++-- packages/http/src/Utilities/HttpRequest.ts | 42 +- packages/http/src/Utilities/HttpResponse.ts | 2 +- packages/http/src/Utilities/InputBag.ts | 26 +- packages/http/tests/Request.spec.ts | 6 +- packages/router/package.json | 5 +- .../router/src/AbstractRouteCollection.ts | 6 +- packages/router/src/CallableDispatcher.ts | 11 +- .../router/src/Commands/RouteListCommand.ts | 4 +- packages/router/src/ControllerDispatcher.ts | 28 +- ...reatesRegularExpressionRouteConstraints.ts | 78 ++ .../src/Middleware/SubstituteBindings.ts | 2 +- .../router/src/PendingResourceRegistration.ts | 289 +++++++ .../PendingSingletonResourceRegistration.ts | 268 +++++++ packages/router/src/Pipeline.ts | 11 +- .../src/Providers/RouteServiceProvider.ts | 89 --- packages/router/src/ResourceRegistrar.ts | 680 +++++++++++++++++ packages/router/src/Route.ts | 64 +- packages/router/src/RouteCollection.ts | 18 +- packages/router/src/RouteRegisterer.ts | 179 +++++ packages/router/src/RouteUrlGenerator.ts | 386 ++++++++++ packages/router/src/Router.ts | 722 +++++++----------- .../FiltersControllerMiddleware.ts | 0 .../RouteDependencyResolver.ts | 8 +- packages/router/src/UrlGenerator.ts | 484 ++++++++++++ packages/router/src/index.ts | 13 +- .../src/Providers/SessionServiceProvider.ts | 2 +- packages/shared/src/Container.ts | 1 + packages/shared/src/Mixins/MixinSystem.ts | 85 +++ packages/shared/src/Mixins/TraitSystem.ts | 426 +++++++++++ packages/shared/src/Mixins/UseFinalizable.ts | 34 + packages/shared/src/Mixins/UseMagic.ts | 175 +++++ packages/shared/src/Utils/PathLoader.ts | 11 + packages/shared/src/Utils/scripts.ts | 2 +- packages/shared/src/index.ts | 5 + packages/shared/tests/mixin.spec.ts | 167 ++++ packages/shared/tsconfig.json | 23 + packages/support/package.json | 14 +- packages/support/src/Contracts/Helpers.ts | 29 + .../src/Exceptions/BadMethodCallException.ts | 5 + packages/support/src/Facades/.gitkeep | 0 packages/support/src/Facades/Facades.ts | 138 ++++ packages/support/src/Facades/RouteFacade.ts | 14 + packages/support/src/Facades/index.ts | 2 + packages/support/src/Helpers.ts | 85 ++- packages/support/src/Helpers/Str.ts | 4 +- packages/support/src/Macroable.ts | 122 +++ .../src/Providers/AssetsServiceProvider.ts | 6 +- .../src/Providers/RouteServiceProvider.ts | 123 +++ .../src/Providers}/ServiceProvider.ts | 6 +- packages/support/src/Tappable.ts | 18 + packages/support/src/index.ts | 7 + packages/support/tests/collection.test.ts | 4 +- packages/support/tsdown.config.ts | 11 + .../url/src/Providers/UrlServiceProvider.ts | 2 +- packages/url/src/RequestAwareHelpers.ts | 2 +- packages/url/src/Url.ts | 10 +- pnpm-lock.yaml | 65 +- pnpm-workspace.yaml | 8 +- tsconfig.base.json | 1 + 136 files changed, 6544 insertions(+), 1215 deletions(-) delete mode 100644 packages/console/src/Providers/ConsoleServiceProvider.ts create mode 100644 packages/contracts/src/Configuration/IAppBuilder.ts create mode 100644 packages/contracts/src/Foundation/CKernel.ts create mode 100644 packages/contracts/src/Foundation/IBootstraper.ts create mode 100644 packages/contracts/src/Routing/IPendingResourceRegistration.ts create mode 100644 packages/contracts/src/Routing/IPendingSingletonResourceRegistration.ts create mode 100644 packages/contracts/src/Routing/IRouteRegistrar.ts create mode 100644 packages/foundation/src/Bootstrapers/BootProviders.ts create mode 100644 packages/foundation/src/Bootstrapers/RegisterFacades.ts create mode 100644 packages/foundation/src/Console/Commands/BuildCommand.ts create mode 100644 packages/foundation/src/Console/Commands/KeyGenerateCommand.ts create mode 100644 packages/foundation/src/Console/Commands/MakeCommand.ts create mode 100644 packages/foundation/src/Console/Commands/PostinstallCommand.ts create mode 100644 packages/foundation/src/Console/ConsoleKernel.ts rename packages/{console/src => foundation/src/Console}/TsdownConfig.ts (100%) create mode 100644 packages/foundation/src/Console/logo.ts rename packages/foundation/src/Container/{Inject.ts => Decorators.ts} (72%) delete mode 100644 packages/foundation/src/Contracts/MiddlewareContract.ts delete mode 100644 packages/foundation/src/Contracts/RateLimiterAdapter.ts create mode 100644 packages/foundation/src/Core/Events/Terminating.ts create mode 100644 packages/foundation/src/Exceptions/CommandNotFoundException.ts create mode 100644 packages/foundation/src/Exceptions/RouteNotFoundException.ts create mode 100644 packages/foundation/src/Exceptions/UrlGenerationException.ts create mode 100644 packages/router/src/CreatesRegularExpressionRouteConstraints.ts create mode 100644 packages/router/src/PendingResourceRegistration.ts create mode 100644 packages/router/src/PendingSingletonResourceRegistration.ts delete mode 100644 packages/router/src/Providers/RouteServiceProvider.ts create mode 100644 packages/router/src/ResourceRegistrar.ts create mode 100644 packages/router/src/RouteRegisterer.ts create mode 100644 packages/router/src/RouteUrlGenerator.ts rename packages/router/src/{TraitLike => Traits}/FiltersControllerMiddleware.ts (100%) rename packages/router/src/{TraitLike => Traits}/RouteDependencyResolver.ts (86%) create mode 100644 packages/router/src/UrlGenerator.ts create mode 100644 packages/shared/src/Container.ts create mode 100644 packages/shared/src/Mixins/MixinSystem.ts create mode 100644 packages/shared/src/Mixins/TraitSystem.ts create mode 100644 packages/shared/src/Mixins/UseFinalizable.ts create mode 100644 packages/shared/src/Mixins/UseMagic.ts create mode 100644 packages/shared/tests/mixin.spec.ts create mode 100644 packages/support/src/Contracts/Helpers.ts create mode 100644 packages/support/src/Exceptions/BadMethodCallException.ts delete mode 100644 packages/support/src/Facades/.gitkeep create mode 100644 packages/support/src/Facades/Facades.ts create mode 100644 packages/support/src/Facades/RouteFacade.ts create mode 100644 packages/support/src/Facades/index.ts create mode 100644 packages/support/src/Macroable.ts rename packages/{router => support}/src/Providers/AssetsServiceProvider.ts (93%) create mode 100644 packages/support/src/Providers/RouteServiceProvider.ts rename packages/{foundation/src/Core => support/src/Providers}/ServiceProvider.ts (94%) create mode 100644 packages/support/src/Tappable.ts create mode 100644 packages/support/tsdown.config.ts diff --git a/.barrelize b/.barrelize index 5caf0b0b..e8751a8c 100644 --- a/.barrelize +++ b/.barrelize @@ -162,10 +162,19 @@ }, { "name": "index.ts", - "root": "packages/support/src", + "root": "packages/support/src/facades", "exclude": [ "**/*.test.ts", "**/*.d.ts" + ] + }, + { + "name": "index.ts", + "root": "packages/support/src", + "exclude": [ + "**/*.test.ts", + "**/*.d.ts", + "**/Facades/*.*" ], "exports": { "**/Arr.ts": [ diff --git a/examples/basic-app/.h3ravel/tsconfig.json b/examples/basic-app/.h3ravel/tsconfig.json index b65c621d..8cde4893 100644 --- a/examples/basic-app/.h3ravel/tsconfig.json +++ b/examples/basic-app/.h3ravel/tsconfig.json @@ -25,7 +25,7 @@ }, "target": "es2022", "module": "es2022", - "moduleResolution": "Node", + "moduleResolution": "bundler", "esModuleInterop": true, "strict": true, "allowJs": true, diff --git a/examples/basic-app/src/app/Console/Commands/DemoCommand.ts b/examples/basic-app/src/app/Console/Commands/DemoCommand.ts index d9541320..8693ac69 100644 --- a/examples/basic-app/src/app/Console/Commands/DemoCommand.ts +++ b/examples/basic-app/src/app/Console/Commands/DemoCommand.ts @@ -1,4 +1,4 @@ -import { Command } from '@h3ravel/console' +import { Command } from '@h3ravel/musket' export class DemoCommand extends Command { /** @@ -15,7 +15,7 @@ export class DemoCommand extends Command { * Execute the console command. */ public async handle (): Promise { - // const simulate = this.option('simulate', false) + const simulate = this.option('simulate', false) this.info('Starting demonstration...') this.debug('Debug: This message only shows with --verbose 3') @@ -37,36 +37,37 @@ export class DemoCommand extends Command { this.newLine() // Demonstrate interaction - // if (!simulate) { - // try { - // const name = await this.ask('What is your name?', 'Developer') - // this.success(`Hello, ${name}!`) + if (!simulate) { + try { + const name = await this.ask('What is your name?', 'Developer') + this.success(`Hello, ${name}!`) - // const confirmed = await this.confirm('Do you want to continue?', true) - // if (confirmed) { - // const environment = await this.choice( - // 'Select environment:', - // ['development', 'staging', 'production'], - // 'development' - // ) - // this.info(`Selected environment: ${environment}`) - // } else { - // this.warn('Operation cancelled by user') - // } - // } catch (error) { - // this.error(`Interaction failed: ${error}`) - // } - // } else { - // this.info('Simulation mode - skipping interactive prompts') - // } + const confirmed = await this.confirm('Do you want to continue?', true) - // this.newLine() - // this.success('Demonstration completed!') + if (confirmed) { + const environment = await this.choice( + 'Select environment:', + ['development', 'staging', 'production'], + 0 + ) + this.info(`Selected environment: ${environment}`) + } else { + this.warn('Operation cancelled by user') + } + } catch (error) { + this.error(`Interaction failed: ${error}`) + } + } else { + this.info('Simulation mode - skipping interactive prompts') + } - // // Show debug information about verbosity - // if (this.getVerbosity() >= 2) { - // this.debug('Verbose mode detected - showing additional information') - // this.debug(`Process arguments: ${process.argv.slice(2).join(' ')}`) - // } + this.newLine() + this.success('Demonstration completed!') + + // Show debug information about verbosity + if (this.getVerbosity() >= 2) { + this.debug('Verbose mode detected - showing additional information') + this.debug(`Process arguments: ${process.argv.slice(2).join(' ')}`) + } } } diff --git a/examples/basic-app/src/app/Http/Controllers/UrlExampleController.ts b/examples/basic-app/src/app/Http/Controllers/UrlExampleController.ts index 7ed5d55b..60d907f9 100644 --- a/examples/basic-app/src/app/Http/Controllers/UrlExampleController.ts +++ b/examples/basic-app/src/app/Http/Controllers/UrlExampleController.ts @@ -1,5 +1,7 @@ -import { Controller } from '@h3ravel/core' +import { Controller, Injectable } from '@h3ravel/core' + import { HttpContext } from '@h3ravel/http' +// import { Route } from '@h3ravel/support/facades' import { Url } from '@h3ravel/url' /** @@ -9,7 +11,9 @@ export class UrlExampleController extends Controller { /** * Demonstrate various URL creation methods */ + @Injectable() async index (ctx: HttpContext) { + // console.log(Route.middleware('web')) const examples = { // Static URL creation fromString: Url.of('https://example.com/path?param=value#section').toString(), diff --git a/examples/basic-app/src/bootstrap/app.ts b/examples/basic-app/src/bootstrap/app.ts index 0cab7c78..515ee063 100644 --- a/examples/basic-app/src/bootstrap/app.ts +++ b/examples/basic-app/src/bootstrap/app.ts @@ -1,12 +1,25 @@ +import { Application, h3ravel } from '@h3ravel/core' + import { UnprocessableEntityHttpException } from '@h3ravel/foundation' -import { h3ravel } from '@h3ravel/core' +import path from 'node:path' import providers from 'src/bootstrap/providers' export default class { async bootstrap () { const app = await h3ravel(providers, process.cwd(), { autoload: true, initialize: false }, async () => undefined) + this.configure(app) + return await app.fire() + } - app.configure() + configure (app: Application) { + return app.configure() + .withRouting({ + web: path.join(process.cwd(), 'src/routes/web.ts'), + api: path.join(process.cwd(), 'src/routes/api.ts'), + commands: path.join(process.cwd(), 'src/routes/console.ts'), + channels: path.join(process.cwd(), 'src/routes/channels.ts'), + health: '/up', + }) .withExceptions((exceptions) => { return exceptions /** @@ -29,7 +42,5 @@ export default class { .withMiddleware(() => { console.log('-=withMiddleware=-') }) - - return await app.fire() } } diff --git a/examples/basic-app/src/routes/api.ts b/examples/basic-app/src/routes/api.ts index a21d466c..c757e69b 100644 --- a/examples/basic-app/src/routes/api.ts +++ b/examples/basic-app/src/routes/api.ts @@ -1,17 +1,10 @@ import { AuthMiddleware } from 'App/Http/Middlewares/AuthMiddleware' -import { Router } from '@h3ravel/router' +import { Route } from '@h3ravel/support/facades' import { UserController } from 'App/Http/Controllers/UserController' -export default (Route: Router) => { - Route.group({ - prefix: '/', middleware: [ - (_event) => { - console.log('Incoming request') - } - ] - }, () => { - Route.apiResource('/users', UserController, [new AuthMiddleware()]) - }) +Route.prefix('/').group(() => { + Route.apiResource('/users', UserController).middleware([new AuthMiddleware()]) +}) - Route.get('/hello', () => 'Hello', 'hello.route') -} +Route.get('/hello', () => 'Hello').name('hello.route') +Route.get('/hello/hi', [UserController, 'index']).name('hello.route') \ No newline at end of file diff --git a/examples/basic-app/src/routes/web.ts b/examples/basic-app/src/routes/web.ts index b83bd35d..ff4c5206 100644 --- a/examples/basic-app/src/routes/web.ts +++ b/examples/basic-app/src/routes/web.ts @@ -1,50 +1,50 @@ import { HomeController } from 'App/Http/Controllers/HomeController' import { HttpContext } from '@h3ravel/http' -import { MailController } from 'src/app/Http/Controllers/MailController' -import { Router } from '@h3ravel/router' -import { UrlExampleController } from 'src/app/Http/Controllers/UrlExampleController' +import { MailController } from 'App/Http/Controllers/MailController' +import { Route } from '@h3ravel/support/facades' +import { UrlExampleController } from 'App/Http/Controllers/UrlExampleController' -export default (Route: Router) => { - // Route.get('/', [HomeController, 'index']) - Route.get('/mail', [MailController, 'send']) +// Route.get('/', [HomeController, 'index']) +Route.get('/mail', [MailController, 'send']) +// URL examples +Route.get('/url-examples/{id?}', [UrlExampleController, 'index']).name('url.examples') +Route.get('/url-signing', [UrlExampleController, 'signing']).name('url.signing') +Route.get('/url-manipulation', [UrlExampleController, 'manipulation']).name('url.manipulation') +Route.match(['GET', 'GET'], 'path5/{user:username}/{name?}', () => ({ name: 2 })).name('path5') +Route.match(['GET'], '/', [HomeController, 'index']).name('index').middleware('web') +Route.match(['GET'], '/test/{user:username}', (_: any, user: any) => { + return `{ Test Result: ${user} }` +}).name('index') - // URL examples - Route.get('/url-examples', [UrlExampleController, 'index'], 'url.examples') - Route.get('/url-signing', [UrlExampleController, 'signing'], 'url.signing') - Route.get('/url-manipulation', [UrlExampleController, 'manipulation'], 'url.manipulation') - Route.match(['post', 'get'], 'path5/{user:username}/{name?}', () => ({ name: 2 })).name('path5') - Route.match(['get'], '/', [HomeController, 'index']).name('index').middleware('web') - Route.match(['get'], '/test/{user:username}', (_, user) => { - return `{ Test Result: ${user} }` - }).name('index') - - Route.get('/app', async function () { - return await view('index', { - links: { - documentation: 'https://h3ravel.toneflix.net/docs', - performance: 'https://h3ravel.toneflix.net/performance', - integration: 'https://h3ravel.toneflix.net/h3-integration', - features: 'https://h3ravel.toneflix.net/features', - } - }) +Route.get('/app', async function () { + return await view('index', { + links: { + documentation: 'https://h3ravel.toneflix.net/docs', + performance: 'https://h3ravel.toneflix.net/performance', + integration: 'https://h3ravel.toneflix.net/h3-integration', + features: 'https://h3ravel.toneflix.net/features', + } }) +}) + +Route.get('/form', async function () { + console.log(session('_errors')) + return await view('test.form') +}) - Route.get('/form', async function () { - console.log(session('_errors')) - return await view('test.form') +Route.put('/validation', async ({ request, response }: HttpContext) => { + const data = await request.validate({ + name: ['required', 'string'], + age: ['required', 'integer'], }) - Route.put('/validation', async ({ request, response }: HttpContext) => { - const data = await request.validate({ - name: ['required', 'string'], - age: ['required', 'integer'], + return response + .setStatusCode(202) + .json({ + message: `User ${data.name} created`, + data, }) +}) - return response - .setStatusCode(202) - .json({ - message: `User ${data.name} created`, - data, - }) - }) -} + +// console.log(Route.getRoutes())//.getRoutesByMethod()['GET']) \ No newline at end of file diff --git a/examples/basic-app/tsdown.default.config.ts b/examples/basic-app/tsdown.default.config.ts index 22402a27..d8999271 100644 --- a/examples/basic-app/tsdown.default.config.ts +++ b/examples/basic-app/tsdown.default.config.ts @@ -1,3 +1,3 @@ -import { TsDownConfig } from '@h3ravel/console' +import { TsDownConfig } from '@h3ravel/foundation' export default TsDownConfig diff --git a/packages/cache/package.json b/packages/cache/package.json index 161cf99e..28156673 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -57,7 +57,7 @@ "version-patch": "pnpm version patch" }, "peerDependencies": { - "@h3ravel/foundation": "workspace:^" + "@h3ravel/support": "workspace:^" }, "devDependencies": { "typescript": "^5.4.0" diff --git a/packages/cache/src/Providers/CacheServiceProvider.ts b/packages/cache/src/Providers/CacheServiceProvider.ts index 2d2f6c5a..a1fe123d 100644 --- a/packages/cache/src/Providers/CacheServiceProvider.ts +++ b/packages/cache/src/Providers/CacheServiceProvider.ts @@ -1,4 +1,4 @@ -import { ServiceProvider } from '@h3ravel/foundation' +import { ServiceProvider } from '@h3ravel/support' /** * Cache drivers and utilities. diff --git a/packages/config/src/Providers/ConfigServiceProvider.ts b/packages/config/src/Providers/ConfigServiceProvider.ts index 08e6c3b1..b0496c35 100644 --- a/packages/config/src/Providers/ConfigServiceProvider.ts +++ b/packages/config/src/Providers/ConfigServiceProvider.ts @@ -4,7 +4,7 @@ import { ConfigRepository, EnvLoader } from '..' import { Bindings } from '@h3ravel/contracts' import { ConfigPublishCommand } from '../Commands/ConfigPublishCommand' -import { ServiceProvider } from '@h3ravel/foundation' +import { ServiceProvider } from '@h3ravel/support' /** * Loads configuration and environment files. diff --git a/packages/console/package.json b/packages/console/package.json index 095a9157..580edacd 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -71,19 +71,21 @@ "@h3ravel/support": "workspace:^" }, "devDependencies": { + "@h3ravel/contracts": "workspace:^", "typescript": "^5.9.2" }, "dependencies": { + "@h3ravel/musket": "catalog:prod", "@h3ravel/shared": "workspace:^", + "@h3ravel/foundation": "workspace:^", "chalk": "^5.6.2", "commander": "^14.0.1", "dayjs": "catalog:", + "dotenv": "catalog:", "execa": "catalog:", "preferred-pm": "catalog:", "radashi": "^12.6.2", "resolve-from": "catalog:", - "dotenv": "catalog:", - "@h3ravel/musket": "catalog:prod", "tsx": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/console/src/IO/app.ts b/packages/console/src/IO/app.ts index 5623f8c0..57b11c89 100644 --- a/packages/console/src/IO/app.ts +++ b/packages/console/src/IO/app.ts @@ -1,16 +1,16 @@ -import { Application, ServiceProvider } from '@h3ravel/core' +import { ConcreteConstructor, IApplication } from '@h3ravel/contracts' -import { ConsoleServiceProvider } from '..' +import { Application } from '@h3ravel/core' +import { ServiceProvider } from '@h3ravel/support' import path from 'node:path' type AServiceProvider = (new (_app: Application) => ServiceProvider) & Partial -export default class { - async fire () { - +export default class Console { + async app () { const DIST_DIR = process.env.DIST_DIR ?? '/.h3ravel/serve/' - const providers: AServiceProvider[] = [] - const app = new Application(process.cwd()) + const providers: ConcreteConstructor[] = [] + const app = new Application(process.cwd(), 'Console') /** * Load Service Providers already registered by the app @@ -20,10 +20,17 @@ export default class { providers.push(...(await import(app_providers)).default) } catch { /** */ } - /** Add the ConsoleServiceProvider */ - providers.push(ConsoleServiceProvider) + /** + * Iniitilize the app + */ + const bootstrapFile = base_path(path.join(DIST_DIR, 'bootstrap/app.js')) + const { default: bootstrap } = await import(bootstrapFile) + new bootstrap().configure(app) /** Register all the Service Providers */ - await app.quickStartup(providers, ['CoreServiceProvider']) + app.initialize(providers, ['CoreServiceProvider']) + .logging(false) + .singleton(IApplication, () => app) + await app.handleCommand() } } diff --git a/packages/console/src/Providers/ConsoleServiceProvider.ts b/packages/console/src/Providers/ConsoleServiceProvider.ts deleted file mode 100644 index c0f45891..00000000 --- a/packages/console/src/Providers/ConsoleServiceProvider.ts +++ /dev/null @@ -1,52 +0,0 @@ -/// - -import { ContainerResolver, ServiceProvider } from '@h3ravel/core' - -import { BuildCommand } from '../Commands/BuildCommand' -import { Kernel } from '@h3ravel/musket' -import { KeyGenerateCommand } from '../Commands/KeyGenerateCommand' -import { MakeCommand } from '../Commands/MakeCommand' -import { PostinstallCommand } from '../Commands/PostinstallCommand' -import { altLogo } from '../logo' -import tsDownConfig from '../TsdownConfig' - -/** - * Handles CLI commands and tooling. - * - * Auto-Registered when in CLI mode - */ -export class ConsoleServiceProvider extends ServiceProvider { - public static priority = 992 - - /** - * Indicate that this service provider only runs in console - */ - public static runsInConsole = true - public runsInConsole = true - - register () { - const DIST_DIR = `/${env('DIST_DIR', '.h3ravel/serve')}/`.replaceAll('//', '') - const baseCommands = [BuildCommand, MakeCommand, PostinstallCommand, KeyGenerateCommand] - - Kernel.init( - this.app, - { - logo: altLogo, - resolver: new ContainerResolver(this.app).resolveMethodParams, - tsDownConfig, - baseCommands, - packages: [ - { name: '@h3ravel/core', alias: 'H3ravel Framework' }, - { name: '@h3ravel/musket', alias: 'Musket CLI' } - ], - cliName: 'musket', - hideMusketInfo: true, - discoveryPaths: [app_path('Console/Commands/*.js').replace('/src/', DIST_DIR)], - } - ); - - ['SIGINT', 'SIGTERM', 'SIGTSTP'].forEach(sig => process.on(sig, () => { - process.exit(0) - })) - } -} diff --git a/packages/console/src/fire.ts b/packages/console/src/fire.ts index 4b32e29c..f0a6ddba 100644 --- a/packages/console/src/fire.ts +++ b/packages/console/src/fire.ts @@ -4,4 +4,4 @@ import 'tsx/esm' import musket from './IO/app' -new musket().fire() +new musket().app() diff --git a/packages/console/src/index.ts b/packages/console/src/index.ts index 80411e43..e70955d9 100644 --- a/packages/console/src/index.ts +++ b/packages/console/src/index.ts @@ -4,5 +4,3 @@ export * from './Commands/MakeCommand' export * from './Commands/PostinstallCommand' export * from './IO/app' export * from './IO/zero' -export * from './Providers/ConsoleServiceProvider' -export * from './TsdownConfig' diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 2ab70973..f349f67c 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -62,7 +62,8 @@ }, "devDependencies": { "edge.js": "catalog:", - "simple-body-validator": "catalog:" + "simple-body-validator": "catalog:", + "@h3ravel/musket": "catalog:prod" }, "dependencies": { "h3": "catalog:prod" diff --git a/packages/contracts/src/Configuration/IAppBuilder.ts b/packages/contracts/src/Configuration/IAppBuilder.ts new file mode 100644 index 00000000..0aef8087 --- /dev/null +++ b/packages/contracts/src/Configuration/IAppBuilder.ts @@ -0,0 +1,52 @@ +import { CallableConstructor } from '../Utilities/Utilities' + +export abstract class IAppBuilder { + /** + * Register the base kernel classes for the application. + */ + abstract withKernels (): this; + + /** + * Register and wire up the application's exception handling layer. + * + * @param using + **/ + abstract withExceptions (using: (exceptions: any) => void): this; + + /** + * Register and wire up the application's middleware handling layer. + * + * @param using + **/ + abstract withMiddleware (callback?: (mw: any) => void): this; + + /** + * Register the routing services for the application. + */ + abstract withRouting ({ + using, + web, + api, + commands, + health, + channels, + pages, + apiPrefix, + then + }?: { + using?: CallableConstructor; + web?: string | string[]; + api?: string | string[]; + commands?: string; + health?: string; + channels?: string; + pages?: string; + apiPrefix?: string; + then?: CallableConstructor; + }): this; + + /** + * create + */ + abstract create (): void; +} \ No newline at end of file diff --git a/packages/contracts/src/Core/IApplication.ts b/packages/contracts/src/Core/IApplication.ts index 7323f312..173d3295 100644 --- a/packages/contracts/src/Core/IApplication.ts +++ b/packages/contracts/src/Core/IApplication.ts @@ -1,6 +1,8 @@ import type { ConcreteConstructor, IPathName } from '../Utilities/Utilities' import type { H3, H3Event } from 'h3' +import { IAppBuilder } from '../Configuration/IAppBuilder' +import { IBootstraper } from '../Foundation/IBootstraper' import { IContainer } from './IContainer' import type { IHttpContext } from '../Http/IHttpContext' import type { IServiceProvider } from './IServiceProvider' @@ -28,7 +30,7 @@ export abstract class IApplication extends IContainer { * * @returns */ - abstract quickStartup (providers: Array>, filtered?: string[], autoRegisterProviders?: boolean): Promise; + abstract initialize (providers: Array>, filtered?: string[], autoRegisterProviders?: boolean): this; /** * Dynamically register all configured providers * @@ -41,7 +43,7 @@ export abstract class IApplication extends IContainer { * @param providers * @param filtered */ - abstract registerProviders (providers: Array>, filtered?: string[]): void; + abstract registerProviders (providers: Array>, filtered?: string[]): void; /** * Register a provider */ @@ -70,12 +72,18 @@ export abstract class IApplication extends IContainer { * Boot all service providers after registration */ abstract boot (): Promise; + /** + * Register a new boot listener. + * + * @param callable $callback + */ + abstract booting (callback: (app: this) => void): void /** * Register a new "booted" listener. * * @param callback */ - abstract booted (callback: (...args: any[]) => void): void + abstract booted (callback: (app: this) => void): void /** * Handle the incoming HTTP request and send the response to the browser. * @@ -100,7 +108,7 @@ export abstract class IApplication extends IContainer { /** * Provide safe overides for the app */ - abstract configure (): any; + abstract configure (): IAppBuilder; /** * Check if the current application environment matches the one provided * @@ -127,6 +135,16 @@ export abstract class IApplication extends IContainer { * @param preferedPort If provided, this will overide the port set in the evironment */ abstract serve (h3App?: H3, preferredPort?: number): Promise; + /** + * Run the given array of bootstrap classes. + * + * @param bootstrappers + */ + abstract bootstrapWith (bootstrappers: ConcreteConstructor[]): void | Promise + /** + * Determine if the application has been bootstrapped before. + */ + abstract hasBeenBootstrapped (): boolean /** * Save the curretn H3 instance for possible future use. * diff --git a/packages/contracts/src/Core/IContainer.ts b/packages/contracts/src/Core/IContainer.ts index e83f2904..93b30bec 100644 --- a/packages/contracts/src/Core/IContainer.ts +++ b/packages/contracts/src/Core/IContainer.ts @@ -1,4 +1,4 @@ -import type { Bindings, UseKey } from '../Utilities/BindingsContract' +import type { Bindings, IBinding, UseKey } from '../Utilities/BindingsContract' import type { IMiddlewareHandler } from '../Routing/IMiddlewareHandler' import { ClassConstructor, CallableConstructor, ExtractClassMethods, ConcreteConstructor } from '../Utilities/Utilities' import { IMiddleware } from '../Routing/IMiddleware' @@ -114,7 +114,7 @@ export abstract class IContainer { * * @param name */ - abstract isAlias (name: string): boolean + abstract isAlias (name: IBinding): boolean /** * Get the alias for an abstract if available. @@ -152,6 +152,13 @@ export abstract class IContainer { abstract has any> (key: C): boolean; abstract has any> (key: F): boolean; + /** + * Determine if the given abstract type has been resolved. + * + * @param abstract + */ + abstract resolved (abstract: IBinding | string): boolean + /** * Register an existing instance as shared in the container. * diff --git a/packages/contracts/src/Core/IController.ts b/packages/contracts/src/Core/IController.ts index 915d331f..4c88801c 100644 --- a/packages/contracts/src/Core/IController.ts +++ b/packages/contracts/src/Core/IController.ts @@ -1,21 +1,24 @@ -import { ControllerMethod } from '../Utilities/Utilities' +import { IApplication } from './IApplication' import { IMiddleware } from '../Routing/IMiddleware' +import { ResourceMethod } from '../Utilities/Utilities' /** * Defines the contract for all controllers. */ export abstract class IController { show?(...ctx: any[]): any + edit?(...ctx: any[]): any index?(...ctx: any[]): any store?(...ctx: any[]): any + create?(...ctx: any[]): any update?(...ctx: any[]): any destroy?(...ctx: any[]): any __invoke?(...ctx: any[]): any - callAction (method: ControllerMethod, parameters: any[]): any { + callAction?(method: ResourceMethod, parameters: any[]): any { void parameters void method } - getMiddleware (): IMiddleware { + getMiddleware?(): IMiddleware { return {} as IMiddleware } } \ No newline at end of file diff --git a/packages/contracts/src/Core/IServiceProvider.ts b/packages/contracts/src/Core/IServiceProvider.ts index f495e45c..e4fb51a1 100644 --- a/packages/contracts/src/Core/IServiceProvider.ts +++ b/packages/contracts/src/Core/IServiceProvider.ts @@ -22,7 +22,7 @@ export abstract class IServiceProvider { /** * Indicate that this service provider only runs in console */ - static console: boolean + static console?: boolean /** * Indicate that this service provider only runs in console diff --git a/packages/contracts/src/Exceptions/IExceptionHandler.ts b/packages/contracts/src/Exceptions/IExceptionHandler.ts index 10d4bfae..c4d5910b 100644 --- a/packages/contracts/src/Exceptions/IExceptionHandler.ts +++ b/packages/contracts/src/Exceptions/IExceptionHandler.ts @@ -39,6 +39,13 @@ export abstract class IExceptionHandler { * @returns */ abstract report (error: any): Promise; + + /** + * Render an exception to the console. + * + * @param e + */ + abstract renderForConsole (e: Error): void /** * Render an exception into an HTTP Response. * diff --git a/packages/contracts/src/Foundation/CKernel.ts b/packages/contracts/src/Foundation/CKernel.ts new file mode 100644 index 00000000..1317bbdb --- /dev/null +++ b/packages/contracts/src/Foundation/CKernel.ts @@ -0,0 +1,57 @@ +import type { Command, Kernel as ConsoleKernel } from '@h3ravel/musket' + +import { IApplication } from '../Core/IApplication' + +export abstract class CKernel { + /** + * Run the console application. + */ + abstract handle (): Promise; + + /** + * Register a given command. + * + * @param command + */ + abstract registerCommand (command: any): void; + + /** + * Get all the registered commands. + */ + abstract all (): Promise<{ + new(app: IApplication, kernel: ConsoleKernel): Command; + }[]>; + + /** + * Bootstrap the application for Musket commands. + * + * @return void + */ + abstract bootstrap (): Promise; + + /** + * Set the paths that should have their Musket commands automatically discovered. + * + * @param paths + */ + abstract addCommandPaths (paths: string[]): this; + + /** + * Set the paths that should have their Artisan "routes" automatically discovered. + * + * @param paths + */ + abstract addCommandRoutePaths (paths: string[]): this + + /** + * Get the Musket application instance. + */ + abstract getConsole (): ConsoleKernel; + + /** + * Terminate the app. + * + * @param request + */ + abstract terminate (status: number): void +} \ No newline at end of file diff --git a/packages/contracts/src/Foundation/IBootstraper.ts b/packages/contracts/src/Foundation/IBootstraper.ts new file mode 100644 index 00000000..8bc73757 --- /dev/null +++ b/packages/contracts/src/Foundation/IBootstraper.ts @@ -0,0 +1,8 @@ +import { IApplication } from '@h3ravel/contracts' + +export abstract class IBootstraper { + /** + * Bootstrap the given application. + */ + abstract bootstrap (app: IApplication): void | Promise +} \ No newline at end of file diff --git a/packages/contracts/src/Http/IHttpRequest.ts b/packages/contracts/src/Http/IHttpRequest.ts index f76721ec..5fa7ea22 100644 --- a/packages/contracts/src/Http/IHttpRequest.ts +++ b/packages/contracts/src/Http/IHttpRequest.ts @@ -30,11 +30,11 @@ export abstract class IHttpRequest { /** * Query string parameters (GET). */ - abstract query: InputBag + abstract _query: InputBag /** * Server and execution environment parameters */ - abstract server: IServerBag + abstract _server: IServerBag /** * Cookies */ diff --git a/packages/contracts/src/Http/IRequest.ts b/packages/contracts/src/Http/IRequest.ts index 10608ab7..53122ae7 100644 --- a/packages/contracts/src/Http/IRequest.ts +++ b/packages/contracts/src/Http/IRequest.ts @@ -294,9 +294,25 @@ export abstract class IRequest< */ abstract uri (): unknown; /** - * Get the full URL for the request. + * Get the root URL for the application. + * + * @return string + */ + abstract root (): string + /** + * Get the URL (no query string) for the request. + * + * @return string + */ + abstract url (): string + /** + * Get the full URL for the request. + */ + abstract fullUrl (): string + /** + * Get the current path info for the request. */ - abstract fullUrl (): string; + abstract path (): string /** * Return the Request instance. */ @@ -333,6 +349,57 @@ export abstract class IRequest< * @param callback */ abstract setRouteResolver (callback: () => IRoute): this + /** + * Get the bearer token from the request headers. + */ + abstract bearerToken (): string | undefined + /** + * Retrieve a request payload item from the request. + * + * @param key + * @param default + */ + abstract post (key?: string, defaultValue?: any): any + /** + * Determine if a header is set on the request. + * + * @param key + */ + abstract hasHeader (key: string): boolean + /** + * Retrieve a header from the request. + * + * @param key + * @param default + */ + abstract header (key?: string, defaultValue?: any): any + /** + * Determine if a cookie is set on the request. + * + * @param string $key + */ + abstract hasCookie (key: string): boolean + /** + * Retrieve a cookie from the request. + * + * @param key + * @param default + */ + abstract cookie (key?: string, defaultValue?: any): any + /** + * Retrieve a query string item from the request. + * + * @param key + * @param default + */ + abstract query (key?: string, defaultValue?: any): any + /** + * Retrieve a server variable from the request. + * + * @param key + * @param default + */ + abstract server (key?: string, defaultValue?: any): any /** * Returns the request body content. * diff --git a/packages/contracts/src/Routing/IControllerDispatcher.ts b/packages/contracts/src/Routing/IControllerDispatcher.ts index dc8e1e83..09239ed0 100644 --- a/packages/contracts/src/Routing/IControllerDispatcher.ts +++ b/packages/contracts/src/Routing/IControllerDispatcher.ts @@ -1,4 +1,4 @@ -import { ControllerMethod, RouteMethod } from '../Utilities/Utilities' +import { ResourceMethod, RouteMethod } from '../Utilities/Utilities' import { IController } from '../Core/IController' import { IMiddleware } from './IMiddleware' @@ -12,7 +12,7 @@ export abstract class IControllerDispatcher { * @param controller * @param method */ - abstract dispatch (route: IRoute, controller: IController, method: ControllerMethod): Promise; + abstract dispatch (route: IRoute, controller: IController, method: ResourceMethod): Promise; /** * Get the middleware for the controller instance. diff --git a/packages/contracts/src/Routing/IPendingResourceRegistration.ts b/packages/contracts/src/Routing/IPendingResourceRegistration.ts new file mode 100644 index 00000000..faeb9f9f --- /dev/null +++ b/packages/contracts/src/Routing/IPendingResourceRegistration.ts @@ -0,0 +1,105 @@ +import { MiddlewareIdentifier, MiddlewareList } from '../Foundation/MiddlewareContract' + +import { IRouteCollection } from './IRouteCollection' +import { ResourceMethod } from '../Utilities/Utilities' + +export abstract class IPendingResourceRegistration { + /** + * Set the methods the controller should apply to. + * + * @param methods + */ + abstract only (...methods: ResourceMethod[]): this; + /** + * Set the methods the controller should exclude. + * + * @param methods + */ + abstract except (...methods: ResourceMethod[]): this; + /** + * Set the route names for controller actions. + * + * @param names + */ + abstract names (names: Record): this; + /** + * Set the route name for a controller action. + * + * @param method + * @param name + */ + abstract setName (method: string, name: string): this; + /** + * Override the route parameter names. + * + * @param parameters + */ + abstract parameters (parameters: any): this; + /** + * Override a route parameter's name. + * + * @param previous + * @param newValue + */ + abstract parameter (previous: string, newValue: any): this; + /** + * Add middleware to the resource routes. + * + * @param middleware + */ + abstract middleware (middleware: MiddlewareList | MiddlewareIdentifier): this; + /** + * Specify middleware that should be added to the specified resource routes. + * + * @param methods + * @param middleware + */ + abstract middlewareFor (methods: ResourceMethod[], middleware: MiddlewareList | MiddlewareIdentifier): this; + /** + * Specify middleware that should be removed from the resource routes. + * + * @param middleware + */ + abstract withoutMiddleware (middleware: MiddlewareList | MiddlewareIdentifier): this; + /** + * Specify middleware that should be removed from the specified resource routes. + * + * @param methods + * @param middleware + */ + abstract withoutMiddlewareFor (methods: ResourceMethod[], middleware: MiddlewareList | MiddlewareIdentifier): this; + /** + * Add "where" constraints to the resource routes. + * + * @param wheres + */ + abstract where (wheres: any): this; + /** + * Indicate that the resource routes should have "shallow" nesting. + * + * @param shallow + */ + abstract shallow (shallow?: boolean): this; + /** + * Define the callable that should be invoked on a missing model exception. + * + * @param callback + */ + abstract missing (callback: string): this; + /** + * Indicate that the resource routes should be scoped using the given binding fields. + * + * @param fields + */ + abstract scoped (fields?: string[]): this; + /** + * Define which routes should allow "trashed" models to be retrieved when resolving implicit model bindings. + * + * @param array methods + */ + abstract withTrashed (methods?: never[]): this; + /** + * Register the singleton resource route. + */ + abstract register (): IRouteCollection | undefined; +} \ No newline at end of file diff --git a/packages/contracts/src/Routing/IPendingSingletonResourceRegistration.ts b/packages/contracts/src/Routing/IPendingSingletonResourceRegistration.ts new file mode 100644 index 00000000..3a69b0cb --- /dev/null +++ b/packages/contracts/src/Routing/IPendingSingletonResourceRegistration.ts @@ -0,0 +1,93 @@ +import { MiddlewareIdentifier, MiddlewareList } from '../Foundation/MiddlewareContract' + +import { IAbstractRouteCollection } from './IAbstractRouteCollection' +import { ResourceMethod } from '../Utilities/Utilities' + +export abstract class IPendingSingletonResourceRegistration { + /** + * Set the methods the controller should apply to. + * + * @param methods + */ + abstract only (...methods: ResourceMethod[]): this; + /** + * Set the methods the controller should exclude. + * + * @param methods + */ + abstract except (...methods: ResourceMethod[]): this; + /** + * Indicate that the resource should have creation and storage routes. + * + * @return this + */ + abstract creatable (): this; + /** + * Indicate that the resource should have a deletion route. + * + * @return this + */ + abstract destroyable (): this; + /** + * Set the route names for controller actions. + * + * @param names + */ + abstract names (names: Record): this; + /** + * Set the route name for a controller action. + * + * @param method + * @param name + */ + abstract setName (method: string, name: string): this; + /** + * Override the route parameter names. + * + * @param parameters + */ + abstract parameters (parameters: any): this; + /** + * Override a route parameter's name. + * + * @param previous + * @param newValue + */ + abstract parameter (previous: string, newValue: any): this; + /** + * Add middleware to the resource routes. + * + * @param middleware + */ + abstract middleware (middleware: MiddlewareList | MiddlewareIdentifier): this; + /** + * Specify middleware that should be added to the specified resource routes. + * + * @param methods + * @param middleware + */ + abstract middlewareFor (methods: ResourceMethod[], middleware: MiddlewareList | MiddlewareIdentifier): this; + /** + * Specify middleware that should be removed from the resource routes. + * + * @param middleware + */ + abstract withoutMiddleware (middleware: MiddlewareList | MiddlewareIdentifier): this; + /** + * Specify middleware that should be removed from the specified resource routes. + * + * @param methods + * @param middleware + */ + abstract withoutMiddlewareFor (methods: ResourceMethod[], middleware: MiddlewareList | MiddlewareIdentifier): this; + /** + * Add "where" constraints to the resource routes. + * + * @param wheres + */ + abstract where (wheres: any): this; + /** + * Register the singleton resource route. + */ + abstract register (): IAbstractRouteCollection | undefined; +} \ No newline at end of file diff --git a/packages/contracts/src/Routing/IRoute.ts b/packages/contracts/src/Routing/IRoute.ts index 9ac03c5c..edd62a71 100644 --- a/packages/contracts/src/Routing/IRoute.ts +++ b/packages/contracts/src/Routing/IRoute.ts @@ -1,4 +1,4 @@ -import type { CallableConstructor, RouteActions, RouteMethod } from '../Utilities/Utilities' +import type { CallableConstructor, GenericObject, RouteActions, RouteMethod } from '../Utilities/Utilities' import type { ICompiledRoute } from './ICompiledRoute' import type { IContainer } from '../Core/IContainer' @@ -124,10 +124,10 @@ export abstract class IRoute { /** * Get or set the middlewares attached to the route. * - * @param array|string|null $middleware - * @return $this|array + * @param middleware */ - abstract middleware (middleware?: string | string[]): any[] | this; + abstract middleware (): any[]; + abstract middleware (middleware?: string | string[]): this; /** * Specify that the "Authorize" / "can" middleware should be applied to the route with the given options. * @@ -184,6 +184,23 @@ export abstract class IRoute { * Get the compiled version of the route. */ abstract getCompiled (): ICompiledRoute | undefined; + + /** + * Get the binding field for the given parameter. + * + * @param parameter + */ + abstract bindingFieldFor (parameter: string | number): string | undefined + /** + * Get the binding fields for the route. + */ + abstract getBindingFields (): GenericObject + /** + * Set the binding fields for the route. + * + * @param bindingFields + */ + abstract setBindingFields (bindingFields: GenericObject): this /** * Set a default value for the route. * diff --git a/packages/contracts/src/Routing/IRouteCollection.ts b/packages/contracts/src/Routing/IRouteCollection.ts index 48a4914e..71560a78 100644 --- a/packages/contracts/src/Routing/IRouteCollection.ts +++ b/packages/contracts/src/Routing/IRouteCollection.ts @@ -26,9 +26,13 @@ export declare class IRouteCollection extends IAbstractRouteCollection { */ match (request: IRequest): IRoute; /** + * * Get routes from the collection by method. + * + * @param method */ - get (method?: string): Record | IRoute[]; + public get (): IRoute[] + public get (method: string): Record /** * Determine if the route collection contains a given named route. */ @@ -36,11 +40,11 @@ export declare class IRouteCollection extends IAbstractRouteCollection { /** * Get a route instance by its name. */ - getByName (name: string): IRoute | null; + getByName (name: string): IRoute | undefined; /** * Get a route instance by its controller action. */ - getByAction (action: string): IRoute | null; + getByAction (action: string): IRoute | undefined; /** * Get all of the routes in the collection. */ diff --git a/packages/contracts/src/Routing/IRouteRegistrar.ts b/packages/contracts/src/Routing/IRouteRegistrar.ts new file mode 100644 index 00000000..3bc12460 --- /dev/null +++ b/packages/contracts/src/Routing/IRouteRegistrar.ts @@ -0,0 +1,19 @@ +import { CallableConstructor, ResourceOptions, RouteActions, RouteMethod } from '../Utilities/Utilities' + +import { IController } from '../Core/IController' +import { IPendingResourceRegistration } from './IPendingResourceRegistration' +import { IPendingSingletonResourceRegistration } from './IPendingSingletonResourceRegistration' +import { IRoute } from './IRoute' + +export abstract class IRouteRegistrar { + abstract attribute (key: string, value: any): this; + abstract resource (name: string, controller: C, options?: ResourceOptions): IPendingResourceRegistration; + abstract apiResource (name: string, controller: C, options?: ResourceOptions): IPendingResourceRegistration; + abstract singleton (name: string, controller: C, options?: ResourceOptions): IPendingSingletonResourceRegistration; + abstract apiSingleton (name: string, controller: C, options?: ResourceOptions): IPendingSingletonResourceRegistration; + abstract group (callback: CallableConstructor | any[] | string): this; + abstract match (methods: RouteMethod | RouteMethod[], uri: string, action?: RouteActions): IRoute; + abstract middleware (): any[]; + abstract middleware (middleware?: string | string[]): this; + abstract prefix (prefix: string): this; +} \ No newline at end of file diff --git a/packages/contracts/src/Routing/IRouter.ts b/packages/contracts/src/Routing/IRouter.ts index 26bc9481..1fd9a914 100644 --- a/packages/contracts/src/Routing/IRouter.ts +++ b/packages/contracts/src/Routing/IRouter.ts @@ -1,17 +1,26 @@ +import type { ActionInput, GenericObject, ResourceOptions, RouteActions, RouteMethod } from '../Utilities/Utilities' import type { Middleware, MiddlewareOptions } from 'h3' -import type { IRoute } from './IRoute' -import type { IRouteCollection } from './IRouteCollection' + import type { IController } from '../Core/IController' import type { IMiddleware } from './IMiddleware' -import type { ActionInput, RouteEventHandler, RouteActions, RouteMethod, ExtractClassMethods, RouterEnd } from '../Utilities/Utilities' +import { IPendingResourceRegistration } from './IPendingResourceRegistration' +import { IPendingSingletonResourceRegistration } from './IPendingSingletonResourceRegistration' import { IRequest } from '../Http/IRequest' -import { MiddlewareList } from '../Foundation/MiddlewareContract' import { IResponse } from '../Http/IResponse' +import type { IRoute } from './IRoute' +import type { IRouteCollection } from './IRouteCollection' +import { MiddlewareList } from '../Foundation/MiddlewareContract' /** * Interface for the Router contract, defining methods for HTTP routing. */ export abstract class IRouter { + /** + * The priority-sorted list of middleware. + * + * Forces the listed middleware to always be in the given order. + */ + public abstract middlewarePriority: MiddlewareList /** * All of the verbs supported by the router. */ @@ -68,112 +77,110 @@ export abstract class IRouter { abstract dispatchToRoute (request: IRequest): Promise; /** * Registers a route that responds to HTTP GET requests. - * - * @param path The URL pattern to match (can include parameters, e.g., '/users/:id'). - * @param definition Either: - * - An EventHandler function - * - A tuple: [ControllerClass, methodName] - * @param name Optional route name (for URL generation or referencing). - * @param middleware Optional array of middleware functions to execute before the handler. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns */ - abstract get any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], - name?: string, - middleware?: IMiddleware[] - ): Omit; + abstract get (uri: string, action: ActionInput): IRoute /** * Registers a route that responds to HTTP POST requests. - * - * @param path The URL pattern to match (can include parameters, e.g., '/users'). - * @param definition Either: - * - An EventHandler function - * - A tuple: [ControllerClass, methodName] - * @param name Optional route name (for URL generation or referencing). - * @param middleware Optional array of middleware functions to execute before the handler. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns */ - abstract post any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], - name?: string, middleware?: IMiddleware[] - ): Omit; + abstract post (uri: string, action: ActionInput): IRoute /** * Registers a route that responds to HTTP PUT requests. - * - * @param path The URL pattern to match (can include parameters, e.g., '/users/:id'). - * @param definition Either: - * - An EventHandler function - * - A tuple: [ControllerClass, methodName] - * @param name Optional route name (for URL generation or referencing). - * @param middleware Optional array of middleware functions to execute before the handler. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns */ - abstract put any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], - name?: string, - middleware?: IMiddleware[] - ): Omit; + abstract put (uri: string, action: ActionInput): IRoute /** * Registers a route that responds to HTTP PATCH requests. - * - * @param path The URL pattern to match (can include parameters, e.g., '/users/:id'). - * @param definition Either: - * - An EventHandler function - * - A tuple: [ControllerClass, methodName] - * @param name Optional route name (for URL generation or referencing). - * @param middleware Optional array of middleware functions to execute before the handler. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns */ - abstract patch any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], - name?: string, - middleware?: IMiddleware[] - ): Omit; + abstract patch (uri: string, action: ActionInput): IRoute /** * Registers a route that responds to HTTP DELETE requests. - * - * @param path The URL pattern to match (can include parameters, e.g., '/users/:id'). - * @param definition Either: - * - An EventHandler function - * - A tuple: [ControllerClass, methodName] - * @param name Optional route name (for URL generation or referencing). - * @param middleware Optional array of middleware functions to execute before the handler. - */ - abstract delete any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], - name?: string, - middleware?: IMiddleware[] - ): Omit; - /** - * API Resource support - * - * @param path - * @param controller + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns */ - abstract apiResource any> ( - path: string, - Controller: C, middleware?: IMiddleware[] - ): Omit; + abstract delete (uri: string, action: ActionInput): IRoute /** * Registers a route the matches the provided methods. + * * @param methods - The route methods to match. * @param uri - The route uri. * @param action - The handler function or [controller class, method] array. */ abstract match ( - methods: Lowercase[], + methods: RouteMethod | RouteMethod[], uri: string, action: ActionInput ): IRoute; /** - * Named route URL generator + * Route a resource to a controller. * - * @param name - * @param params - * @returns + * @param name + * @param controller + * @param options + */ + abstract resource (name: string, controller: C, options: ResourceOptions): IPendingResourceRegistration + /** + * Register an array of API resource controllers. + * + * @param resources + * @param options */ - abstract route (name: string, params?: Record): string | undefined; + abstract apiResources (resources: GenericObject, options: ResourceOptions): void + /** + * API Resource support + * + * @param path + * @param controller + */ + abstract apiResource (name: string, controller: C, options: ResourceOptions): IPendingResourceRegistration + + /** + * Register an array of singleton resource controllers. + * + * @param singletons + * @param options + */ + abstract singletons (singletons: GenericObject, options: ResourceOptions): void + /** + * Route a singleton resource to a controller. + * + * @param name + * @param controller + * @param options + */ + abstract singleton (name: string, controller: C, options: ResourceOptions): IPendingSingletonResourceRegistration + + /** + * Register an array of API singleton resource controllers. + * + * @param singletons + * @param options + */ + abstract apiSingletons (singletons: GenericObject, options: ResourceOptions): void + /** + * Route an API singleton resource to a controller. + * + * @param name + * @param controller + * @param options + */ + abstract apiSingleton (name: string, controller: C, options: ResourceOptions): IPendingSingletonResourceRegistration /** * Grouping * @@ -214,12 +221,35 @@ export abstract class IRouter { * @param handler - The middleware handler. * @param opts - Optional middleware options. */ - abstract middleware ( + abstract h3middleware ( path: string | IMiddleware[] | Middleware, - handler: Middleware | MiddlewareOptions, + handler?: Middleware | MiddlewareOptions, opts?: MiddlewareOptions ): this; - + /** + * Get all of the defined middleware short-hand names. + */ + abstract getMiddleware (): GenericObject + /** + * Register a short-hand name for a middleware. + * + * @param name + * @param class + */ + abstract aliasMiddleware (name: string, cls: IMiddleware): this + /** + * Gather the middleware for the given route with resolved class names. + * + * @param route + */ + abstract gatherRouteMiddleware (route: IRoute): any + /** + * Resolve a flat array of middleware classes from the provided array. + * + * @param middleware + * @param excluded + */ + abstract resolveMiddleware (middleware: IMiddleware[], excluded: IMiddleware[]): any /** * Register a group of middleware. * diff --git a/packages/contracts/src/Utilities/BindingsContract.ts b/packages/contracts/src/Utilities/BindingsContract.ts index fc9621af..94a73899 100644 --- a/packages/contracts/src/Utilities/BindingsContract.ts +++ b/packages/contracts/src/Utilities/BindingsContract.ts @@ -2,6 +2,7 @@ import type { H3, serve } from 'h3' import { IResponsable, IResponse } from '../Http/IResponse' import type { Edge } from 'edge.js' +import { IDispatcher } from '../Events/IDispatcher' import { IHttpContext } from '../Http/IHttpContext' import { IRequest } from '../Http/IRequest' import { IRouter } from '../Routing/IRouter' @@ -19,19 +20,21 @@ export type Bindings = { [key: string]: any; [key: `app.${string}`]: any; [key: `middleware.${string}`]: any; + db: any env (): NodeJS.ProcessEnv env (key: T, def?: any): any view (viewPath: string, params?: Record): Promise edge: Edge; asset (key: string, def?: string): string router: IRouter + events: IDispatcher config: { get> (): X get, T extends Extract> (key: T, def?: any): X[T] set (key: T, value: any): void load?(): any } - 'db': any + 'app.events': IDispatcher 'http.app': H3 'path.base': string 'load.paths': PathLoader @@ -42,3 +45,5 @@ export type Bindings = { } export type UseKey = Record> = keyof RemoveIndexSignature + +export type IBinding = UseKey | (new (...args: any[]) => unknown) \ No newline at end of file diff --git a/packages/contracts/src/Utilities/Utilities.ts b/packages/contracts/src/Utilities/Utilities.ts index 8a2ad024..e3819f6d 100644 --- a/packages/contracts/src/Utilities/Utilities.ts +++ b/packages/contracts/src/Utilities/Utilities.ts @@ -4,12 +4,12 @@ import { MiddlewareList } from '../Foundation/MiddlewareContract' import type { IHttpContext } from '../Http/IHttpContext' -export type IPathName = 'views' | 'routes' | 'assets' | 'base' | 'public' | 'storage' | 'config' | 'database' -export type RouterEnd = 'get' | 'delete' | 'put' | 'post' | 'patch' | 'apiResource' | 'group' | 'route'; +export type IPathName = 'views' | 'routes' | 'assets' | 'base' | 'public' | 'storage' | 'config' | 'database' | 'commands' +export type RouterEnd = 'get' | 'delete' | 'put' | 'post' | 'patch' | 'apiResource' | 'group' | 'route' | 'any'; export type RouteMethod = 'GET' | 'HEAD' | 'PUT' | 'PATCH' | 'POST' | 'DELETE' | 'OPTIONS'; export type RequestMethod = 'HEAD' | 'GET' | 'PUT' | 'DELETE' | 'TRACE' | 'OPTIONS' | 'PURGE' | 'POST' | 'CONNECT' | 'PATCH'; -export type ControllerMethod = 'index' | 'show' | 'update' | 'destroy'; -export type GenericObject = Record; +export type ResourceMethod = 'index' | 'create' | 'store' | 'show' | 'edit' | 'update' | 'destroy' +export type GenericObject = Record; export type RequestObject = Record; export type ResponseObject = Record; @@ -22,14 +22,16 @@ export type ExtractClassMethods = { */ export type EventHandler = (ctx: IHttpContext) => any +export type TGeneric = Record export type ClassConstructor = abstract new (...args: any[]) => T +export type MixinConstructor = ClassConstructor export type RouteEventHandler = (ctx: IHttpContext, ...args: any[]) => any export type MergedConstructor = (new (...args: any[]) => T) & Record -export type AbstractConstructor = (abstract new (...args: any[]) => T) & Record +export type AbstractConstructor = ClassConstructor & Record export type CallableConstructor = (...args: Y[]) => X export type AppEvent = CallableConstructor export type AppListener = CallableConstructor -export type ConcreteConstructor = new (...args: any[]) => Required +export type ConcreteConstructor = new (...args: any[]) => RA extends true ? Required : T export interface RouteActions { [key: string]: any @@ -41,13 +43,36 @@ export interface RouteActions { as?: string name?: string controller?: RouteEventHandler | IController | string - missing?: CallableConstructor + missing?: any uses?: any http?: boolean https?: boolean middleware?: MiddlewareList namespace?: string excluded_middleware?: any + scopeBindings?: any + withoutMiddleware?: any + withoutScopedBindings?: any +} + +export interface ResourceOptions { + as?: string + missing?: string + prefix?: string + names?: Record + middleware?: MiddlewareList + shallow?: any + only?: ResourceMethod[] + except?: ResourceMethod[] + parameters?: any + wheres?: any + trashed?: ResourceMethod[] + creatable?: any + destroyable?: any + bindingFields?: string[] + middleware_for?: GenericObject + excluded_middleware?: MiddlewareList + excluded_middleware_for?: GenericObject } export interface ClassicRouteDefinition { @@ -77,10 +102,9 @@ export interface NormalizedAction { methodName?: string } -export type ServiceProviderConstructor = (new (app: any) => IServiceProvider) & IServiceProvider; - export type AServiceProvider = (new (app: any) => IServiceProvider) & Partial export type OServiceProvider = (new (app: any) => Partial) & Partial +export type ServiceProviderConstructor = (new (app: any) => IServiceProvider) & IServiceProvider; export type ListenerClassConstructor = (new (...args: any) => any) & { subscribe?(...args: any[]): any }; \ No newline at end of file diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8bdc3211..545abe7b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,3 +1,4 @@ +export * from './Configuration/IAppBuilder' export * from './Core/IApplication' export * from './Core/IContainer' export * from './Core/IController' @@ -5,6 +6,8 @@ export * from './Core/IRegisterer' export * from './Core/IServiceProvider' export * from './Events/IDispatcher' export * from './Exceptions/IExceptionHandler' +export * from './Foundation/CKernel' +export * from './Foundation/IBootstraper' export * from './Foundation/IKernel' export * from './Foundation/MiddlewareContract' export * from './Foundation/RateLimiterAdapter' @@ -29,9 +32,12 @@ export * from './Routing/ICompiledRoute' export * from './Routing/IControllerDispatcher' export * from './Routing/IMiddleware' export * from './Routing/IMiddlewareHandler' +export * from './Routing/IPendingResourceRegistration' +export * from './Routing/IPendingSingletonResourceRegistration' export * from './Routing/IRoute' export * from './Routing/IRouteCollection' export * from './Routing/IRouter' +export * from './Routing/IRouteRegistrar' export * from './Session/FlashBag' export * from './Session/ISessionManager' export * from './Session/SessionContract' diff --git a/packages/core/src/Application.ts b/packages/core/src/Application.ts index 0f6b6af0..5b4de670 100644 --- a/packages/core/src/Application.ts +++ b/packages/core/src/Application.ts @@ -1,9 +1,9 @@ import 'reflect-metadata' import { FileSystem, Logger, PathLoader } from '@h3ravel/shared' -import { type H3, type H3Event } from 'h3' +import { H3, serve, type H3Event } from 'h3' -import { ConcreteConstructor, IKernel, IUrl, type IApplication, type IHttpContext, type IPathName, type IServiceProvider } from '@h3ravel/contracts' +import { CKernel, ConcreteConstructor, IBootstraper, IKernel, IUrl, type IApplication, type IHttpContext, type IPathName, type IServiceProvider } from '@h3ravel/contracts' import { InvalidArgumentException, Str } from '@h3ravel/support' import { AppBuilder, ConfigException } from '@h3ravel/foundation' @@ -17,15 +17,16 @@ import dotenvExpand from 'dotenv-expand' import path from 'node:path' import { readFile } from 'node:fs/promises' import semver from 'semver' +import { CoreServiceProvider } from './Providers/CoreServiceProvider' export class Application extends Container implements IApplication { /** * Indicates if the application has "booted". */ #booted = false - public paths = new PathLoader() - public context?: (event: H3Event) => Promise - public h3Event?: H3Event + paths = new PathLoader() + context?: (event: H3Event) => Promise + h3Event?: H3Event private tries: number = 0 private basePath: string private versions: { [key: string]: string, app: string, ts: string } = { app: '0.0.0', ts: '0.0.0' } @@ -33,8 +34,9 @@ export class Application extends Container implements IApplication { private h3App?: H3 private providers: Array = [] - protected externalProviders: Array> = [] + protected externalProviders: Array> = [] protected filteredProviders: Array = [] + private autoRegisterProviders: boolean = false /** * The route resolver callback. @@ -44,14 +46,29 @@ export class Application extends Container implements IApplication { /** * List of registered console commands */ - public registeredCommands: (new (app: any, kernel: any) => any)[] = [] + registeredCommands: (new (app: any, kernel: any) => any)[] = [] /** * The array of booted callbacks. */ - protected bootedCallbacks: Array<(...args: any[]) => void> = [] + protected bootedCallbacks: Array<(app: this) => void> = [] - constructor(basePath: string) { + /** + * The array of booting callbacks. + */ + protected bootingCallbacks: Array<(app: this) => void> = [] + + /** + * Indicates if the application has been bootstrapped before. + */ + protected bootstrapped = false + + /** + * Controls logging + */ + private logsDisabled = false + + constructor(basePath: string, protected initializer?: string) { super() dotenvExpand.expand(dotenv.config({ quiet: true })) @@ -102,7 +119,7 @@ export class Application extends Container implements IApplication { /** * Get all registered providers */ - public getRegisteredProviders (): IServiceProvider[] { + getRegisteredProviders (): IServiceProvider[] { return this.providers } @@ -114,13 +131,13 @@ export class Application extends Container implements IApplication { * Minimal App: Loads only core, config, http, router by default. * Full-Stack App: Installs database, mail, queue, cache → they self-register via their providers. */ - protected async getConfiguredProviders (): Promise[]> { + protected async getConfiguredProviders (): Promise[]> { return [ - (await import('@h3ravel/core')).CoreServiceProvider as never, + CoreServiceProvider ] } - protected async getAllProviders (): Promise>> { + protected async getAllProviders (): Promise>> { const coreProviders = await this.getConfiguredProviders() return [...coreProviders, ...this.externalProviders] } @@ -134,26 +151,35 @@ export class Application extends Container implements IApplication { * * @returns */ - public async quickStartup (providers: Array>, filtered: string[] = [], autoRegisterProviders = true) { + initialize (providers: Array>, filtered: string[] = [], autoRegisterProviders = true) { + /** + * Bind HTTP APP to the service container + */ + this.singleton('http.app', () => { + return new H3() + }) + + /** + * Bind the HTTP server to the service container + */ + this.singleton('http.serve', () => serve) + this.registerProviders(providers, filtered) - await this.registerConfiguredProviders(autoRegisterProviders) - return this.boot() + this.autoRegisterProviders = autoRegisterProviders + return this } /** * Dynamically register all configured providers - * - * @param autoRegister If set to false, service providers will not be auto discovered and registered. */ - public async registerConfiguredProviders (autoRegister = true) { + async registerConfiguredProviders () { const providers = await this.getAllProviders() - ProviderRegistry.setSortable(false) ProviderRegistry.setFiltered(this.filteredProviders) ProviderRegistry.registerMany(providers) - if (autoRegister) { - await ProviderRegistry.discoverProviders(autoRegister) + if (this.autoRegisterProviders) { + await ProviderRegistry.discoverProviders(this.autoRegisterProviders) } ProviderRegistry.doSort() @@ -170,15 +196,15 @@ export class Application extends Container implements IApplication { * @param providers * @param filtered */ - registerProviders (providers: Array>, filtered: string[] = []): void { + registerProviders (providers: Array>, filtered: string[] = []): void { this.externalProviders.push(...providers) - this.filteredProviders = filtered + this.filteredProviders = Array.from(new Set(this.filteredProviders.concat(filtered))) } /** * Register a provider */ - public async register (provider: IServiceProvider) { + async register (provider: IServiceProvider) { await new ContainerResolver(this).resolveMethodParams(provider, 'register', this) if (provider.registeredCommands && provider.registeredCommands.length > 0) { this.registeredCommands.push(...provider.registeredCommands) @@ -191,7 +217,7 @@ export class Application extends Container implements IApplication { * * @param commands An array of console commands to register. */ - public withCommands (commands: (new (app: any, kernel: any) => any)[]) { + withCommands (commands: (new (app: any, kernel: any) => any)[]) { this.registeredCommands = commands return this @@ -200,7 +226,7 @@ export class Application extends Container implements IApplication { /** * checks if the application is running in CLI */ - public runningInConsole (): boolean { + runningInConsole (): boolean { return typeof process !== 'undefined' && !!process.stdout && !!process.stdin @@ -210,11 +236,11 @@ export class Application extends Container implements IApplication { /** * checks if the application is running in Unit Test */ - public runningUnitTests (): boolean { + runningUnitTests (): boolean { return process.env.VITEST === 'true' } - public getRuntimeEnv (): 'browser' | 'node' | 'unknown' { + getRuntimeEnv (): 'browser' | 'node' | 'unknown' { if (typeof window !== 'undefined' && typeof document !== 'undefined') { return 'browser' } @@ -227,25 +253,44 @@ export class Application extends Container implements IApplication { /** * Determine if the application has booted. */ - public isBooted (): boolean { + isBooted (): boolean { return this.#booted } + /** + * Determine if the application has booted. + */ + logging (logging: boolean = true): this { + this.logsDisabled = !logging + return this + } + + protected logsEnabled () { + if (this.logsDisabled) return false + + const debuggable = process.env.APP_DEBUG === 'true' && process.env.EXTENDED_DEBUG !== 'false' + + return (debuggable || Number(process.env.VERBOSE) > 1) && !this.providers.some(e => e.runsInConsole) + } + /** * Boot all service providers after registration */ - public async boot () { + async boot () { if (this.#booted) return this + this.fireAppCallbacks(this.bootingCallbacks) + + /** + * Register all the configured service providers + */ + await this.registerConfiguredProviders() + /** * If debug is enabled, let's show the loaded service provider info */ - if (((process.env.APP_DEBUG === 'true' && process.env.EXTENDED_DEBUG !== 'false') || Number(process.env.VERBOSE) > 1) && - !this.providers.some(e => e.runsInConsole) - ) { - ProviderRegistry.log(this.providers) - } + ProviderRegistry.log(this.providers, this.logsEnabled()) for (const provider of this.providers) { if (provider.boot) { @@ -275,12 +320,21 @@ export class Application extends Container implements IApplication { return this } + /** + * Register a new boot listener. + * + * @param callable $callback + */ + booting (callback: (app: this) => void): void { + this.bootingCallbacks.push(callback) + } + /** * Register a new "booted" listener. * * @param callback */ - public booted (callback: (...args: any[]) => void): void { + booted (callback: (app: this) => void): void { this.bootedCallbacks.push(callback) if (this.isBooted()) { @@ -293,7 +347,7 @@ export class Application extends Container implements IApplication { * * @param callbacks */ - protected fireAppCallbacks (callbacks: Array<(...args: any[]) => void>): void { + protected fireAppCallbacks (callbacks: Array<(app: this) => void>): void { let index = 0 while (index < callbacks.length) { @@ -338,6 +392,19 @@ export class Application extends Container implements IApplication { }) } + /** + * Handle the incoming Artisan command. + */ + async handleCommand () { + const kernel = this.make(CKernel) + + const status = await kernel.handle() + + kernel.terminate(status) + + return status + } + /** * Get the URI resolver callback. */ @@ -359,22 +426,23 @@ export class Application extends Container implements IApplication { /** * Determine if middleware has been disabled for the application. */ - public shouldSkipMiddleware () { + shouldSkipMiddleware () { return this.bound('middleware.disable') && this.make('middleware.disable') === true } /** * Provide safe overides for the app */ - public configure () { + configure (): AppBuilder { return new AppBuilder(this) .withKernels() + .withCommands() } /** * Check if the current application environment matches the one provided */ - public environment (env: E): E extends undefined ? string : boolean { + environment (env: E): E extends undefined ? string : boolean { return (this.make('config').get('app.env') === env) as never } @@ -387,9 +455,9 @@ export class Application extends Container implements IApplication { * @param preferedPort If provided, this will overide the port set in the evironment * @alias serve */ - public async fire (): Promise - public async fire (h3App: H3, preferredPort?: number): Promise - public async fire (h3App?: H3, preferredPort?: number): Promise { + async fire (): Promise + async fire (h3App: H3, preferredPort?: number): Promise + async fire (h3App?: H3, preferredPort?: number): Promise { if (h3App) this.h3App = h3App @@ -409,12 +477,15 @@ export class Application extends Container implements IApplication { * @param h3App The current H3 app instance * @param preferedPort If provided, this will overide the port set in the evironment */ - public async serve (h3App?: H3, preferredPort?: number): Promise { + async serve (h3App?: H3, preferredPort?: number): Promise { if (!h3App) { throw new InvalidArgumentException('No valid H3 app instance was provided.') } + // Boot the application service providers and other requirements + await this.boot() + const serve = this.make('http.serve') const port: number = preferredPort ?? env('PORT', 3000) @@ -455,6 +526,32 @@ export class Application extends Container implements IApplication { return this } + /** + * Run the given array of bootstrap classes. + * + * @param bootstrappers + */ + async bootstrapWith (bootstrappers: ConcreteConstructor[]): Promise { + this.bootstrapped = true + + for (const bootstrapper of bootstrappers) { + if (this.has('app.events')) + this.make('app.events').dispatch('bootstrapping: ' + bootstrapper.name, [this]) + + await this.make(bootstrapper).bootstrap(this) + + if (this.has('app.events')) + this.make('app.events').dispatch('bootstrapped: ' + bootstrapper.name, [this]) + } + } + + /** + * Determine if the application has been bootstrapped before. + */ + hasBeenBootstrapped (): boolean { + return this.bootstrapped + } + /** * Save the curretn H3 instance for possible future use. * diff --git a/packages/core/src/Container.ts b/packages/core/src/Container.ts index f22147ed..212cd68f 100644 --- a/packages/core/src/Container.ts +++ b/packages/core/src/Container.ts @@ -1,10 +1,9 @@ import 'reflect-metadata' -import { ExtractClassMethods, IContainer, type UseKey, ClassConstructor, type Bindings, CallableConstructor, IMiddleware, ConcreteConstructor } from '@h3ravel/contracts' -import { Handler, MiddlewareHandler } from '@h3ravel/foundation' +import { CallableConstructor, IMiddleware, ConcreteConstructor, type IBinding } from '@h3ravel/contracts' +import { ExtractClassMethods, IContainer, type UseKey, ClassConstructor, type Bindings } from '@h3ravel/contracts' +import { MiddlewareHandler } from '@h3ravel/foundation' import { ContainerResolver } from './Manager/ContainerResolver' -type IBinding = UseKey | (new (...args: any[]) => unknown) - export class Container extends IContainer { public bindings = new Map unknown>() public singletons = new Map() @@ -24,7 +23,11 @@ export class Container extends IContainer { /** * The container's shared instances. */ - protected instances = new Map any>() + protected instances = new Map any>() + /** + * The container's resolved instances. + */ + protected resolvedInstances = new Set() /** * The registered type alias. */ @@ -234,6 +237,8 @@ export class Container extends IContainer { if (raiseEvents) this.runAfterResolvingCallbacks(abstract, resolved) + this.resolvedInstances.add(abstract) + return resolved } @@ -322,7 +327,7 @@ export class Container extends IContainer { * * @param name */ - isAlias (name: string) { + isAlias (name: IBinding | string) { return this.aliases.has(name) && typeof this.aliases.get(name) !== 'undefined' } @@ -332,9 +337,11 @@ export class Container extends IContainer { * @param abstract */ getAlias (abstract: any): any { - if (typeof abstract === 'string' && this.aliases.has(abstract)) { + if (typeof abstract === 'string' && this.aliases.has(abstract)) return this.getAlias(this.aliases.get(abstract)) - } + + if (abstract == null) + return abstract return this.aliases.get(abstract) ?? abstract } @@ -383,6 +390,19 @@ export class Container extends IContainer { return this.bound(key) } + /** + * Determine if the given abstract type has been resolved. + * + * @param abstract + */ + resolved (abstract: IBinding | string): boolean { + if (this.isAlias(abstract)) { + abstract = this.getAlias(abstract) + } + + return this.resolvedInstances.has(abstract) || this.instances.has(abstract) + } + /** * Register an existing instance as shared in the container. * diff --git a/packages/core/src/Controller.ts b/packages/core/src/Controller.ts index b1167a41..298df15b 100644 --- a/packages/core/src/Controller.ts +++ b/packages/core/src/Controller.ts @@ -7,8 +7,8 @@ import { IController } from '@h3ravel/contracts' export abstract class Controller extends IController { protected app: Application - constructor(app: Application) { + constructor(app?: Application) { super() - this.app = app + this.app = app! } } diff --git a/packages/core/src/H3ravel.ts b/packages/core/src/H3ravel.ts index 330c08e6..2647ec47 100644 --- a/packages/core/src/H3ravel.ts +++ b/packages/core/src/H3ravel.ts @@ -1,7 +1,8 @@ -import { Application, Kernel, OServiceProvider } from '.' +import { Application, OServiceProvider } from '.' import { IApplication, IHttpContext } from '@h3ravel/contracts' import { EntryConfig } from './Contracts/H3ravelContract' +import { Facades } from '@h3ravel/support/facades' import { H3 } from 'h3' /** @@ -36,7 +37,7 @@ export const h3ravel = async ( let h3App: H3 | undefined // Initialize the Application class - const app = new Application(basePath) + const app = new Application(basePath, 'h3ravel') // Overide defined paths if (config.customPaths) { @@ -46,8 +47,8 @@ export const h3ravel = async ( } // Start up the app - // @ts-expect-error Provider signature does not match since param is optional, but it should work - await app.quickStartup(providers, config.filteredProviders, config.autoload) + // @ts-expect-error Provider signature does not match since console is optional, but it works + app.initialize(providers, config.filteredProviders, config.autoload) try { // Get the http app container binding @@ -87,6 +88,9 @@ export const h3ravel = async ( // console.log(resp) // return resp // }) + if (!Facades.getApplication()) { + Facades.setApplication(app) + } await app.handleRequest() void middleware } catch { diff --git a/packages/core/src/Manager/ContainerResolver.ts b/packages/core/src/Manager/ContainerResolver.ts index 82d9f86b..adb65acc 100644 --- a/packages/core/src/Manager/ContainerResolver.ts +++ b/packages/core/src/Manager/ContainerResolver.ts @@ -1,6 +1,7 @@ import 'reflect-metadata' import { Application } from '..' +import { IApplication } from '@h3ravel/contracts' type Predicate = | string @@ -8,7 +9,7 @@ type Predicate = | (abstract new (...args: any[]) => any) export class ContainerResolver { - constructor(private app: Application) { } + constructor(private app: IApplication) { } async resolveMethodParams> (instance: I, method: keyof I, ..._default: any[]) { /** diff --git a/packages/core/src/ProviderRegistry.ts b/packages/core/src/ProviderRegistry.ts index d4a62a9e..8f22d351 100644 --- a/packages/core/src/ProviderRegistry.ts +++ b/packages/core/src/ProviderRegistry.ts @@ -2,11 +2,12 @@ import { ConcreteConstructor, IServiceProvider } from '@h3ravel/contracts' import type { Application } from './Application' import { ContainerResolver } from '../src/Manager/ContainerResolver' +import { createRequire } from 'node:module' import fg from 'fast-glob' import path from 'node:path' export class ProviderRegistry { - private static providers = new Map>() + private static providers = new Map>() private static priorityMap = new Map() private static filteredProviders: string[] = [] private static sortable = true @@ -26,7 +27,7 @@ export class ProviderRegistry { * @param provider * @returns */ - private static getKey (provider: ConcreteConstructor): string { + private static getKey (provider: ConcreteConstructor): string { // If provider has a declared static uid/id → prefer that const anyProvider = provider as any if (typeof anyProvider.uid === 'string') { @@ -48,7 +49,7 @@ export class ProviderRegistry { * @param providers * @returns */ - static register (...providers: ConcreteConstructor[]): void { + static register (...providers: ConcreteConstructor[]): void { const list = this.sortable ? this.sort(providers.concat(...this.providers.values())) : providers.concat(...this.providers.values()) @@ -65,7 +66,7 @@ export class ProviderRegistry { * @param providers * @returns */ - static registerMany (providers: ConcreteConstructor[]): void { + static registerMany (providers: ConcreteConstructor[]): void { const list = this.sortable ? this.sort(providers.concat(...this.providers.values())) : providers.concat(...this.providers.values()) @@ -115,8 +116,8 @@ export class ProviderRegistry { * @param providers * @returns */ - static sort (providers: ConcreteConstructor[]) { - const makeKey = (Provider: ConcreteConstructor) => `${Provider.name}::${this.getKey(Provider)}` + static sort (providers: ConcreteConstructor[]) { + const makeKey = (Provider: ConcreteConstructor) => `${Provider.name}::${this.getKey(Provider)}` // Step 1: Sort purely by priority (descending) providers.sort((A, B) => ((B as any).priority ?? 0) - ((A as any).priority ?? 0)) @@ -158,7 +159,7 @@ export class ProviderRegistry { */ static doSort () { const raw = this.sort(Array.from(this.providers.values())) - const providers = new Map>() + const providers = new Map>() for (const provider of raw) { const key = this.getKey(provider) @@ -173,7 +174,9 @@ export class ProviderRegistry { * * @param priorityMap */ - static log

(providers?: Array

| Map) { + static log

(providers?: Array

| Map, enabled = true) { + if (!enabled) return + const sorted = Array.from(((providers as unknown as P[]) ?? this.providers).values()) console.table( @@ -192,7 +195,7 @@ export class ProviderRegistry { * * @returns */ - static all (): ConcreteConstructor[] { + static all (): ConcreteConstructor[] { return Array.from(this.providers.values()) } @@ -202,7 +205,7 @@ export class ProviderRegistry { * @param provider * @returns */ - static has (provider: ConcreteConstructor): boolean { + static has (provider: ConcreteConstructor): boolean { return this.providers.has(this.getKey(provider)) } @@ -219,11 +222,11 @@ export class ProviderRegistry { 'node_modules/h3ravel-*/package.json', ]) - const providers: ConcreteConstructor[] = [] + const providers: ConcreteConstructor[] = [] if (autoRegister) { for (const manifestPath of manifests) { - const pkg = await this.getManifest(path.resolve(manifestPath)) + const pkg = this.getManifest(path.resolve(manifestPath)) if (pkg.h3ravel?.providers) { providers.push(...await Promise.all( @@ -233,7 +236,7 @@ export class ProviderRegistry { } } - for (const provider of providers) { + for (const provider of providers.filter(e => typeof e !== 'undefined')) { const key = this.getKey(provider) this.providers.set(key, provider) } @@ -248,15 +251,8 @@ export class ProviderRegistry { * @param manifestPath * @returns */ - private static async getManifest (manifestPath: string) { - let pkg: any - try { - pkg = (await import(manifestPath)).default - } catch { - const { createRequire } = await import('module') - const require = createRequire(import.meta.url) - pkg = require(manifestPath) - } - return pkg + private static getManifest (manifestPath: string) { + const require = createRequire(import.meta.url) + return require(manifestPath) } } diff --git a/packages/core/src/ServiceProvider.ts b/packages/core/src/ServiceProvider.ts index 5e99b8ce..971466c8 100644 --- a/packages/core/src/ServiceProvider.ts +++ b/packages/core/src/ServiceProvider.ts @@ -1 +1 @@ -export { ServiceProvider } from '@h3ravel/foundation' \ No newline at end of file +export { ServiceProvider } from '@h3ravel/support' \ No newline at end of file diff --git a/packages/database/src/Model.ts b/packages/database/src/Model.ts index a1824f4a..7850dfc7 100644 --- a/packages/database/src/Model.ts +++ b/packages/database/src/Model.ts @@ -1,4 +1,6 @@ -import { Model as BaseModel } from '@h3ravel/arquebus' +import { Model as BaseModel, Builder } from '@h3ravel/arquebus' + +import { IQueryBuilder } from '@h3ravel/arquebus/types' export class Model extends BaseModel { /** @@ -8,7 +10,33 @@ export class Model extends BaseModel { * @param {String|null} field * @returns */ - public resolveRouteBinding (value: any, field: undefined | string | null = null): Promise { - return this.newQuery().where(field ?? 'ids', value).firstOrFail() as unknown as Promise + resolveRouteBinding (value: any, field: undefined | string | null = null): Promise { + // return this.newQuery().where(field ?? 'ids', value).firstOrFail() as unknown as Promise + return this.resolveRouteBindingQuery(this as never, value, field).firstOrFail() as never + } + + /** + * Retrieve the model for a bound value. + * + * @param query + * @param value + * @param field + */ + resolveRouteBindingQuery (query: Builder, value: any, field: undefined | string | null = null): IQueryBuilder { + return query.where(field ?? this.getRouteKeyName(), value) as never + } + + /** + * Get the value of the model's route key. + */ + getRouteKey () { + return this.getAttribute(this.getRouteKeyName()) + } + + /** + * Get the route key for the model. + */ + getRouteKeyName () { + return this.getKeyName() } } diff --git a/packages/database/src/Providers/DatabaseServiceProvider.ts b/packages/database/src/Providers/DatabaseServiceProvider.ts index 3e867d83..5c061524 100644 --- a/packages/database/src/Providers/DatabaseServiceProvider.ts +++ b/packages/database/src/Providers/DatabaseServiceProvider.ts @@ -1,7 +1,7 @@ import { MakeCommand } from '../Commands/MakeCommand' import { MigrateCommand } from '../Commands/MigrateCommand' import { SeedCommand } from '../Commands/SeedCommand' -import { ServiceProvider } from '@h3ravel/core' +import { ServiceProvider } from '@h3ravel/support' import { arquebus } from '@h3ravel/arquebus' import { arquebusConfig } from '../Configuration' diff --git a/packages/events/src/Providers/EventsServiceProvider.ts b/packages/events/src/Providers/EventsServiceProvider.ts index e19a786d..dd4fa820 100644 --- a/packages/events/src/Providers/EventsServiceProvider.ts +++ b/packages/events/src/Providers/EventsServiceProvider.ts @@ -22,6 +22,10 @@ export class EventsServiceProvider extends ServiceProvider { }) }) - this.app.alias(IDispatcher, Dispatcher) + this.app.alias([ + ['events', 'app.events'], + [Dispatcher, 'app.events'], + [IDispatcher, 'app.events'], + ]) } } diff --git a/packages/foundation/package.json b/packages/foundation/package.json index b5417bc7..4df23c93 100644 --- a/packages/foundation/package.json +++ b/packages/foundation/package.json @@ -69,6 +69,7 @@ "dependencies": { "h3": "catalog:prod", "@h3ravel/shared": "workspace:^", + "@h3ravel/musket": "catalog:prod", "@h3ravel/support": "workspace:^" } } \ No newline at end of file diff --git a/packages/foundation/src/Adapters/InMemoryRateLimiter.ts b/packages/foundation/src/Adapters/InMemoryRateLimiter.ts index c477cbed..35f8f353 100644 --- a/packages/foundation/src/Adapters/InMemoryRateLimiter.ts +++ b/packages/foundation/src/Adapters/InMemoryRateLimiter.ts @@ -1,4 +1,4 @@ -import { RateLimiterAdapter } from '../Contracts/RateLimiterAdapter' +import { RateLimiterAdapter } from '@h3ravel/contracts' /** * Very small in-memory token-bucket / counter limiter. diff --git a/packages/foundation/src/Bootstrapers/BootProviders.ts b/packages/foundation/src/Bootstrapers/BootProviders.ts new file mode 100644 index 00000000..0df9efa2 --- /dev/null +++ b/packages/foundation/src/Bootstrapers/BootProviders.ts @@ -0,0 +1,10 @@ +import { IApplication, IBootstraper } from '@h3ravel/contracts' + +export class BootProviders extends IBootstraper { + /** + * Bootstrap the given application. + */ + async bootstrap (app: IApplication) { + await app.boot() + } +} \ No newline at end of file diff --git a/packages/foundation/src/Bootstrapers/RegisterFacades.ts b/packages/foundation/src/Bootstrapers/RegisterFacades.ts new file mode 100644 index 00000000..9d7d130d --- /dev/null +++ b/packages/foundation/src/Bootstrapers/RegisterFacades.ts @@ -0,0 +1,14 @@ +import { IApplication, IBootstraper } from '@h3ravel/contracts' + +import { Facades } from '@h3ravel/support/facades' + +export class RegisterFacades extends IBootstraper { + /** + * Bootstrap the given application. + */ + bootstrap (app: IApplication) { + Facades.clearResolvedInstances() + + Facades.setApplication(app) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Configuration/AppBuilder.ts b/packages/foundation/src/Configuration/AppBuilder.ts index b171c11c..c6ee8e44 100644 --- a/packages/foundation/src/Configuration/AppBuilder.ts +++ b/packages/foundation/src/Configuration/AppBuilder.ts @@ -1,5 +1,10 @@ -import { ExceptionHandler, Exceptions, Kernel, Middleware, MiddlewareList } from '..' -import { IApplication, IExceptionHandler, IKernel } from '@h3ravel/contracts' +import { CKernel, CallableConstructor, IApplication, IExceptionHandler, IKernel, MiddlewareList } from '@h3ravel/contracts' +import { ConsoleKernel, ExceptionHandler, Exceptions, Kernel, Middleware } from '..' + +import { Route } from '@h3ravel/support/facades' +import { Collection, isClass, RouteServiceProvider } from '@h3ravel/support' +import { existsSync, statSync } from 'node:fs' +import { Command } from '@h3ravel/musket' export class AppBuilder { @@ -8,15 +13,24 @@ export class AppBuilder { */ protected pageMiddleware: MiddlewareList[] = [] + /** + * Any additional routing callbacks that should be invoked while registering routes. + */ + protected additionalRoutingCallbacks: CallableConstructor[] = [] + constructor(private app: IApplication) { } /** * Register the base kernel classes for the application. */ - public withKernels () { + withKernels () { this.app.singleton(IKernel, Kernel) - // TODO: Register Console Kernel here too + this.app.singleton(CKernel, () => new ConsoleKernel(this.app)) + this.app.alias([ + [Kernel, IKernel], + [ConsoleKernel, CKernel] + ]) return this } @@ -26,7 +40,7 @@ export class AppBuilder { * * @param using **/ - public withExceptions (using: (exceptions: Exceptions) => void) { + withExceptions (using: (exceptions: Exceptions) => void) { // Register the ExceptionHandler as a singleton this.app.singleton(IExceptionHandler, () => new ExceptionHandler()) this.app.alias([ @@ -50,11 +64,12 @@ export class AppBuilder { * * @param using **/ - public withMiddleware (callback?: (mw: Middleware) => void) { + withMiddleware (callback?: (mw: Middleware) => void) { // After resolution, pass an instance of Middleware into the user callback this.app.afterResolving(IKernel, (kernel) => { const middleware = new Middleware(this.app) - .redirectGuestsTo(() => route('login')) + // TODO: Implement the route() method and use here + .redirectGuestsTo(() => 'route(\'login\')') if (callback && typeof callback === 'function') { callback(middleware) @@ -88,10 +103,131 @@ export class AppBuilder { return this } + /** + * Register the routing services for the application. + */ + withRouting ({ using, web, api, commands, health, channels, apiPrefix = 'api', then }: { + using?: CallableConstructor; + web?: string | string[]; + api?: string | string[]; + commands?: string | Collection>; + health?: string; + channels?: string; + apiPrefix?: string; + then?: CallableConstructor; + } = {}) { + if ( + using == null && + (typeof web === 'string' || Array.isArray(web) || typeof api === 'string' || Array.isArray(api) || typeof health === 'string') || + typeof api === 'function' + ) { + using = this.buildRoutingCallback({ web, api, health, apiPrefix, then }) + if (typeof health === 'string') { + // TODO: Implement maintenance mode features + // PreventRequestsDuringMaintenance.except(health) + } + } + + RouteServiceProvider.loadRoutesUsing(using) + + this.app.booting((app) => { + app.registerProviders([RouteServiceProvider]) + }) + + if (typeof commands === 'string' && existsSync(commands) !== false) { + this.withCommands([commands]) + } + + if (typeof channels === 'string' && existsSync(channels) !== false) { + // this.withBroadcasting(channels) + // TODO: Implement broadcasting features + } + + return this + } + + /** + * Create the routing callback for the application. + * + * @param web + * @param api + * @param health + * @param apiPrefix + * @param then + */ + protected buildRoutingCallback ({ web, api, health, apiPrefix, then }: { + web?: string | string[]; + api?: string | string[]; + health?: string; + apiPrefix: string; + then?: CallableConstructor; + }) { + return () => { + if (typeof api === 'string' || Array.isArray(api)) { + if (Array.isArray(api)) { + for (const apiRoute of api) { + if (existsSync(apiRoute) !== false) { + Route.middleware('api').prefix(apiPrefix).group(apiRoute) + } + } + } else { + Route.middleware('api').prefix(apiPrefix).group(api) + } + } + + if (typeof web === 'string' || Array.isArray(web)) { + if (Array.isArray(web)) { + for (const webRoute of web) { + if (existsSync(webRoute) !== false) { + Route.middleware('web').group(webRoute) + } + } + } else { + Route.middleware('web').group(web) + } + } + + for (const callback of this.additionalRoutingCallbacks) { + callback() + } + + if (then && typeof then === 'function') { + then(this.app) + } + } + } + + /** + * Register additional Artisan commands with the application. + * + * @param commands + */ + withCommands (commands?: typeof Command[] | string[]) { + let paths: any, routes: any + if (!commands || commands.length < 1) { + commands = [this.app.getPath('commands')] + } + + this.app.afterResolving(CKernel, (kernel) => { + [commands as any, paths] = (new Collection[] | string[]>(commands)).partition((command) => isClass(command)); + + [routes, paths] = paths.partition((path: string) => statSync(path, { throwIfNoEntry: false })?.isFile()) + + this.app.booted(() => { + kernel.registerCommand((commands as any).all()) + kernel.addCommandPaths(paths.all()) + kernel.addCommandRoutePaths(routes.all()) + }) + // TODO: revist this to ensure everything works as expected + }) + + return this + } + /** * create */ - public create () { + create () { } } \ No newline at end of file diff --git a/packages/foundation/src/Configuration/Middleware.ts b/packages/foundation/src/Configuration/Middleware.ts index d559a341..0d72b342 100644 --- a/packages/foundation/src/Configuration/Middleware.ts +++ b/packages/foundation/src/Configuration/Middleware.ts @@ -1,5 +1,4 @@ -import { IApplication, IMiddleware } from '@h3ravel/contracts' -import { MiddlewareList, RedirectHandler } from '../Contracts/MiddlewareContract' +import { IApplication, IMiddleware, MiddlewareList, RedirectHandler } from '@h3ravel/contracts' import { Arr } from '@h3ravel/support' @@ -329,6 +328,7 @@ export class Middleware { ].filter(e => e !== null), 'api': [ + 'SubstituteBindings', this.apiLimiter ? 'throttle:' + this.apiLimiter : null, ].filter(e => e !== null), } diff --git a/packages/foundation/src/Console/Commands/BuildCommand.ts b/packages/foundation/src/Console/Commands/BuildCommand.ts new file mode 100644 index 00000000..c8d1052c --- /dev/null +++ b/packages/foundation/src/Console/Commands/BuildCommand.ts @@ -0,0 +1,97 @@ +import { Logger, TaskManager } from '@h3ravel/shared' + +import { Command } from '@h3ravel/musket' +import { execa } from 'execa' +import preferredPM from 'preferred-pm' + +export class BuildCommand extends Command { + + /** + * The name and signature of the console command. + * + * @var string + */ + protected signature: string = `build + {--m|minify : Minify your bundle output} + {--d|dev : Build for dev but don't watch for changes} + ` + + /** + * The console command description. + * + * @var string + */ + protected description: string = 'Build the app for production' + + public async handle () { + try { + await this.fire() + } catch (e) { + Logger.error(e as any) + } + } + + protected async fire () { + const outDir = this.option('dev') ? '.h3ravel/serve' : env('DIST_DIR', 'dist') + const minify = this.option('minify') + const verbosity = this.getVerbosity() + const debug = verbosity > 0 + + this.newLine() + await BuildCommand.build({ outDir, minify, verbosity, debug, mute: false }) + this.newLine() + } + + /** + * build + */ + public static async build ({ debug, minify, mute, verbosity, outDir } = { + mute: false, + debug: false, + minify: false, + verbosity: 0, + outDir: 'dist' + }) { + + const pm = (await preferredPM(base_path()))?.name ?? 'pnpm' + + const LOG_LEVELS = [ + 'silent', + 'info', + 'warn', + 'error', + ] + + const ENV_VARS = { + EXTENDED_DEBUG: debug ? 'true' : 'false', + CLI_BUILD: 'true', + NODE_ENV: 'production', + DIST_DIR: outDir, + DIST_MINIFY: minify, + LOG_LEVEL: LOG_LEVELS[verbosity] + } + + const silent = ENV_VARS.LOG_LEVEL === 'silent' ? '--silent' : null + + if (mute) { + return await execa( + pm, + ['tsdown', silent, '--config-loader', 'unconfig', '-c', 'tsdown.default.config.ts'].filter(e => e !== null), + { stdout: 'inherit', stderr: 'inherit', cwd: base_path(), env: Object.assign({}, process.env, ENV_VARS) } + ) + } + + const type = outDir === 'dist' ? 'Production' : 'Development' + + return await TaskManager.advancedTaskRunner( + [[`Creating ${type} Bundle`, 'STARTED'], [`${type} Bundle Created`, 'COMPLETED']], + async () => { + await execa( + pm, + ['tsdown', silent, '--config-loader', 'unconfig', '-c', 'tsdown.default.config.ts'].filter(e => e !== null), + { stdout: 'inherit', stderr: 'inherit', cwd: base_path(), env: Object.assign({}, process.env, ENV_VARS) } + ) + } + ) + } +} diff --git a/packages/foundation/src/Console/Commands/KeyGenerateCommand.ts b/packages/foundation/src/Console/Commands/KeyGenerateCommand.ts new file mode 100644 index 00000000..276539eb --- /dev/null +++ b/packages/foundation/src/Console/Commands/KeyGenerateCommand.ts @@ -0,0 +1,92 @@ +import { FileSystem, Logger } from '@h3ravel/shared' +import { copyFile, readFile, writeFile } from 'fs/promises' + +import { Command } from '@h3ravel/musket' +import crypto from 'crypto' +import dotenv from 'dotenv' + +export class KeyGenerateCommand extends Command { + + /** + * The name and signature of the console command. + * + * @var string + */ + protected signature: string = `key:generate + {--force: Force the operation to run when in production} + {--show: Display the key instead of modifying files} + ` + + /** + * The console command description. + * + * @var string + */ + protected description: string = 'Set the application key' + + public async handle () { + const config = { + key: crypto.randomBytes(32).toString('base64'), + envPath: base_path('.env'), + egEnvPath: base_path('.env.example'), + updated: false, + show: this.option('show') + } + + this.newLine() + + // Try to create the .env file if it does not exist + if (!await FileSystem.fileExists(config.envPath)) { + if (await FileSystem.fileExists(config.egEnvPath)) { + await copyFile(config.egEnvPath, config.envPath) + } else { + this.error('.env file not found.') + this.newLine() + process.exit(0) + } + } + + // Read and parse the .env file + let content = await readFile(config.envPath, 'utf8') + const buf = Buffer.from(content) + const env = dotenv.parse(buf) + + // Show the Application key + if (config.show) { + // If the Application key is not exit with an erorr message + if (!env.APP_KEY || env.APP_KEY === '') { + this.error('Application key not set.') + this.newLine() + process.exit(0) + } + + // Actually show the Application key + const [enc, key] = env.APP_KEY.split(':') + Logger.log([[enc, 'yellow'], [key, 'white']], ':') + this.newLine() + process.exit(0) + } else if (env.APP_ENV === 'production' && !this.option('force')) { + // If the Application is currently in production and the force flag is not set, exit with an error + this.error('Application is currently in production, failed to set key.') + this.newLine() + process.exit(1) + } + + // Check if APP_KEY exists + if (/^APP_KEY=.*$/m.test(content)) { + config.updated = true + content = content.replace(/^APP_KEY=.*$/m, `APP_KEY=base64:${config.key}`) + } else { + // Add APP_KEY to the top, preserving existing content + config.updated = false + content = `APP_KEY=base64:${config.key}\n\n${content}` + } + + // Write the application key to the .env file + await writeFile(config.envPath, content, 'utf8') + + // Show the success message + this.success('Application key set successfully.') + this.newLine() + } +} diff --git a/packages/foundation/src/Console/Commands/MakeCommand.ts b/packages/foundation/src/Console/Commands/MakeCommand.ts new file mode 100644 index 00000000..3db82de8 --- /dev/null +++ b/packages/foundation/src/Console/Commands/MakeCommand.ts @@ -0,0 +1,121 @@ +import { FileSystem, Logger } from '@h3ravel/shared' +import { mkdir, readFile, writeFile } from 'node:fs/promises' + +import { Command } from '@h3ravel/musket' +import { Str } from '@h3ravel/support' +import nodepath from 'node:path' + +export class MakeCommand extends Command { + + /** + * The name and signature of the console command. + * + * @var string + */ + protected signature: string = `#make: + {controller : Create a new controller class. + | {--a|api : Exclude the create and edit methods from the controller} + | {--m|model= : Generate a resource controller for the given model} + | {--r|resource : Generate a resource controller class} + | {--force : Create the controller even if it already exists} + } + {resource : Create a new resource. + | {--c|collection : Create a resource collection} + | {--force : Create the resource even if it already exists} + } + {command : Create a new Musket command. + | {--command : The terminal command that will be used to invoke the class} + | {--force : Create the class even if the console command already exists} + } + {view : Create a new view. + | {--force : Create the view even if it already exists} + } + {^name : The name of the [name] to generate} + ` + /** + * The console command description. + * + * @var string + */ + protected description: string = 'Generate component classes' + + public async handle (this: any) { + const command = (this.dictionary.baseCommand ?? this.dictionary.name) as never + + if (!this.argument('name')) { + this.program.error('Please provide a valid name for the ' + command) + } + + const methods = { + controller: 'makeController', + resource: 'makeResource', + view: 'makeView', + command: 'makeCommand', + } as const + + await this[methods[command]]() + } + + /** + * Create a new controller class. + */ + protected async makeController () { + const type = this.option('api') ? '-resource' : '' + const name = this.argument('name') + const force = this.option('force') + + const crtlrPath = FileSystem.findModulePkg('@h3ravel/http', this.kernel.cwd) ?? '' + const stubPath = nodepath.join(crtlrPath, `dist/stubs/controller${type}.stub`) + const path = app_path(`Http/Controllers/${name}.ts`) + + /** The Controller is scoped to a path make sure to create the associated directories */ + if (name.includes('/')) { + await mkdir(Str.beforeLast(path, '/'), { recursive: true }) + } + + /** Check if the controller already exists */ + if (!force && await FileSystem.fileExists(path)) { + Logger.error(`ERORR: ${name} controller already exists`) + } + + let stub = await readFile(stubPath, 'utf-8') + stub = stub.replace(/{{ name }}/g, name) + + await writeFile(path, stub) + Logger.split('INFO: Controller Created', Logger.log(nodepath.basename(path), 'gray', false)) + } + + protected makeResource () { + Logger.success('Resource support is not yet available') + } + + /** + * Create a new Musket command + */ + protected makeCommand () { + Logger.success('Musket command creation is not yet available') + } + + /** + * Create a new view. + */ + protected async makeView () { + const name = this.argument('name') + const force = this.option('force') + + const path = base_path(`src/resources/views/${name}.edge`) + + /** The view is scoped to a path make sure to create the associated directories */ + if (name.includes('/')) { + await mkdir(Str.beforeLast(path, '/'), { recursive: true }) + } + + /** Check if the view already exists */ + if (!force && await FileSystem.fileExists(path)) { + Logger.error(`ERORR: ${name} view already exists`) + } + + await writeFile(path, `{{-- src/resources/views/${name}.edge --}}`) + Logger.split('INFO: View Created', Logger.log(`src/resources/views/${name}.edge`, 'gray', false)) + } +} diff --git a/packages/foundation/src/Console/Commands/PostinstallCommand.ts b/packages/foundation/src/Console/Commands/PostinstallCommand.ts new file mode 100644 index 00000000..bf2f8d16 --- /dev/null +++ b/packages/foundation/src/Console/Commands/PostinstallCommand.ts @@ -0,0 +1,59 @@ +import { mkdir, writeFile } from 'node:fs/promises' + +import { Command } from '@h3ravel/musket' +import { FileSystem } from '@h3ravel/shared' +import { KeyGenerateCommand } from './KeyGenerateCommand' + +export class PostinstallCommand extends Command { + + /** + * The name and signature of the console command. + * + * @var string + */ + protected signature: string = 'postinstall' + + /** + * The console command description. + * + * @var string + */ + protected description: string = 'Default post installation command' + + public async handle () { + this.genEncryptionKey() + this.createSqliteDB() + } + + /** + * Create sqlite database if none exist + * + * @returns + */ + private async genEncryptionKey () { + new KeyGenerateCommand(this.app, this.kernel) + .setProgram(this.program) + .setOption('force', true) + .setOption('silent', true) + .setOption('quiet', true) + .setInput({ force: true, silent: true, quiet: true }, [], [], {}, this.program) + .handle() + } + + /** + * Create sqlite database if none exist + * + * @returns + */ + private async createSqliteDB () { + if (config('database.default') !== 'sqlite') return + + if (!await FileSystem.fileExists(database_path())) { + await mkdir(database_path(), { recursive: true }) + } + + if (!await FileSystem.fileExists(database_path('db.sqlite'))) { + await writeFile(database_path('db.sqlite'), '') + } + } +} diff --git a/packages/foundation/src/Console/ConsoleKernel.ts b/packages/foundation/src/Console/ConsoleKernel.ts new file mode 100644 index 00000000..21ec7a78 --- /dev/null +++ b/packages/foundation/src/Console/ConsoleKernel.ts @@ -0,0 +1,293 @@ +import { BootProviders, ExceptionHandler, RegisterFacades } from '..' +import { CKernel, CallableConstructor, ConcreteConstructor, IApplication, IBootstraper } from '@h3ravel/contracts' +import { Command, Kernel } from '@h3ravel/musket' +import { existsSync, statSync } from 'node:fs' + +import { BuildCommand } from './Commands/BuildCommand' +import { ContainerResolver } from '@h3ravel/core' +import { DateTime } from '@h3ravel/support' +import { Injectable } from '..' +import { KeyGenerateCommand } from './Commands/KeyGenerateCommand' +import { MakeCommand } from './Commands/MakeCommand' +import { PostinstallCommand } from './Commands/PostinstallCommand' +import { Terminating } from '../Core/Events/Terminating' +import { altLogo } from './logo' +import { createRequire } from 'node:module' +import tsDownConfig from './TsdownConfig' + +/** + * ConsoleKernel class handles musket execution and transformations. + * It acts as the core pipeline for console inputs. +*/ +@Injectable() +export class ConsoleKernel extends CKernel { + protected DIST_DIR: string + + /** + * The bootstrap classes for the application. + */ + #bootstrappers: ConcreteConstructor[] = [ + RegisterFacades, + BootProviders + ] + + /** + * The current Musket console instance + */ + protected commands: typeof Command[] = [] + /** + * The current Musket console instance + */ + protected console?: Kernel + + /** + * When the current command started. + */ + protected commandStartedAt?: DateTime + + /** + * Indicates if the Closure commands have been loaded. + */ + protected commandsLoaded = false + + /** + * The paths where Musket commands should be automatically discovered. + */ + protected commandPaths = new Set() + + /** + * The paths where Musket command routes should be automatically discovered. + */ + protected commandRoutePaths = new Set() + + protected commandLifecycleDurationHandlers: { + 'threshold': number, + 'handler': CallableConstructor, + }[] = [] + + /** + * Create a new Console kernel instance. + * + * @param app The current application instance + */ + constructor( + protected app: IApplication, + ) { + super() + globalThis.env ??= ((key: string, def: string) => Reflect.get(process.env, key) ?? def) as never + this.DIST_DIR = `/${env('DIST_DIR', '.h3ravel/serve')}/`.replaceAll('//', '') + } + + /** + * Get the bootstrap classes for the application. + * + * @return array + */ + protected bootstrappers () { + return this.#bootstrappers + } + + /** + * Report the exception to the exception handler. + * @param e + */ + protected reportException (e: Error) { + this.app.make(ExceptionHandler).report(e) + } + + /** + * Render the given exception. + * + * @param e + */ + protected renderException (e: Error) { + this.app.make(ExceptionHandler).renderForConsole(e) + } + + /** + * Run the console application. + */ + async handle () { + this.commandStartedAt = DateTime.now() + + try { + await this.bootstrap() + + const status = await this.getConsole().run(true); + + ['SIGINT', 'SIGTERM', 'SIGTSTP'].forEach(sig => process.on(sig, () => { + process.exit(0) + })) + + return status + } catch (e: any) { + this.reportException(e) + this.renderException(e) + + return 1 + } + } + + /** + * Register a given command. + * + * @param command + */ + registerCommand (command: typeof Command | typeof Command[]) { + this.getConsole().registerCommands(Array.isArray(command) ? command : [command]) + } + + /** + * Get all the registered commands. + */ + async all () { + await this.bootstrap() + + return this.getConsole().getRegisteredCommands() + } + + /** + * Bootstrap the application for Musket commands. + * + * @return void + */ + async bootstrap () { + if (!this.app.hasBeenBootstrapped()) { + await this.app.bootstrapWith(this.bootstrappers()) + } + + // this.app.loadDeferredProviders() + + if (!this.commandsLoaded) { + this.registerCommands() + + if (this.shouldDiscoverCommands()) { + this.discoverCommands() + } + + this.commandsLoaded = true + } + } + + /** + * Determine if the kernel should discover commands. + */ + protected shouldDiscoverCommands () { + return this.constructor === ConsoleKernel + } + + /** + * Register the commands for the application. + */ + protected registerCommands () { + // + } + + /** + * Discover the commands that should be automatically loaded. + */ + protected discoverCommands () { + const require = createRequire(import.meta.url) + + this.getConsole().registerDiscoveryPath(Array.from(this.commandPaths)) + + for (let path of this.commandRoutePaths) { + path = path.replace('/src/', this.DIST_DIR) + if (existsSync(path)) { + class RouteCommand extends Command { + handle = require(path).default + } + + this.getConsole().registerCommands([RouteCommand]) + } + } + } + + /** + * Set the paths that should have their Musket commands automatically discovered. + * + * @param paths + */ + addCommandPaths (paths: string[]) { + paths.forEach(e => { + e = e.replace('/src/', this.DIST_DIR) + this.commandPaths.add(statSync(e, { throwIfNoEntry: false })?.isFile() ? e : e + '*.js') + }) + return this + } + + /** + * Set the paths that should have their Artisan "routes" automatically discovered. + * + * @param paths + */ + addCommandRoutePaths (paths: string[]): this { + paths.forEach(e => this.commandRoutePaths.add(e)) + + return this + } + + /** + * Get the Musket application instance. + */ + getConsole (): Kernel { + if (this.console == null) { + const baseCommands = [BuildCommand, MakeCommand, PostinstallCommand, KeyGenerateCommand] as any[] + + this.console = new Kernel(this.app) + .setCwd(process.cwd()) + .setConfig({ + logo: altLogo, + resolver: new ContainerResolver(this.app).resolveMethodParams, + tsDownConfig, + baseCommands, + packages: [ + { name: '@h3ravel/core', alias: 'H3ravel Framework' }, + { name: '@h3ravel/musket', alias: 'Musket CLI' } + ], + name: 'musket', + hideMusketInfo: true, + // discoveryPaths is commented out so we can rely on the console kernel to provide it + // discoveryPaths: [app_path('Console/Commands/*.js').replace('/src/', this.DIST_DIR)], + }) + .setPackages([ + { name: '@h3ravel/core', alias: 'H3ravel Framework' }, + { name: '@h3ravel/musket', alias: 'Musket CLI' } + ]) + .registerCommands(this.commands) + .bootstrap() + } + + return this.console + } + + /** + * Terminate the app. + * + * @param request + */ + public terminate (status: number): void { + this.app.make('app.events').dispatch(new Terminating()) + + // this.app.terminate(); + + if (!this.commandStartedAt) return + + this.commandStartedAt?.tz(this.app.make('config').get('app.timezone') ?? 'UTC') + + /* + * Handle duration thresholds + */ + let end: DateTime + + for (const { threshold, handler } of Object.values(this.commandLifecycleDurationHandlers)) { + end ??= new DateTime() + + if (this.commandStartedAt.diff(end, 'milliseconds') > threshold) { + handler(this.commandStartedAt, status) + } + } + + this.commandStartedAt = undefined + } +} \ No newline at end of file diff --git a/packages/console/src/TsdownConfig.ts b/packages/foundation/src/Console/TsdownConfig.ts similarity index 100% rename from packages/console/src/TsdownConfig.ts rename to packages/foundation/src/Console/TsdownConfig.ts diff --git a/packages/foundation/src/Console/logo.ts b/packages/foundation/src/Console/logo.ts new file mode 100644 index 00000000..0de6bfdb --- /dev/null +++ b/packages/foundation/src/Console/logo.ts @@ -0,0 +1,32 @@ +export const logo = String.raw` + 111 + 111111111 + 1111111111 111111 + 111111 111 111111 + 111111 111 111111 +11111 111 11111 +1111111 111 1111111 +111 11111 111 111111 111 1111 1111 11111111 1111 +111 11111 1111 111111 111 1111 1111 1111 11111 1111 +111 11111 11111 111 1111 1111 111111111111 111111111111 1111 1111111 1111 +111 111111 1111 111 111111111111 111111 11111 1111 111 1111 11111111 1111 1111 +111 111 11111111 111 1101 1101 111111111 11111111 1111 1111111111111111101 +111 1111111111111111 1111 111 1111 1111 111 11111011 1111 111 1111111 1101 1111 +111 11111 1110111111111111 111 1111 1111 1111111101 1111 111111111 1111011 111111111 1111 +1111111 111110111110 111 1111 1111 111111 1111 11011101 10111 11111 1111 +11011 111111 11 11111 + 111111 11101 111111 + 111111 111 111111 + 111111 111 111111 + 111111111 + 110 +` + +export const altLogo = String.raw` + _ _ _____ _ +| | | |___ / _ __ __ ___ _____| | +| |_| | |_ \| '__/ _ \ \ / / _ \ | +| _ |___) | | | (_| |\ V / __/ | +|_| |_|____/|_| \__,_| \_/ \___|_| + +` diff --git a/packages/foundation/src/Container/Inject.ts b/packages/foundation/src/Container/Decorators.ts similarity index 72% rename from packages/foundation/src/Container/Inject.ts rename to packages/foundation/src/Container/Decorators.ts index 3bec12e0..3b180103 100644 --- a/packages/foundation/src/Container/Inject.ts +++ b/packages/foundation/src/Container/Decorators.ts @@ -1,3 +1,5 @@ +import { INTERNAL_METHODS } from '@h3ravel/shared' + export function Inject (...dependencies: string[]) { return function (target: any) { target.__inject__ = dependencies @@ -35,3 +37,15 @@ export function Injectable (): MethodDecorator & ClassDecorator { // }) as any // } +export const internal = (target: any, propertyKey: string) => { + if (!target[INTERNAL_METHODS]) { + target[INTERNAL_METHODS] = new Set() + } + target[INTERNAL_METHODS].add(propertyKey) +} + +export const isInternal = (instance: any, prop: string) => { + const proto = Object.getPrototypeOf(instance) + const internalSet: Set = proto[INTERNAL_METHODS] + return internalSet?.has(prop) ?? false +} \ No newline at end of file diff --git a/packages/foundation/src/Contracts/MiddlewareContract.ts b/packages/foundation/src/Contracts/MiddlewareContract.ts deleted file mode 100644 index b2ee154d..00000000 --- a/packages/foundation/src/Contracts/MiddlewareContract.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { IMiddleware } from '@h3ravel/contracts' - -export type RedirectHandler = string | (() => string); -export type MiddlewareIdentifier = string | IMiddleware; -export type MiddlewareList = MiddlewareIdentifier[]; \ No newline at end of file diff --git a/packages/foundation/src/Contracts/RateLimiterAdapter.ts b/packages/foundation/src/Contracts/RateLimiterAdapter.ts deleted file mode 100644 index 29b24e9a..00000000 --- a/packages/foundation/src/Contracts/RateLimiterAdapter.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * RateLimiterAdapter types - */ - -export type LimitSpec = { - key?: string - maxAttempts: number - decaySeconds: number -} - -export type Unlimited = { - unlimited: true -} - -/** - * Rate Limiter Adapter Interface - */ -export interface RateLimiterAdapter { - /** - * Attempt a key with a maxAttempts and decaySeconds. - * - * Return true if this is allowed (i.e., *not* throttled), - * false if the limit is reached. - */ - attempt ( - key: string, - maxAttempts: number, - allowCallback: () => boolean | Promise, - decaySeconds: number - ): Promise -} \ No newline at end of file diff --git a/packages/foundation/src/Core/Events/Terminating.ts b/packages/foundation/src/Core/Events/Terminating.ts new file mode 100644 index 00000000..f9dd9c09 --- /dev/null +++ b/packages/foundation/src/Core/Events/Terminating.ts @@ -0,0 +1,3 @@ +export class Terminating { + // +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/Base/Exceptions.ts b/packages/foundation/src/Exceptions/Base/Exceptions.ts index b0cb48de..5786701c 100644 --- a/packages/foundation/src/Exceptions/Base/Exceptions.ts +++ b/packages/foundation/src/Exceptions/Base/Exceptions.ts @@ -122,6 +122,15 @@ export class Exceptions { return this } + /** + * Render an exception to the console. + * + * @param e + */ + public renderForConsole (e: Error) { + this.handler.renderForConsole(e) + } + /** * Indicate that the given exception class should not be ignored. */ diff --git a/packages/foundation/src/Exceptions/Base/Handler.ts b/packages/foundation/src/Exceptions/Base/Handler.ts index c8b5ea0e..830aa9d6 100644 --- a/packages/foundation/src/Exceptions/Base/Handler.ts +++ b/packages/foundation/src/Exceptions/Base/Handler.ts @@ -1,14 +1,15 @@ /// -import type { ExceptionConditionCallback, ExceptionConstructor, IHttpContext, IRequest, IResponse } from '@h3ravel/contracts' -import { LimitSpec, RateLimiterAdapter } from '../../Contracts/RateLimiterAdapter' +import type { ExceptionConditionCallback, ExceptionConstructor, IHttpContext, IRequest, IResponse, RateLimiterAdapter, LimitSpec } from '@h3ravel/contracts' import { IExceptionHandler, type RenderExceptionCallback, type ReportExceptionCallback, type ThrottleExceptionCallback } from '@h3ravel/contracts' -import { FileSystem, Console } from '@h3ravel/shared' +import { FileSystem, Console, Logger } from '@h3ravel/shared' import { InMemoryRateLimiter } from '../../Adapters/InMemoryRateLimiter' import { readFileSync } from 'node:fs' import { HttpExceptionFactory } from './HttpExceptionFactory' import { statusTexts } from '../../Http/ResponseUtilities' +import { Str } from '@h3ravel/support' +import { CommandNotFoundException } from '../CommandNotFoundException' /** * @@ -48,7 +49,7 @@ export abstract class Handler extends IExceptionHandler { protected renderCallbacks: RenderExceptionCallback[] = [] /** - * Exception mapping: from constructor -> mapper function (returns instance or new error). + * Exception mapping: from constructor.mapper function (returns instance or new error). */ protected exceptionMap = new Map any>() @@ -192,6 +193,36 @@ export abstract class Handler extends IExceptionHandler { await this.reportThrowable(e) } + /** + * Render an exception to the console. + * + * @param e + */ + renderForConsole (e: Error) { + if (e instanceof CommandNotFoundException) { + let message = Str.of(e.message).explode('.').at(0) ?? '' + const alternatives = e.getAlternatives() + if (alternatives != null) { + message += '. Do you mean one of these?' + + Logger.log(message, 'white') + Logger.parse(alternatives.map(e => ['• ' + e, 'gray']), '\n') + + Logger.log('', 'white') + } else { + Logger.log(message, 'white') + } + + return + } + + const error = this.convertExceptionToArray(e) + Logger.log(`Exception: ${error.exception ?? 'UnknownException'}`, 'white') + Logger.error(error.message ?? 'Unknown Error') + if (error.trace) + Logger.parse(error.trace.map(e => ['• ' + e, 'gray']), '\n') + } + /** * Internal reporting pipeline. * @@ -541,7 +572,7 @@ export abstract class Handler extends IExceptionHandler { * @param e * @returns */ - protected convertExceptionToArray (e: any): Record { + protected convertExceptionToArray (e: any): { message?: string; exception?: string; trace?: string[] } { const debug = this.appDebug() if (!debug) { return { diff --git a/packages/foundation/src/Exceptions/CommandNotFoundException.ts b/packages/foundation/src/Exceptions/CommandNotFoundException.ts new file mode 100644 index 00000000..fd9465c7 --- /dev/null +++ b/packages/foundation/src/Exceptions/CommandNotFoundException.ts @@ -0,0 +1,25 @@ +import { InvalidArgumentException } from '@h3ravel/support' + +/** + * Exception thrown when an incorrect command name typed in the console. + */ +export class CommandNotFoundException extends InvalidArgumentException { + /** + * @param message Exception message to throw + * @param alternatives List of similar defined names + * @param code Exception code + * @param previous Previous exception used for the exception chaining + */ + constructor( + message: string, + private alternatives: string[] = [], + public code = 0, + public previous?: Error, + ) { + super(message) + } + + getAlternatives (): string[] { + return this.alternatives + } +} diff --git a/packages/foundation/src/Exceptions/RouteNotFoundException.ts b/packages/foundation/src/Exceptions/RouteNotFoundException.ts new file mode 100644 index 00000000..4c290ee3 --- /dev/null +++ b/packages/foundation/src/Exceptions/RouteNotFoundException.ts @@ -0,0 +1,7 @@ +import { InvalidArgumentException } from '@h3ravel/support' + +/** + * Exception thrown when a route does not exist. + */ +export class RouteNotFoundException extends InvalidArgumentException implements Error { +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/UrlGenerationException.ts b/packages/foundation/src/Exceptions/UrlGenerationException.ts new file mode 100644 index 00000000..fda2e8ca --- /dev/null +++ b/packages/foundation/src/Exceptions/UrlGenerationException.ts @@ -0,0 +1,22 @@ +import { IRoute } from '@h3ravel/contracts' + +export class UrlGenerationException extends Error { + constructor(message: string) { + super(message) + this.name = 'UrlGenerationException' + } + + static forMissingParameters (route: IRoute, parameters: string[] = []) { + const parameterLabel = parameters.length === 1 ? 'parameter' : 'parameters' + + let message = `Missing required ${parameterLabel} for [Route: ${route.getName()}] [URI: ${route.uri()}]` + + if (parameters.length > 0) { + message += ` [Missing ${parameterLabel}: ${parameters.join(', ')}]` + } + + message += '.' + + return new UrlGenerationException(message) + } +} diff --git a/packages/foundation/src/Http/Events/RequestHandled.ts b/packages/foundation/src/Http/Events/RequestHandled.ts index 7a36ad7f..c2edba51 100644 --- a/packages/foundation/src/Http/Events/RequestHandled.ts +++ b/packages/foundation/src/Http/Events/RequestHandled.ts @@ -1,4 +1,4 @@ -import { IRequest, IResponse } from '@h3ravel/shared' +import { IRequest, IResponse } from '@h3ravel/contracts' export class RequestHandled { /** diff --git a/packages/foundation/src/Http/Kernel.ts b/packages/foundation/src/Http/Kernel.ts index 03aed431..38226063 100644 --- a/packages/foundation/src/Http/Kernel.ts +++ b/packages/foundation/src/Http/Kernel.ts @@ -1,19 +1,21 @@ // namespace Illuminate\Foundation\Http; import { Arr, DateTime, InvalidArgumentException } from '@h3ravel/support' -import { IApplication, IExceptionHandler, IKernel, IMiddleware, IRequest, IResponse, IRouter } from '@h3ravel/contracts' -import { MiddlewareIdentifier, MiddlewareList } from '../Contracts/MiddlewareContract' +import { ConcreteConstructor, IApplication, IBootstraper, IExceptionHandler, IKernel, IMiddleware, IRequest, IResponse, IRouter, MiddlewareIdentifier, MiddlewareList } from '@h3ravel/contracts' +import { Facades } from '@h3ravel/support/facades' import { Injectable } from '..' +import { RegisterFacades } from '../Bootstrapers/RegisterFacades' import { RequestHandled } from './Events/RequestHandled' +import { Terminating } from '../Core/Events/Terminating' @Injectable() export class Kernel extends IKernel { - // use InteractsWithTime; /** * The bootstrap classes for the application. */ - #bootstrappers = [ + #bootstrappers: ConcreteConstructor[] = [ + RegisterFacades ] /** @@ -62,6 +64,7 @@ export class Kernel extends IKernel { protected router: IRouter ) { super() + this.syncMiddlewareToRouter() } @@ -97,9 +100,13 @@ export class Kernel extends IKernel { * @param request */ protected async sendRequestThroughRouter (request: IRequest): Promise { + const { Pipeline } = await import('@h3ravel/router') + this.app.instance('request', request) - const { Pipeline } = await import('@h3ravel/router') + Facades.clearResolvedInstance('request') + + await this.bootstrap() return await (new Pipeline(this.app as never)) .send(request) @@ -112,10 +119,10 @@ export class Kernel extends IKernel { * * @return void */ - public bootstrap () { - // if (!this.app.hasBeenBootstrapped()) { - // this.app.bootstrapWith(this.bootstrappers()); - // } + async bootstrap () { + if (!this.app.hasBeenBootstrapped()) { + await this.app.bootstrapWith(this.bootstrappers()) + } } /** @@ -135,8 +142,8 @@ export class Kernel extends IKernel { * @param request * @param response */ - public terminate (request: IRequest, response: IResponse) { - // this.app.make('app.events').dispatch(new Terminating) + public terminate (request: IRequest, response: IResponse): void { + this.app.make('app.events').dispatch(new Terminating()) this.terminateMiddleware(request, response) @@ -422,14 +429,14 @@ export class Kernel extends IKernel { */ protected syncMiddlewareToRouter () { // TODO: Pay Attention to these - // this.router.middlewarePriority = this.middlewarePriority + this.router.middlewarePriority = this.middlewarePriority for (const [key, middleware] of Object.entries(this.middlewareGroups)) { - this.router.middlewareGroup(key, middleware) + // this.router.middlewareGroup(key, middleware) } - // for (const [key, middleware] of Object.entries(this.middlewareAliases)) { - // this.router.aliasMiddleware(key, middleware) - // } + for (const [key, middleware] of Object.entries(this.middlewareAliases)) { + // this.router.aliasMiddleware(key, middleware) + } } /** diff --git a/packages/foundation/src/Testing/supertestAdapter.ts b/packages/foundation/src/Testing/supertestAdapter.ts index 95f4c443..401437b2 100644 --- a/packages/foundation/src/Testing/supertestAdapter.ts +++ b/packages/foundation/src/Testing/supertestAdapter.ts @@ -18,7 +18,7 @@ export async function supertestAdapter (app?: Application, serviceProviders: Ser if (!app) { const { EventsServiceProvider } = await import(('@h3ravel/events')) const { HttpServiceProvider } = await import(('@h3ravel/http')) - const { RouteServiceProvider } = await import(('@h3ravel/router')) + const { RouteServiceProvider } = await import(('@h3ravel/support')) providers = [EventsServiceProvider, HttpServiceProvider, RouteServiceProvider, ...serviceProviders] } diff --git a/packages/foundation/src/index.ts b/packages/foundation/src/index.ts index 69002191..00ccb575 100644 --- a/packages/foundation/src/index.ts +++ b/packages/foundation/src/index.ts @@ -1,12 +1,15 @@ export * from './Adapters/InMemoryRateLimiter' +export * from './Bootstrapers/BootProviders' +export * from './Bootstrapers/RegisterFacades' export * from './Configuration/AppBuilder' export * from './Configuration/Middleware' -export * from './Container/Inject' -export * from './Contracts/MiddlewareContract' -export * from './Contracts/RateLimiterAdapter' -export * from './Core/ServiceProvider' +export * from './Console/ConsoleKernel' +export * from './Console/logo' +export * from './Console/TsdownConfig' +export * from './Container/Decorators' export * from './Exceptions/AccessDeniedHttpException' export * from './Exceptions/BadRequestHttpException' +export * from './Exceptions/CommandNotFoundException' export * from './Exceptions/ConflictHttpException' export * from './Exceptions/GoneHttpException' export * from './Exceptions/LengthRequiredHttpException' @@ -15,14 +18,21 @@ export * from './Exceptions/NotAcceptableHttpException' export * from './Exceptions/NotFoundHttpException' export * from './Exceptions/PreconditionFailedHttpException' export * from './Exceptions/PreconditionRequiredHttpException' +export * from './Exceptions/RouteNotFoundException' export * from './Exceptions/ServiceUnavailableHttpException' export * from './Exceptions/TooManyRequestsHttpException' export * from './Exceptions/UnprocessableEntityHttpException' export * from './Exceptions/UnsupportedMediaTypeHttpException' +export * from './Exceptions/UrlGenerationException' export * from './Http/Kernel' export * from './Http/MiddlewareHandler' export * from './Http/ResponseUtilities' export * from './Testing/supertestAdapter' +export * from './Console/Commands/BuildCommand' +export * from './Console/Commands/KeyGenerateCommand' +export * from './Console/Commands/MakeCommand' +export * from './Console/Commands/PostinstallCommand' +export * from './Core/Events/Terminating' export * from './Exceptions/Base/ExceptionHandler' export * from './Exceptions/Base/Exceptions' export * from './Exceptions/Base/Handler' diff --git a/packages/foundation/src/views/errors/error.edge b/packages/foundation/src/views/errors/error.edge index f667b8d8..1aa35950 100644 --- a/packages/foundation/src/views/errors/error.edge +++ b/packages/foundation/src/views/errors/error.edge @@ -80,6 +80,11 @@ font-size: 0.85rem; overflow-x: auto; } + + pre.details { + white-space: pre-wrap; + word-wrap: break-word; + } @@ -99,7 +104,7 @@ @if(debug)

Error Details

-
{{ exception?.message }}
+
{{ exception?.message }}
@if(exception?.stack)

Stack Trace

diff --git a/packages/http/src/Middleware.ts b/packages/http/src/Middleware.ts index d3314c3d..50627a57 100644 --- a/packages/http/src/Middleware.ts +++ b/packages/http/src/Middleware.ts @@ -4,7 +4,7 @@ import { Injectable } from '@h3ravel/foundation' @Injectable() export abstract class Middleware extends IMiddleware { - constructor(protected app: IApplication) { + constructor(protected app?: IApplication) { super() } } diff --git a/packages/http/src/Providers/HttpServiceProvider.ts b/packages/http/src/Providers/HttpServiceProvider.ts index 0d6090a4..96701c05 100644 --- a/packages/http/src/Providers/HttpServiceProvider.ts +++ b/packages/http/src/Providers/HttpServiceProvider.ts @@ -1,6 +1,5 @@ /// -import { H3, serve } from 'h3' import { HttpContext, Request, Response } from '..' import { IApplication, IHttpContext, IRequest, IResponse } from '@h3ravel/contracts' @@ -22,15 +21,9 @@ export class HttpServiceProvider { constructor(private app: IApplication) { } register () { - /** Bind HTTP APP to the service container */ - this.app.singleton('http.app', () => { - return new H3() - }) - - /** Bind the HTTP server to the service container */ - this.app.singleton('http.serve', () => serve) - - /** Register Musket Commands */ + /** + * Register Musket Commands + */ this.registeredCommands = [FireCommand] this.app.alias([ diff --git a/packages/http/src/Request.ts b/packages/http/src/Request.ts index e91bd254..5c10de6b 100644 --- a/packages/http/src/Request.ts +++ b/packages/http/src/Request.ts @@ -159,7 +159,7 @@ export class Request< /** * Retrieve all data from the instance (query + body). */ - public all> (keys?: string | string[]): T { + all> (keys?: string | string[]): T { const input = Obj.deepMerge({}, this.input(), this.allFiles()) if (!keys) { @@ -183,11 +183,11 @@ export class Request< * @param defaultValue * @returns */ - public input ( + input ( key?: K, defaultValue?: any ): K extends undefined ? RequestObject : any { - const source = { ...this.getInputSource().all(), ...this.query.all() } + const source = { ...this.getInputSource().all(), ...this._query.all() } return key ? data_get(source, key, defaultValue) : Arr.except(source, ['_method']) } @@ -205,11 +205,11 @@ export class Request< * @param expectArray set to true to return an `UploadedFile[]` array. * @returns */ - public file (): Record; - public file (key?: undefined, defaultValue?: any, expectArray?: true): Record; - public file (key: string, defaultValue?: any, expectArray?: false | undefined): UploadedFile; - public file (key: string, defaultValue?: any, expectArray?: true): UploadedFile[]; - public file (key?: K, defaultValue?: any, expectArray?: E) { + file (): Record; + file (key?: undefined, defaultValue?: any, expectArray?: true): Record; + file (key: string, defaultValue?: any, expectArray?: false | undefined): UploadedFile; + file (key: string, defaultValue?: any, expectArray?: true): UploadedFile[]; + file (key?: K, defaultValue?: any, expectArray?: E) { const files = data_get(this.allFiles(), key!, defaultValue) if (!files) return defaultValue @@ -229,7 +229,7 @@ export class Request< * * @param guard */ - public user (guard?: string): U | undefined { + user (guard?: string): U | undefined { return Reflect.apply(this.getUserResolver(), this, [guard]) } @@ -239,9 +239,9 @@ export class Request< * @param param * @param defaultRoute */ - public route (): IRoute - public route (param?: string, defaultParam?: any): any - public route (param?: string, defaultParam?: any) { + route (): IRoute + route (param?: string, defaultParam?: any): any + route (param?: string, defaultParam?: any) { const route = Reflect.apply(this.getRouteResolver(), this, []) if (typeof route === 'undefined' || !param) { @@ -257,7 +257,7 @@ export class Request< * @param key * @return boolean */ - public hasFile (key: string): boolean { + hasFile (key: string): boolean { let files = this.file(key, undefined, true) if (!Array.isArray(files)) { @@ -279,7 +279,7 @@ export class Request< /** * Get an object with all the files on the request. */ - public allFiles () { + allFiles () { if (this.convertedFiles) return this.convertedFiles const entries = Object @@ -294,7 +294,7 @@ export class Request< /** * Extract and convert uploaded files from FormData. */ - public convertUploadedFiles ( + convertUploadedFiles ( files: Record ): Record { if (!this.formData) @@ -324,7 +324,7 @@ export class Request< /** * Get the current decoded path info for the request. */ - public decodedPath () { + decodedPath () { try { return decodeURIComponent(this.path()) } catch { @@ -338,14 +338,14 @@ export class Request< * @param keys * @returns */ - public has (keys: string[] | string): boolean { + has (keys: string[] | string): boolean { return Obj.has(this.all(), keys) } /** * Determine if the instance is missing a given key. */ - public missing (key: string | string[]) { + missing (key: string | string[]) { const keys = Array.isArray(key) ? key : [key] return !this.has(keys) @@ -357,7 +357,7 @@ export class Request< * @param keys * @returns */ - public only> (keys: string[]): T { + only> (keys: string[]): T { const data = Object.entries(this.all>()).filter(([key]) => keys.includes(key)) return Object.fromEntries(data) as T @@ -366,7 +366,7 @@ export class Request< /** * Determine if the request is over HTTPS. */ - public secure () { + secure () { return this.isSecure() } @@ -376,7 +376,7 @@ export class Request< * @param keys * @returns */ - public except> (keys: string[]): T { + except> (keys: string[]): T { const data = Object.entries(this.all>()).filter(([key]) => !keys.includes(key)) return Object.fromEntries(data) as T @@ -388,7 +388,7 @@ export class Request< * @param input - An object containing key-value pairs to merge. * @returns this - For fluent chaining. */ - public merge (input: Record): this { + merge (input: Record): this { const source = this.getInputSource() for (const [key, value] of Object.entries(input)) { @@ -403,7 +403,7 @@ export class Request< * * @param input */ - public mergeIfMissing (input: Record) { + mergeIfMissing (input: Record) { return this.merge( Object.fromEntries(Object.entries(input).filter(([key]) => this.missing(key))) ) @@ -412,7 +412,7 @@ export class Request< /** * Get the keys for all of the input and files. */ - public keys (): string[] { + keys (): string[] { return [...Object.keys(this.input()), ...this.files.keys()] } @@ -423,7 +423,7 @@ export class Request< * @param defaultValue * @returns a global instance of the current session manager. */ - public session | undefined = undefined> (key?: K, defaultValue?: any): K extends undefined + session | undefined = undefined> (key?: K, defaultValue?: any): K extends undefined ? ISessionManager : K extends string ? any : void | Promise { @@ -455,21 +455,21 @@ export class Request< /** * Get the host name. */ - public host () { + host () { return this.getHost() } /** * Get the HTTP host being requested. */ - public httpHost () { + httpHost () { return this.getHttpHost() } /** * Get the scheme and HTTP host. */ - public schemeAndHttpHost () { + schemeAndHttpHost () { return this.getSchemeAndHttpHost() } @@ -478,7 +478,7 @@ export class Request< * * @return bool */ - public isJson () { + isJson () { return Str.contains(this.getHeader('CONTENT_TYPE') ?? '', ['/json', '+json']) } @@ -487,7 +487,7 @@ export class Request< * * @returns */ - public expectsJson (): boolean { + expectsJson (): boolean { return Str.contains(this.getHeader('Accept') ?? '', 'application/json') } @@ -497,7 +497,7 @@ export class Request< * * @returns */ - public wantsJson (): boolean { + wantsJson (): boolean { const acceptable = this.getAcceptableContentTypes() return !!acceptable[0] && Str.contains(acceptable[0].toLowerCase(), ['/json', '+json']) @@ -508,7 +508,7 @@ export class Request< * * @return bool */ - public pjax () { + pjax () { return this.headers.get('X-PJAX') == true } @@ -518,14 +518,14 @@ export class Request< * @alias isXmlHttpRequest() * @returns {boolean} */ - public ajax (): boolean { + ajax (): boolean { return this.isXmlHttpRequest() } /** * Get the client IP address. */ - public ip (): string | undefined { + ip (): string | undefined { return getRequestIP(this.event) } @@ -536,9 +536,9 @@ export class Request< * @param defaultValue * @returns */ - public async old (): Promise> - public async old (key: string, defaultValue?: any): Promise - public async old (key?: string, defaultValue?: any): Promise { + async old (): Promise> + async old (key: string, defaultValue?: any): Promise + async old (key?: string, defaultValue?: any): Promise { const payload = await this.session().get('_old', {}) if (key) return safeDot(payload, key) || defaultValue @@ -549,7 +549,7 @@ export class Request< /** * Get a URI instance for the request. */ - public uri (): IUrl { + uri (): IUrl { const Url = Reflect.apply(this.app.getUriResolver(), this, [])! return Url.of(this.fullUrl(), this.app) @@ -560,7 +560,7 @@ export class Request< * * @return string */ - public root () { + root (): string { return Str.rtrim(this.getSchemeAndHttpHost() + this.getBaseUrl(), '/') } @@ -569,21 +569,21 @@ export class Request< * * @return string */ - public url () { + url (): string { return Str.rtrim(this.uri().toString().replace(/\?.*/, ''), '/') } /** * Get the full URL for the request. */ - public fullUrl (): string { + fullUrl (): string { return this.event.req.url } /** * Get the current path info for the request. */ - public path (): string { + path (): string { const pattern = (this.getPathInfo() ?? '').replace(/^\/+|\/+$/g, '') return pattern === '' ? '/' : pattern } @@ -591,14 +591,14 @@ export class Request< /** * Return the Request instance. */ - public instance (): this { + instance (): this { return this } /** * Get the request method. */ - public method (): RequestMethod { + method (): RequestMethod { return this.getMethod() } @@ -609,7 +609,7 @@ export class Request< * @param defaultValue * @return {InputBag} */ - public json ( + json ( key?: string, defaultValue?: any ): K extends undefined ? InputBag : any { @@ -632,7 +632,7 @@ export class Request< /** * Get the user resolver callback. */ - public getUserResolver (): ((gaurd?: string) => U | undefined) { + getUserResolver (): ((gaurd?: string) => U | undefined) { return this.userResolver ?? (() => undefined) } @@ -641,7 +641,7 @@ export class Request< * * @param callback */ - public setUserResolver (callback: (gaurd?: string) => U) { + setUserResolver (callback: (gaurd?: string) => U) { this.userResolver = callback return this @@ -650,7 +650,7 @@ export class Request< /** * Get the route resolver callback. */ - public getRouteResolver (): () => IRoute | undefined { + getRouteResolver (): () => IRoute | undefined { return this.routeResolver ?? (() => undefined) } @@ -659,12 +659,111 @@ export class Request< * * @param callback */ - public setRouteResolver (callback: () => IRoute) { + setRouteResolver (callback: () => IRoute) { this.routeResolver = callback return this } + /** + * Get the bearer token from the request headers. + */ + bearerToken (): string | undefined { + let header = this.header('Authorization', '') + + const position = header.toLowerCase().lastIndexOf('bearer ') + + if (position !== -1) { + header = header.slice(position + 7) + + const commaIndex = header.indexOf(',') + + return commaIndex !== -1 + ? header.slice(0, commaIndex) + : header + } + + return undefined + } + + /** + * Retrieve data from the instance. + * + * @param key + * @param defaultValue + */ + protected data (key?: string, defaultValue?: any) { + return this.input(key, defaultValue) + } + + /** + * Retrieve a request payload item from the request. + * + * @param key + * @param default + */ + post (key?: string, defaultValue?: any) { + return this.retrieveItem('request', key, defaultValue) + } + + /** + * Determine if a header is set on the request. + * + * @param key + */ + hasHeader (key: string) { + return this.header(key) != null + } + + /** + * Retrieve a header from the request. + * + * @param key + * @param default + */ + header (key?: string, defaultValue?: any) { + return this.retrieveItem('headers', key, defaultValue) + } + + /** + * Determine if a cookie is set on the request. + * + * @param string $key + */ + hasCookie (key: string) { + return this.cookie(key) != null + } + + /** + * Retrieve a cookie from the request. + * + * @param key + * @param default + */ + cookie (key?: string, defaultValue?: any) { + return this.retrieveItem('cookies', key, defaultValue) + } + + /** + * Retrieve a query string item from the request. + * + * @param key + * @param default + */ + query (key?: string, defaultValue?: any) { + return this.retrieveItem('_query', key, defaultValue) + } + + /** + * Retrieve a server variable from the request. + * + * @param key + * @param default + */ + server (key?: string, defaultValue?: any) { + return this.retrieveItem('_server', key, defaultValue) + } + /** * Get the input source for the request. * @@ -675,7 +774,30 @@ export class Request< return this.json() } - return ['GET', 'HEAD'].includes(this.getRealMethod()) ? this.query : this.request + return ['GET', 'HEAD'].includes(this.getRealMethod()) ? this._query : this.request + } + + /** + * Retrieve a parameter item from a given source. + * + * @param source + * @param key + * @param defaultValue + */ + protected retrieveItem ( + source: 'cookies' | '_server' | 'request' | '_query' | 'headers' | 'files' | 'attributes', + key?: string, + defaultValue?: any + ) { + if (key == null) { + return this[source].all() + } + + if (this[source] instanceof InputBag) { + return this[source].all()[key] ?? defaultValue + } + + return this[source].get(key, defaultValue) } /** @@ -683,7 +805,7 @@ export class Request< * * @param keys */ - public dump (...keys: any[]): this { + dump (...keys: any[]): this { if (keys.length > 0) this.only(keys).then(dump) else this.all().then(dump) diff --git a/packages/http/src/Utilities/HttpRequest.ts b/packages/http/src/Utilities/HttpRequest.ts index ec5261fc..5051c088 100644 --- a/packages/http/src/Utilities/HttpRequest.ts +++ b/packages/http/src/Utilities/HttpRequest.ts @@ -114,12 +114,12 @@ export class HttpRequest { /** * Query string parameters (GET). */ - public query!: InputBag + public _query!: InputBag /** * Server and execution environment parameters */ - public server!: ServerBag + public _server!: ServerBag /** * Cookies @@ -190,12 +190,12 @@ export class HttpRequest { protected buildRequirements () { this.params = getRouterParams(this.event) this.request = new InputBag(this.formData ? this.formData.input() : {}, this.event) - this.query = new InputBag(getQuery(this.event), this.event) + this._query = new InputBag(getQuery(this.event), this.event) this.attributes = new ParamBag(getRouterParams(this.event), this.event) this.cookies = new InputBag(parseCookies(this.event), this.event) this.files = new FileBag(this.formData ? this.formData.files() : {}, this.event) - this.server = new ServerBag(Object.fromEntries(this.event.req.headers.entries()), this.event) - this.headers = new HeaderBag(this.server.getHeaders()) + this._server = new ServerBag(Object.fromEntries(this.event.req.headers.entries()), this.event) + this.headers = new HeaderBag(this._server.getHeaders()) this.acceptableContentTypes = [] // this.languages = undefined // this.charsets = undefined @@ -352,14 +352,14 @@ export class HttpRequest { */ protected prepareRequestUri (): string { let requestUri = '' - // console.log(this.server.all()) + // console.log(this._server.all()) // IIS-style URL rewrite could be behind a header like x-original-url - const unencodedUrl = this.server.get('x-original-url') ?? '' + const unencodedUrl = this._server.get('x-original-url') ?? '' if (this.isIisRewrite() && unencodedUrl) { requestUri = unencodedUrl - this.server.remove('x-original-url') - } else if (this.server.has('REQUEST_URI')) { - requestUri = this.server.get('REQUEST_URI') ?? '' + this._server.remove('x-original-url') + } else if (this._server.has('REQUEST_URI')) { + requestUri = this._server.get('REQUEST_URI') ?? '' if (requestUri && requestUri[0] === '/') { // Remove fragment @@ -385,7 +385,7 @@ export class HttpRequest { } // normalize the request URI for future use - this.server.set('REQUEST_URI', requestUri) + this._server.set('REQUEST_URI', requestUri) return requestUri } @@ -399,7 +399,7 @@ export class HttpRequest { return '' } - const scriptFilename = this.server.get('SCRIPT_FILENAME') ?? '' + const scriptFilename = this._server.get('SCRIPT_FILENAME') ?? '' const filename = path.basename(scriptFilename) let basePath: string @@ -470,7 +470,7 @@ export class HttpRequest { } else if (this.isFromTrustedProxy() && (host = this.getTrustedValues(HttpRequest.HEADER_X_FORWARDED_HOST))) { host = host[0] } else if (!(host = this.headers.get('HOST'))) { - return this.server.get('SERVER_PORT') + return this._server.get('SERVER_PORT') } if (host[0] === '[') { @@ -495,7 +495,7 @@ export class HttpRequest { if (this.isFromTrustedProxy() && (host = this.getTrustedValues(HttpRequest.HEADER_X_FORWARDED_HOST)?.[0])) { // do nothing, host already assigned } else if (!(host = this.headers.get('HOST'))) { - host = this.server.get('SERVER_NAME') ?? this.server.get('SERVER_ADDR') ?? process.env.SERVER_NAME ?? '' + host = this._server.get('SERVER_NAME') ?? this._server.get('SERVER_ADDR') ?? process.env.SERVER_NAME ?? '' } /* trim and remove port number, lowercase */ @@ -552,7 +552,7 @@ export class HttpRequest { return ['https', 'on', 'ssl', '1'].includes(proto[0]?.toLowerCase()) } - const https = this.server.get('HTTPS') + const https = this._server.get('HTTPS') return !!https && 'off' !== https.toLowerCase() } @@ -566,9 +566,9 @@ export class HttpRequest { */ private isIisRewrite (): boolean { try { - if (1 === this.server.getInt('IIS_WasUrlRewritten')) { + if (1 === this._server.getInt('IIS_WasUrlRewritten')) { this.#isIisRewrite = true - this.server.remove('IIS_WasUrlRewritten') + this._server.remove('IIS_WasUrlRewritten') } } catch { /** */ } @@ -699,7 +699,7 @@ export class HttpRequest { let method = this.event.req.headers.get('X-HTTP-METHOD-OVERRIDE') as RequestMethod if (!method && HttpRequest.httpMethodParameterOverride) { - method = this.request.get('_method', this.query.get('_method', 'POST')) as RequestMethod + method = this.request.get('_method', this._query.get('_method', 'POST')) as RequestMethod } if (typeof method !== 'string') { @@ -909,8 +909,8 @@ export class HttpRequest { return result } - if (this.query.has(key)) { - return this.query.all()[key] + if (this._query.has(key)) { + return this._query.all()[key] } if (this.request.has(key)) { @@ -927,7 +927,7 @@ export class HttpRequest { * contents of a proxy-specific header. */ public isFromTrustedProxy (): boolean { - return !HttpRequest.trustedProxies?.length && IpUtils.checkIp(this.server.get('REMOTE_ADDR')!, HttpRequest.trustedProxies) + return !HttpRequest.trustedProxies?.length && IpUtils.checkIp(this._server.get('REMOTE_ADDR')!, HttpRequest.trustedProxies) } /** diff --git a/packages/http/src/Utilities/HttpResponse.ts b/packages/http/src/Utilities/HttpResponse.ts index 207099e8..15b585e8 100644 --- a/packages/http/src/Utilities/HttpResponse.ts +++ b/packages/http/src/Utilities/HttpResponse.ts @@ -742,7 +742,7 @@ export class HttpResponse extends IHttpResponse { } // 6. Fix protocol - const protocol = request.server?.get('SERVER_PROTOCOL') || 'HTTP/1.1' + const protocol = request._server?.get('SERVER_PROTOCOL') || 'HTTP/1.1' if (protocol !== 'HTTP/1.0') { this.setProtocolVersion('1.1') } diff --git a/packages/http/src/Utilities/InputBag.ts b/packages/http/src/Utilities/InputBag.ts index 90097f26..74216e9b 100644 --- a/packages/http/src/Utilities/InputBag.ts +++ b/packages/http/src/Utilities/InputBag.ts @@ -2,7 +2,7 @@ import { BadRequestException } from '../Exceptions/BadRequestException' import { H3Event } from 'h3' import { Obj } from '@h3ravel/support' import { ParamBag } from './ParamBag' -import { RequestObject } from '@h3ravel/shared' +import { RequestObject } from '@h3ravel/contracts' /** * InputBag is a container for user input values @@ -29,7 +29,7 @@ export class InputBag extends ParamBag { * @throws BadRequestException if the input contains a non-scalar value * @returns */ - public get ( + get ( key: string, defaultValue: T | null = null ): T | string | number | boolean | null { @@ -66,7 +66,7 @@ export class InputBag extends ParamBag { * @param inputs * @returns */ - public replace (inputs: RequestObject = {}): void { + replace (inputs: RequestObject = {}): void { this.parameters = {} this.add(inputs) } @@ -77,7 +77,7 @@ export class InputBag extends ParamBag { * @param inputs * @returns */ - public add (inputs: RequestObject = {}): void { + add (inputs: RequestObject = {}): void { Object.entries(inputs).forEach(([key, value]) => this.set(key, value)) } @@ -89,7 +89,7 @@ export class InputBag extends ParamBag { * @throws TypeError if value is not scalar or array * @returns */ - public set (key: string, value: any): void { + set (key: string, value: any): void { if ( value !== null && typeof value !== 'string' && @@ -112,7 +112,7 @@ export class InputBag extends ParamBag { * @param key * @returns */ - public has (key: string): boolean { + has (key: string): boolean { return Object.prototype.hasOwnProperty.call(this.parameters, key) } @@ -121,7 +121,7 @@ export class InputBag extends ParamBag { * * @returns */ - public all (): RequestObject { + all (): RequestObject { return { ...this.parameters } } @@ -133,7 +133,7 @@ export class InputBag extends ParamBag { * @throws BadRequestException if input contains a non-scalar value * @returns */ - public getString (key: string, defaultValue = ''): string { + getString (key: string, defaultValue = ''): string { const value = this.get(key, defaultValue) return String(value ?? '') } @@ -148,7 +148,7 @@ export class InputBag extends ParamBag { * @throws BadRequestException if validation fails * @returns */ - public filter ( + filter ( key: string, defaultValue: T | null = null, filterFn?: (value: any) => boolean @@ -179,7 +179,7 @@ export class InputBag extends ParamBag { * @throws BadRequestException if conversion fails * @returns */ - public getEnum> ( + getEnum> ( key: string, EnumClass: T, defaultValue: T[keyof T] | null = null @@ -202,7 +202,7 @@ export class InputBag extends ParamBag { * * @param key */ - public remove (key: string): void { + remove (key: string): void { delete this.parameters[key] } @@ -211,7 +211,7 @@ export class InputBag extends ParamBag { * * @returns */ - public keys (): string[] { + keys (): string[] { return Object.keys(this.parameters) } @@ -220,7 +220,7 @@ export class InputBag extends ParamBag { * * @returns */ - public count (): number { + count (): number { return this.keys().length } } diff --git a/packages/http/tests/Request.spec.ts b/packages/http/tests/Request.spec.ts index 24fb7588..a3fb5046 100644 --- a/packages/http/tests/Request.spec.ts +++ b/packages/http/tests/Request.spec.ts @@ -196,7 +196,7 @@ describe('Request', () => { // create request and then tweak query to simulate GET params const req = await Request.create(event, app as any) // simulate query bag contents by directly setting query (InputBag exposes all()) - ; (req as any).query = new InputBag({ q: '1' }, event) + ; (req as any)._query = new InputBag({ q: '1' }, event) const merged = (req as any).all() expect(merged).toEqual(expect.objectContaining({ foo: 'bar', q: '1' })) }) @@ -209,7 +209,7 @@ describe('Request', () => { text: async () => '', }) const getReq = await Request.create(getEvent, app as any) - ; (getReq as any).query = new InputBag({ a: 'q' }, getEvent) + ; (getReq as any)._query = new InputBag({ a: 'q' }, getEvent) expect(getReq.input()).toEqual(expect.objectContaining({ a: 'q' })) // POST request @@ -378,7 +378,7 @@ describe('Request', () => { }) const req = await Request.create(event, app as any) ; (req as any).attributes = new ParamBag({ routeParam: 'rp' }, event) - ; (req as any).query = new InputBag({ q: '1' }, event) + ; (req as any)._query = new InputBag({ q: '1' }, event) ; (req as any).request = new InputBag({ bodyKey: 'b' }, event) expect(req.get('routeParam')).toBe('rp') diff --git a/packages/router/package.json b/packages/router/package.json index 498519f4..9dafaab6 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -3,10 +3,7 @@ "version": "1.13.6", "description": "Route facade, decorators and controller system for H3ravel.", "h3ravel": { - "providers": [ - "RouteServiceProvider", - "AssetsServiceProvider" - ] + "providers": [] }, "type": "module", "main": "./dist/index.cjs", diff --git a/packages/router/src/AbstractRouteCollection.ts b/packages/router/src/AbstractRouteCollection.ts index e453aec5..83cceeeb 100644 --- a/packages/router/src/AbstractRouteCollection.ts +++ b/packages/router/src/AbstractRouteCollection.ts @@ -40,11 +40,15 @@ export abstract class AbstractRouteCollection implements IAbstractRouteCollectio ) } - /* + /** * Final handler for a matched route. Responsible for: * - Throwing for not found * - Throwing for method not allowed * - Attaching params extracted from the match + * + * @param req + * @param route + * @returns */ protected handleMatchedRoute (req: Request, route?: Route | null): Route { if (route) { diff --git a/packages/router/src/CallableDispatcher.ts b/packages/router/src/CallableDispatcher.ts index 7c6ca70b..c8c492c6 100644 --- a/packages/router/src/CallableDispatcher.ts +++ b/packages/router/src/CallableDispatcher.ts @@ -2,18 +2,17 @@ import { CallableConstructor, ICallableDispatcher } from '@h3ravel/contracts' import { Application } from '@h3ravel/core' import { Route } from './Route' -import { RouteDependencyResolver } from './TraitLike/RouteDependencyResolver' +import { RouteDependencyResolver } from './Traits/RouteDependencyResolver' +import { mix } from '@h3ravel/shared' -export class CallableDispatcher extends ICallableDispatcher { - resolver: RouteDependencyResolver +export class CallableDispatcher extends mix(ICallableDispatcher, RouteDependencyResolver) { /** * * @param container The container instance. */ public constructor(protected container: Application) { - super() - this.resolver = new RouteDependencyResolver(container) + super(container) } /** @@ -34,7 +33,7 @@ export class CallableDispatcher extends ICallableDispatcher { * @param handler */ protected resolveParameters (route: Route) { - return this.resolver.resolveMethodDependencies( + return this.resolveMethodDependencies( route.parametersWithoutNulls() ) } diff --git a/packages/router/src/Commands/RouteListCommand.ts b/packages/router/src/Commands/RouteListCommand.ts index 74386be5..6f0e15d5 100644 --- a/packages/router/src/Commands/RouteListCommand.ts +++ b/packages/router/src/Commands/RouteListCommand.ts @@ -1,7 +1,7 @@ -import { Logger, LoggerChalk, RouteMethod } from '@h3ravel/shared' +import { ClassicRouteDefinition, RouteMethod } from '@h3ravel/contracts' +import { Logger, LoggerChalk } from '@h3ravel/shared' import { Application } from '@h3ravel/core' -import { ClassicRouteDefinition } from '@h3ravel/contracts' import { Command } from '@h3ravel/musket' export class RouteListCommand extends Command { diff --git a/packages/router/src/ControllerDispatcher.ts b/packages/router/src/ControllerDispatcher.ts index 2124fb90..695d98d1 100644 --- a/packages/router/src/ControllerDispatcher.ts +++ b/packages/router/src/ControllerDispatcher.ts @@ -1,21 +1,23 @@ -import { ControllerMethod, IController, IControllerDispatcher, RouteMethod } from '@h3ravel/contracts' +import { IController, IControllerDispatcher, IMiddleware, ResourceMethod, RouteMethod } from '@h3ravel/contracts' import { Application } from '@h3ravel/core' import { Collection } from '@h3ravel/support' -import { FiltersControllerMiddleware } from './TraitLike/FiltersControllerMiddleware' +import { FiltersControllerMiddleware } from './Traits/FiltersControllerMiddleware' import { Route } from './Route' -import { RouteDependencyResolver } from './TraitLike/RouteDependencyResolver' - -export class ControllerDispatcher extends IControllerDispatcher { - resolver: RouteDependencyResolver +import { RouteDependencyResolver } from './Traits/RouteDependencyResolver' +import { mix } from '@h3ravel/shared' +export class ControllerDispatcher extends mix( + IControllerDispatcher, + RouteDependencyResolver, + FiltersControllerMiddleware +) { /** * * @param container The container instance. */ public constructor(protected container: Application) { - super() - this.resolver = new RouteDependencyResolver(container) + super(container) } /** @@ -25,7 +27,7 @@ export class ControllerDispatcher extends IControllerDispatcher { * @param controller * @param method */ - public async dispatch (route: Route, controller: Required, method: ControllerMethod) { + public async dispatch (route: Route, controller: Required, method: ResourceMethod) { const parameters = await this.resolveParameters(route, controller, method) if (Object.prototype.hasOwnProperty.call(controller, 'callAction')) { @@ -42,8 +44,8 @@ export class ControllerDispatcher extends IControllerDispatcher { * @param controller * @param method */ - protected async resolveParameters (route: Route, controller: IController, method: ControllerMethod) { - return this.resolver.resolveClassMethodDependencies( + protected async resolveParameters (route: Route, controller: IController, method: ResourceMethod) { + return this.resolveClassMethodDependencies( route.parametersWithoutNulls(), controller, method ) } @@ -60,8 +62,8 @@ export class ControllerDispatcher extends IControllerDispatcher { } return (new Collection(controller.getMiddleware())) - .reject((data) => FiltersControllerMiddleware.methodExcludedByOptions(method, data.options)) + .reject((data) => ControllerDispatcher.methodExcludedByOptions(method, data.options)) .pluck('middleware') - .all() + .all() as never } } diff --git a/packages/router/src/CreatesRegularExpressionRouteConstraints.ts b/packages/router/src/CreatesRegularExpressionRouteConstraints.ts new file mode 100644 index 00000000..f355963b --- /dev/null +++ b/packages/router/src/CreatesRegularExpressionRouteConstraints.ts @@ -0,0 +1,78 @@ +import { Collection } from '@h3ravel/support' +import { trait } from '@h3ravel/shared' + +export const CreatesRegularExpressionRouteConstraints = trait(Base => { + return class CreatesRegularExpressionRouteConstraints extends Base { + where (_wheres: any): this { return this } + + /** + * Specify that the given route parameters must be alphabetic. + * + * @param parameters + */ + whereAlpha (parameters: string | string[]) { + return this.assignExpressionToParameters(parameters, '[a-zA-Z]+') + } + + /** + * Specify that the given route parameters must be alphanumeric. + * + * @param parameters + */ + whereAlphaNumeric (parameters: string | string[]) { + return this.assignExpressionToParameters(parameters, '[a-zA-Z0-9]+') + } + + /** + * Specify that the given route parameters must be numeric. + * + * @param parameters + */ + whereNumber (parameters: string | string[]) { + return this.assignExpressionToParameters(parameters, '[0-9]+') + } + + /** + * Specify that the given route parameters must be ULIDs. + * + * @param parameters + */ + whereUlid (parameters: string | string[]) { + return this.assignExpressionToParameters(parameters, '[0-7][0-9a-hjkmnp-tv-zA-HJKMNP-TV-Z]{25}') + } + + /** + * Specify that the given route parameters must be UUIDs. + * + * @param parameters + */ + whereUuid (parameters: string | string[]) { + return this.assignExpressionToParameters(parameters, '[da-fA-F]{8}-[da-fA-F]{4}-[da-fA-F]{4}-[da-fA-F]{4}-[da-fA-F]{12}') + } + + /** + * Specify that the given route parameters must be one of the given values. + * + * @param parameters + * @param values + */ + whereIn (parameters: string | string[], values: any[]) { + return this.assignExpressionToParameters(parameters, (new Collection(values)) + .map((value) => value) + .implode('|') + ) + } + + /** + * Apply the given regular expression to the given parameters. + * + * @param parameters + * @param expression + */ + assignExpressionToParameters (parameters: string | string[], expression: string) { + return this.where(Collection.wrap(parameters) + .mapWithKeys((parameter) => ({ [parameter as string]: expression } as never)) + .all()) + } + } +}) \ No newline at end of file diff --git a/packages/router/src/Middleware/SubstituteBindings.ts b/packages/router/src/Middleware/SubstituteBindings.ts index be5c3106..5d1f5f40 100644 --- a/packages/router/src/Middleware/SubstituteBindings.ts +++ b/packages/router/src/Middleware/SubstituteBindings.ts @@ -19,7 +19,7 @@ export class SubstituteBindings extends Middleware { async handle (request: Request, next: (request: Request) => Promise) { const route = request.route() - + console.log(route, '----') try { this.router.substituteBindings(route) this.router.substituteImplicitBindings(route) diff --git a/packages/router/src/PendingResourceRegistration.ts b/packages/router/src/PendingResourceRegistration.ts new file mode 100644 index 00000000..6f72f405 --- /dev/null +++ b/packages/router/src/PendingResourceRegistration.ts @@ -0,0 +1,289 @@ +import { Arr, Macroable } from '@h3ravel/support' +import { IController, MiddlewareIdentifier, MiddlewareList, ResourceMethod, ResourceOptions } from '@h3ravel/contracts' + +import { CreatesRegularExpressionRouteConstraints } from './CreatesRegularExpressionRouteConstraints' +import { ResourceRegistrar } from './ResourceRegistrar' +import { RouteCollection } from './RouteCollection' +import { Router } from './Router' +import { use } from '@h3ravel/shared' +import variadic from 'packages/support/src/Helpers' + +export class PendingResourceRegistration extends use( + CreatesRegularExpressionRouteConstraints, + Macroable, +) { + + /** + * The resource registrar. + */ + protected registrar: ResourceRegistrar + + /** + * The resource name. + */ + protected name: string + + /** + * The resource controller. + */ + protected controller: typeof IController + + /** + * The resource options. + */ + protected options: ResourceOptions = {} + + /** + * The resource's registration status. + */ + protected registered = false + + /** + * Create a new pending resource registration instance. + * + * @param registrar + * @param name + * @param controller + * @param options + */ + constructor(registrar: ResourceRegistrar, name: string, controller: typeof IController, options: ResourceOptions) { + super() + this.name = name + this.options = options + this.registrar = registrar + this.controller = controller + } + + /** + * Set the methods the controller should apply to. + * + * @param methods + */ + only (...methods: ResourceMethod[]): this { + this.options.only = variadic(methods) + + return this + } + + /** + * Set the methods the controller should exclude. + * + * @param methods + */ + except (...methods: ResourceMethod[]): this { + this.options.except = variadic(methods) + + return this + } + + /** + * Set the route names for controller actions. + * + * @param names + */ + names (names: Record): this { + this.options.names = names + + return this + } + + /** + * Set the route name for a controller action. + * + * @param method + * @param name + */ + setName (method: string, name: string): this { + if (this.options.names) { + this.options.names[method] = name + } else { + this.options.names = { [method]: name } + } + + return this + } + + /** + * Override the route parameter names. + * + * @param parameters + */ + parameters (parameters: any): this { + this.options.parameters = parameters + + return this + } + + /** + * Override a route parameter's name. + * + * @param previous + * @param newValue + */ + parameter (previous: string, newValue: any): this { + this.options.parameters[previous] = newValue + + return this + } + + /** + * Add middleware to the resource routes. + * + * @param middleware + */ + middleware (middleware: MiddlewareList | MiddlewareIdentifier): this { + const middlewares = Arr.wrap(middleware) + + for (let key = 0; key < middlewares.length; key++) { + const value = middlewares[key] + middlewares[key] = value + } + + this.options.middleware = middlewares + + if (typeof this.options.middleware_for !== 'undefined') { + for (const [method, value] of Object.entries(this.options.middleware_for ?? {})) { + this.options.middleware_for[method] = Router.uniqueMiddleware([ + ...Arr.wrap(value), + ...middlewares + ]) + } + } + + return this + } + + /** + * Specify middleware that should be added to the specified resource routes. + * + * @param methods + * @param middleware + */ + middlewareFor (methods: ResourceMethod[], middleware: MiddlewareList | MiddlewareIdentifier) { + methods = Arr.wrap(methods) + let middlewares = Arr.wrap(middleware) + + if (typeof this.options.middleware !== 'undefined') { + middlewares = Router.uniqueMiddleware([ + ...(this.options.middleware ?? []), + ...middlewares + ]) + } + + for (const method of methods) { + if (this.options.middleware_for) { + this.options.middleware_for[method] = middlewares + } else { + this.options.middleware_for = { [method]: middlewares } + } + } + + return this + } + + /** + * Specify middleware that should be removed from the resource routes. + * + * @param middleware + */ + withoutMiddleware (middleware: MiddlewareList | MiddlewareIdentifier): this { + this.options.excluded_middleware = [ + ...(this.options.excluded_middleware ?? []), + ...Arr.wrap(middleware) + ] + + return this + } + + /** + * Specify middleware that should be removed from the specified resource routes. + * + * @param methods + * @param middleware + */ + withoutMiddlewareFor (methods: ResourceMethod[], middleware: MiddlewareList | MiddlewareIdentifier): this { + methods = Arr.wrap(methods) + const middlewares = Arr.wrap(middleware) + + for (const method of methods) { + if (this.options.excluded_middleware_for) { + this.options.excluded_middleware_for[method] = middlewares + } else { + this.options.excluded_middleware_for = { [method]: middlewares } + } + } + + return this + } + + /** + * Add "where" constraints to the resource routes. + * + * @param wheres + */ + where (wheres: any): this { + this.options.wheres = wheres + + return this + } + + /** + * Indicate that the resource routes should have "shallow" nesting. + * + * @param shallow + */ + shallow (shallow = true): this { + this.options.shallow = shallow + + return this + } + + /** + * Define the callable that should be invoked on a missing model exception. + * + * @param callback + */ + missing (callback: string): this { + this.options.missing = callback + + return this + } + + /** + * Indicate that the resource routes should be scoped using the given binding fields. + * + * @param fields + */ + scoped (fields: string[] = []): this { + this.options.bindingFields = fields + + return this + } + + /** + * Define which routes should allow "trashed" models to be retrieved when resolving implicit model bindings. + * + * @param array methods + */ + withTrashed (methods = []): this { + this.options['trashed'] = methods + + return this + } + + /** + * Register the singleton resource route. + */ + register (): RouteCollection | undefined { + this.registered = true + + return this.registrar.register( + this.name, this.controller, this.options + ) + } + + $finalize (e?: this) { + if (!this.registered) (e ?? this).register() + return this ?? e + } +} diff --git a/packages/router/src/PendingSingletonResourceRegistration.ts b/packages/router/src/PendingSingletonResourceRegistration.ts new file mode 100644 index 00000000..abe60156 --- /dev/null +++ b/packages/router/src/PendingSingletonResourceRegistration.ts @@ -0,0 +1,268 @@ +import { Arr, Macroable } from '@h3ravel/support' +import { Finalizable, use } from '@h3ravel/shared' +import { IController, MiddlewareIdentifier, MiddlewareList, ResourceMethod, ResourceOptions } from '@h3ravel/contracts' + +import { CreatesRegularExpressionRouteConstraints } from './CreatesRegularExpressionRouteConstraints' +import { ResourceRegistrar } from './ResourceRegistrar' +import { RouteCollection } from './RouteCollection' +import { Router } from './Router' +import variadic from 'packages/support/src/Helpers' + +export class PendingSingletonResourceRegistration extends use( + Finalizable, + CreatesRegularExpressionRouteConstraints, + Macroable, +) { + + /** + * The resource registrar. + */ + protected registrar: ResourceRegistrar + + /** + * The resource name. + */ + protected name: string + + /** + * The resource controller. + */ + protected controller: typeof IController + + /** + * The resource options. + */ + protected options: ResourceOptions = {} + + /** + * The resource's registration status. + */ + protected registered = false + + /** + * Create a new pending singleton resource registration instance. + * + * @param registrar + * @param name + * @param controller + * @param options + */ + constructor(registrar: ResourceRegistrar, name: string, controller: typeof IController, options: ResourceOptions) { + super() + this.name = name + this.options = options + this.registrar = registrar + this.controller = controller + } + + /** + * Set the methods the controller should apply to. + * + * @param methods + */ + only (...methods: ResourceMethod[]): this { + this.options.only = variadic(methods) + + return this + } + + /** + * Set the methods the controller should exclude. + * + * @param methods + */ + except (...methods: ResourceMethod[]): this { + this.options.except = variadic(methods) + + return this + } + + /** + * Indicate that the resource should have creation and storage routes. + * + * @return this + */ + creatable () { + this.options.creatable = true + + return this + } + + /** + * Indicate that the resource should have a deletion route. + * + * @return this + */ + destroyable () { + this.options.destroyable = true + + return this + } + + /** + * Set the route names for controller actions. + * + * @param names + */ + names (names: Record): this { + this.options.names = names + + return this + } + + /** + * Set the route name for a controller action. + * + * @param method + * @param name + */ + setName (method: string, name: string): this { + if (this.options.names) { + this.options.names[method] = name + } else { + this.options.names = { [method]: name } + } + + return this + } + + /** + * Override the route parameter names. + * + * @param parameters + */ + parameters (parameters: any): this { + this.options.parameters = parameters + + return this + } + + /** + * Override a route parameter's name. + * + * @param previous + * @param newValue + */ + parameter (previous: string, newValue: any): this { + this.options.parameters[previous] = newValue + + return this + } + + /** + * Add middleware to the resource routes. + * + * @param middleware + */ + middleware (middleware: MiddlewareList | MiddlewareIdentifier): this { + const middlewares = Arr.wrap(middleware) + + for (let key = 0; key < middlewares.length; key++) { + const value = middlewares[key] + middlewares[key] = value + } + + this.options.middleware = middlewares + + if (typeof this.options.middleware_for !== 'undefined') { + for (const [method, value] of Object.entries(this.options.middleware_for ?? {})) { + this.options.middleware_for[method] = Router.uniqueMiddleware([ + ...Arr.wrap(value), + ...middlewares + ]) + } + } + + return this + } + + /** + * Specify middleware that should be added to the specified resource routes. + * + * @param methods + * @param middleware + */ + middlewareFor (methods: ResourceMethod[], middleware: MiddlewareList | MiddlewareIdentifier) { + methods = Arr.wrap(methods) + let middlewares = Arr.wrap(middleware) + + if (typeof this.options.middleware !== 'undefined') { + middlewares = Router.uniqueMiddleware([ + ...(this.options.middleware ?? []), + ...middlewares + ]) + } + + for (const method of methods) { + if (this.options.middleware_for) { + this.options.middleware_for[method] = middlewares + } else { + this.options.middleware_for = { [method]: middlewares } + } + } + + return this + } + + /** + * Specify middleware that should be removed from the resource routes. + * + * @param middleware + */ + withoutMiddleware (middleware: MiddlewareList | MiddlewareIdentifier): this { + this.options.excluded_middleware = [ + ...(this.options.excluded_middleware ?? []), + ...Arr.wrap(middleware) + ] + + return this + } + + /** + * Specify middleware that should be removed from the specified resource routes. + * + * @param methods + * @param middleware + */ + withoutMiddlewareFor (methods: ResourceMethod[], middleware: MiddlewareList | MiddlewareIdentifier): this { + methods = Arr.wrap(methods) + const middlewares = Arr.wrap(middleware) + + for (const method of methods) { + if (this.options.excluded_middleware_for) { + this.options.excluded_middleware_for[method] = middlewares + } else { + this.options.excluded_middleware_for = { [method]: middlewares } + } + } + + return this + } + + /** + * Add "where" constraints to the resource routes. + * + * @param wheres + */ + where (wheres: any): this { + this.options.wheres = wheres + + return this + } + + /** + * Register the singleton resource route. + */ + register (): RouteCollection | undefined { + this.registered = true + + return this.registrar.singleton( + this.name, this.controller, this.options + ) + } + + $finalize (e?: this) { + if (!this.registered) (e ?? this).register() + return this ?? e + } +} diff --git a/packages/router/src/Pipeline.ts b/packages/router/src/Pipeline.ts index acb05f90..17b06ed8 100644 --- a/packages/router/src/Pipeline.ts +++ b/packages/router/src/Pipeline.ts @@ -1,6 +1,7 @@ import { Container, ContainerResolver } from '@h3ravel/core' import { CallableConstructor } from '@h3ravel/contracts' +import { Logger } from '@h3ravel/shared' import { Pipe } from './Contracts/Utilities' import { RuntimeException } from '@h3ravel/support' @@ -118,8 +119,14 @@ export class Pipeline { if (typeof pipe === 'string') { const [name, extras] = this.parsePipeString(pipe) const bound = this.getContainer().boundMiddlewares(name) - instance = this.getContainer().make(bound as never) - parameters = [passable, stack, ...extras] + if (bound) { + instance = this.getContainer().make(bound as never) + parameters = [passable, stack, ...extras] + } else { + instance = () => { + Logger.error(`Error: Middleware [${name}] not bound: Skipping...`, false) + } + } // Pipe is an object instance } else if (typeof pipe === 'function') { diff --git a/packages/router/src/Providers/RouteServiceProvider.ts b/packages/router/src/Providers/RouteServiceProvider.ts deleted file mode 100644 index 7cbf149f..00000000 --- a/packages/router/src/Providers/RouteServiceProvider.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { IRouter } from '@h3ravel/contracts' -import { Logger } from '@h3ravel/shared' -import { RouteListCommand } from '../Commands/RouteListCommand' -import { Router } from '../Router' -import { ServiceProvider } from '@h3ravel/core' -import { SubstituteBindings } from '../Middleware/SubstituteBindings' -import path from 'node:path' -import { readdir } from 'node:fs/promises' - -/** - * Handles routing registration - * - * Load route files (web.ts, api.ts). - * Map controllers to routes. - * Register route-related middleware. - * - * Auto-Registered - */ -export class RouteServiceProvider extends ServiceProvider { - public static priority = 997 - - register () { - this.app.bindMiddleware('SubstituteBindings', SubstituteBindings) - - this.booted(() => { - const router = this.app.make(IRouter) - if (typeof router.getRoutes === 'function') { - router.getRoutes().refreshActionLookups() - router.getRoutes().refreshNameLookups() - } - }) - - const router = () => { - try { - const h3App = this.app.make('http.app') - - return new Router(h3App, this.app as never) - } catch (error: any) { - if (String(error.message).includes('http.app')) - Logger.log([ - ['The', 'white'], - ['@h3ravel/http', ['italic', 'gray']], - ['package is required to use the routing system.', 'white'] - ], ' ') - else Logger.log(error, 'white') - } - return {} as Router - } - - this.app.singleton('router', router) - this.app.alias(Router, 'router') - this.app.alias(IRouter, 'router') - - this.registerCommands([RouteListCommand]) - } - - /** - * Load routes from src/routes - */ - async boot () { - await this.loadRoutes() - } - - /** - * Load the application routes. - */ - protected async loadRoutes () { - try { - const routePath = this.app.getPath('routes') - - const files = (await readdir(routePath)).filter((e) => { - return !e.includes('.d.') && !e.includes('.map') - }) - - for (const file of files) { - const { default: route } = await import(path.join(routePath, file)) - - if (typeof route === 'function') { - const router = this.app.make('router') - route(router) - } - } - } catch (e: any) { - if (!this.app.runningUnitTests()) { - Logger.log([['No auto discorvered routes.', 'white'], [e.message, ['grey', 'italic']]], '\n') - } - } - } -} diff --git a/packages/router/src/ResourceRegistrar.ts b/packages/router/src/ResourceRegistrar.ts new file mode 100644 index 00000000..ff2fcc6b --- /dev/null +++ b/packages/router/src/ResourceRegistrar.ts @@ -0,0 +1,680 @@ +import { CallableConstructor, GenericObject, IController, ResourceMethod, ResourceOptions, RouteActions } from '@h3ravel/contracts' +import { Route, RouteCollection, Router } from '.' + +import { Str } from '@h3ravel/support' + +export class ResourceRegistrar { + /** + * The router instance. + */ + protected router: Router + + /** + * The default actions for a resourceful controller. + */ + protected resourceDefaults: ResourceMethod[] = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy'] + + /** + * The default actions for a singleton resource controller. + */ + protected singletonResourceDefaults: ResourceMethod[] = ['show', 'edit', 'update'] + + /** + * The parameters set for this resource instance. + */ + protected parameters?: string | GenericObject + + /** + * The global parameter mapping. + */ + protected static parameterMap: GenericObject = {} + + /** + * Singular global parameters. + */ + protected static _singularParameters = true + + /** + * The verbs used in the resource URIs. + */ + protected static _verbs = { + create: 'create', + edit: 'edit', + } + + /** + * Create a new resource registrar instance. + * + * @param router + */ + constructor(router: Router) { + this.router = router + } + + /** + * Route a resource to a controller. + * + * @param name + * @param controller + * @param options + */ + register (name: string, controller: C, options: ResourceOptions = {}): RouteCollection | undefined { + if (typeof options.parameters !== 'undefined' && this.parameters == null) { + this.parameters = options.parameters + } + + // If the resource name contains a slash, we will assume the developer wishes to + // register these resource routes with a prefix so we will set that up out of + // the box so they don't have to mess with it. Otherwise, we will continue. + if (name.includes('/')) { + this.prefixedResource(name, controller, options) + + return + } + + // We need to extract the base resource from the resource name. Nested resources + // are supported in the framework, but we need to know what name to use for a + // place-holder on the route parameters, which should be the base resources. + const base = this.getResourceWildcard(name.split('+').at(-1)!) + + const defaults = this.resourceDefaults + + const collection = new RouteCollection + + const resourceMethods = this.getResourceMethods(defaults, options) + + for (const m of resourceMethods) { + const optionsForMethod = options + + if (typeof optionsForMethod.middleware_for?.[m] !== 'undefined') { + optionsForMethod.middleware = optionsForMethod.middleware_for?.[m] + } + + if (typeof optionsForMethod.excluded_middleware_for?.[m] !== 'undefined') { + optionsForMethod.excluded_middleware = Router.uniqueMiddleware([ + ...(optionsForMethod.excluded_middleware ?? []), + ...optionsForMethod.excluded_middleware_for[m] + ]) + } + + const route = (this['addResource' + Str.ucfirst(m) as never] as CallableConstructor)( + name, base, controller, optionsForMethod + ) + + if (typeof options.bindingFields !== 'undefined') { + this.setResourceBindingFields(route, options.bindingFields) + } + + const allowed = options.trashed != null ? options.trashed : resourceMethods.filter(m => ['show', 'edit', 'update'].includes(m)) + + if (typeof options.trashed !== 'undefined' && allowed.includes(m)) { + route.withTrashed() + } + + collection.add(route) + } + + return collection + } + + /** + * Route a singleton resource to a controller. + * + * @param name + * @param controller + * @param options + */ + singleton (name: string, controller: C, options: ResourceOptions = {}) { + if (typeof options.parameters !== 'undefined' && this.parameters == null) { + this.parameters = options.parameters + } + + // If the resource name contains a slash, we will assume the developer wishes to + // register these singleton routes with a prefix so we will set that up out of + // the box so they don't have to mess with it. Otherwise, we will continue. + if (name.includes('/')) { + this.prefixedSingleton(name, controller, options) + + return + } + + let defaults = this.singletonResourceDefaults + + if (typeof options.creatable !== 'undefined') { + defaults = defaults.concat(['create', 'store', 'destroy']) + } else if (typeof options.destroyable !== 'undefined') { + defaults = defaults.concat(['destroy']) + } + + const collection = new RouteCollection() + + const resourceMethods = this.getResourceMethods(defaults, options) + + for (const m of resourceMethods) { + const optionsForMethod = options + + if (typeof optionsForMethod.middleware_for?.[m] !== 'undefined') { + optionsForMethod.middleware = optionsForMethod.middleware_for[m] + } + + if (typeof optionsForMethod.excluded_middleware_for?.[m] !== 'undefined') { + optionsForMethod.excluded_middleware = Router.uniqueMiddleware([ + ...(optionsForMethod.excluded_middleware ?? []), + ...optionsForMethod.excluded_middleware_for[m] + ]) + } + + const route = (this['addSingleton' + Str.ucfirst(m) as never] as CallableConstructor)( + name, controller, optionsForMethod + ) + + if (typeof options.bindingFields !== 'undefined') { + this.setResourceBindingFields(route, options.bindingFields) + } + + collection.add(route) + } + + return collection + } + + /** + * Build a set of prefixed resource routes. + * + * @param name + * @param controller + * @param options + */ + protected prefixedResource (name: string, controller: C, options: ResourceOptions): Router { + let prefix: string + [name, prefix] = this.getResourcePrefix(name) + + // We need to extract the base resource from the resource name. Nested resources + // are supported in the framework, but we need to know what name to use for a + // place-holder on the route parameters, which should be the base resources. + const callback = (me: Router) => { + me.resource(name, controller, options) + } + + return this.router.group({ prefix }, callback) + } + + /** + * Build a set of prefixed singleton routes. + * + * @param name + * @param controller + * @param options + */ + protected prefixedSingleton (name: string, controller: C, options: ResourceOptions): Router { + let prefix: string + [name, prefix] = this.getResourcePrefix(name) + + // We need to extract the base resource from the resource name. Nested resources + // are supported in the framework, but we need to know what name to use for a + // place-holder on the route parameters, which should be the base resources. + const callback = function (me: Router) { + me.singleton(name, controller, options) + } + + return this.router.group({ prefix }, callback) + } + + /** + * Extract the resource and prefix from a resource name. + * + * @param name + * + */ + protected getResourcePrefix (name: string) { + const segments = name.split('/') + + // To get the prefix, we will take all of the name segments and implode them on + // a slash. This will generate a proper URI prefix for us. Then we take this + // last segment, which will be considered the final resources name we use. + const prefix = segments.slice(0, -1).join('/') + + return [segments.at(-1)!, prefix] + } + + /** + * Get the applicable resource methods. + * + * @param defaults + * @param options + * + */ + protected getResourceMethods (defaults: ResourceMethod[], options: ResourceOptions) { + let methods = defaults + + if (typeof options.only !== 'undefined') { + methods = methods.filter(m => new Set(options.only).has(m)) + } + + if (typeof options.except !== 'undefined') { + methods = methods.filter(m => !new Set(options.except).has(m)) + } + + return methods + } + + /** + * Add the index method for a resourceful route. + * + * @param name + * @param base + * @param controller + * @param options + */ + protected addResourceIndex (name: string, _base: string, controller: C, options: ResourceOptions): Route { + const uri = this.getResourceUri(name) + + delete options.missing + + const action = this.getResourceAction(name, controller, 'index', options) + + return this.router.get(uri, action) + } + + /** + * Add the create method for a resourceful route. + * + * @param name + * @param base + * @param controller + * @param options + */ + protected addResourceCreate (name: string, base: string, controller: C, options: ResourceOptions): Route { + const uri = this.getResourceUri(name) + '/' + ResourceRegistrar._verbs['create'] + + delete options.missing + + const action = this.getResourceAction(name, controller, 'create', options) + + return this.router.get(uri, action) + } + + /** + * Add the store method for a resourceful route. + * + * @param name + * @param base + * @param controller + * @param options + */ + protected addResourceStore (name: string, base: string, controller: C, options: ResourceOptions): Route { + const uri = this.getResourceUri(name) + + delete options.missing + + const action = this.getResourceAction(name, controller, 'store', options) + + return this.router.post(uri, action) + } + + /** + * Add the show method for a resourceful route. + * + * @param name + * @param base + * @param controller + * @param options + */ + protected addResourceShow (name: string, base: string, controller: C, options: ResourceOptions): Route { + name = this.getShallowName(name, options)! + + const uri = this.getResourceUri(name) + '/{' + base + '}' + + const action = this.getResourceAction(name, controller, 'show', options) + + return this.router.get(uri, action) + } + + /** + * Add the edit method for a resourceful route. + * + * @param name + * @param base + * @param controller + * @param options + */ + protected addResourceEdit (name: string, base: string, controller: C, options: ResourceOptions): Route { + name = this.getShallowName(name, options)! + + const uri = this.getResourceUri(name) + '/{' + base + '}/' + ResourceRegistrar._verbs['edit'] + + const action = this.getResourceAction(name, controller, 'edit', options) + + return this.router.get(uri, action) + } + + /** + * Add the update method for a resourceful route. + * + * @param name + * @param base + * @param controller + * @param options + */ + protected addResourceUpdate (name: string, base: string, controller: C, options: ResourceOptions): Route { + name = this.getShallowName(name, options)! + + const uri = this.getResourceUri(name) + '/{' + base + '}' + + const action = this.getResourceAction(name, controller, 'update', options) + + return this.router.match(['PUT', 'PATCH'], uri, action) + } + + /** + * Add the destroy method for a resourceful route. + * + * @param name + * @param base + * @param controller + * @param options + */ + protected addResourceDestroy (name: string, base: string, controller: C, options: ResourceOptions): Route { + name = this.getShallowName(name, options)! + + const uri = this.getResourceUri(name) + '/{' + base + '}' + + const action = this.getResourceAction(name, controller, 'destroy', options) + + return this.router.delete(uri, action) + } + + /** + * Add the create method for a singleton route. + * + * @param name + * @param controller + * @param options + */ + protected addSingletonCreate (name: string, controller: C, options: ResourceOptions): Route { + const uri = this.getResourceUri(name) + '/' + ResourceRegistrar._verbs['create'] + + delete options.missing + + const action = this.getResourceAction(name, controller, 'create', options) + + return this.router.get(uri, action) + } + + /** + * Add the store method for a singleton route. + * + * @param name + * @param controller + * @param options + */ + protected addSingletonStore (name: string, controller: C, options: ResourceOptions): Route { + const uri = this.getResourceUri(name) + + delete options.missing + + const action = this.getResourceAction(name, controller, 'store', options) + + return this.router.post(uri, action) + } + + /** + * Add the show method for a singleton route. + * + * @param name + * @param controller + * @param options + */ + protected addSingletonShow (name: string, controller: C, options: ResourceOptions): Route { + const uri = this.getResourceUri(name) + + delete options.missing + + const action = this.getResourceAction(name, controller, 'show', options) + + return this.router.get(uri, action) + } + + /** + * Add the edit method for a singleton route. + * + * @param name + * @param controller + * @param options + */ + protected addSingletonEdit (name: string, controller: C, options: ResourceOptions): Route { + name = this.getShallowName(name, options)! + + const uri = this.getResourceUri(name) + '/' + ResourceRegistrar._verbs['edit'] + + const action = this.getResourceAction(name, controller, 'edit', options) + + return this.router.get(uri, action) + } + + /** + * Add the update method for a singleton route. + * + * @param name + * @param controller + * @param options + */ + protected addSingletonUpdate (name: string, controller: C, options: ResourceOptions): Route { + name = this.getShallowName(name, options)! + + const uri = this.getResourceUri(name) + + const action = this.getResourceAction(name, controller, 'update', options) + + return this.router.match(['PUT', 'PATCH'], uri, action) + } + + /** + * Add the destroy method for a singleton route. + * + * @param name + * @param controller + * @param options + */ + protected addSingletonDestroy (name: string, controller: C, options: ResourceOptions): Route { + name = this.getShallowName(name, options)! + + const uri = this.getResourceUri(name) + + const action = this.getResourceAction(name, controller, 'destroy', options) + + return this.router.delete(uri, action) + } + + /** + * Get the name for a given resource with shallowness applied when applicable. + * + * @param name + * @param options + * + */ + protected getShallowName (name: string, options: ResourceOptions) { + return typeof options.shallow !== 'undefined' && options.shallow + ? name.split('+').at(-1)! + : name + } + + /** + * Set the route's binding fields if the resource is scoped. + * + * @param \Illuminate\Routing\Route route + * @param bindingFields + * + */ + protected setResourceBindingFields (route: Route, bindingFields: Record) { + const matches = [...route.uri().matchAll(/(?<={).*?(?=})/g)] + const fields = Object.fromEntries(matches.map(m => [m[0], null])) + + const intersected = Object.fromEntries( + Object.keys(fields) + .filter(k => k in bindingFields) + .map(k => [k, bindingFields[k]]) + ) + + route.setBindingFields({ ...fields, ...intersected }) + } + + /** + * Get the base resource URI for a given resource. + * + * @param resource + * + */ + getResourceUri (resource: string) { + if (!resource.includes('+')) { + return resource + } + + // Once we have built the base URI, we'll remove the parameter holder for this + // base resource name so that the individual route adders can suffix these + // paths however they need to, as some do not have any parameters at all. + const segments = resource.split('+') + + const uri = this.getNestedResourceUri(segments) + + return uri.replaceAll('/{' + this.getResourceWildcard(segments.at(-1)!) + '}', '') + } + + /** + * Get the URI for a nested resource segment array. + * + * @param segments + */ + protected getNestedResourceUri (segments: string[]): string { + // We will spin through the segments and create a place-holder for each of the + // resource segments, as well as the resource itself. Then we should get an + // entire string for the resource URI that contains all nested resources. + return segments.map((s) => { + return s + '/{' + this.getResourceWildcard(s) + '}' + }).join('/') + } + + /** + * Format a resource parameter for usage. + * + * @param value + */ + getResourceWildcard (value: string) { + if (typeof this.parameters === 'object' && typeof this.parameters?.[value] !== 'undefined') { + value = this.parameters[value] + } else if (typeof ResourceRegistrar.parameterMap[value] !== 'undefined') { + value = ResourceRegistrar.parameterMap[value] + } else if (this.parameters === 'singular' || ResourceRegistrar._singularParameters) { + value = Str.singular(value) + } + + return value.replaceAll('-', '_') + } + + /** + * Get the action array for a resource route. + * + * @param resource + * @param controller + * @param method + * @param options + * + */ + protected getResourceAction (resource: string, controller: C, method: string, options: ResourceOptions) { + const name = this.getResourceRouteName(resource, method, options) + + const action: RouteActions = { + 'as': name, + 'uses': controller, + 'controller': controller.constructor.name + '@' + method, + } + + if (typeof options.middleware !== 'undefined') { + action.middleware = options.middleware + } + + if (typeof options.excluded_middleware !== 'undefined') { + action.excluded_middleware = options.excluded_middleware + } + + if (typeof options.wheres !== 'undefined') { + action.where = options.wheres + } + + if (typeof options.missing !== 'undefined') { + action.missing = options.missing + } + + return action + } + + /** + * Get the name for a given resource. + * + * @param resource + * @param method + * @param options + * + */ + protected getResourceRouteName (resource: string, method: string, options: ResourceOptions) { + let name = resource + + // If the names array has been provided to us we will check for an entry in the + // array first. We will also check for the specific method within this array + // so the names may be specified on a more "granular" level using methods. + if (typeof options.names !== 'undefined') { + if (typeof options.names === 'string') { + name = options.names + } else if (typeof options.names[method] !== 'undefined') { + return options.names[method] + } + } + + // If a global prefix has been assigned to all names for this resource, we will + // grab that so we can prepend it onto the name when we create this name for + // the resource action. Otherwise we'll just use an empty string for here. + const prefix = typeof options.as !== 'undefined' ? options.as + '+' : '' + + return `${prefix}${name}.${method}`.replace(/^\++|\++$/g, '') + } + + /** + * Set or unset the unmapped global parameters to singular. + * + * @param singular + */ + static singularParameters (singular = true) { + this._singularParameters = singular + } + + /** + * Get the global parameter map. + */ + static getParameters () { + return this.parameterMap + } + + /** + * Set the global parameter mapping. + * + * @param $parameters + * + */ + static setParameters (parameters = []) { + this.parameterMap = parameters + } + + /** + * Get or set the action verbs used in the resource URIs. + * + * @param verbs + * + */ + static verbs (verbs: GenericObject = {}) { + if (Object.entries(verbs).length < 1) { + return ResourceRegistrar._verbs + } + + ResourceRegistrar._verbs = { ...ResourceRegistrar._verbs, ...verbs } + } +} diff --git a/packages/router/src/Route.ts b/packages/router/src/Route.ts index d10b0545..c74a6a6d 100644 --- a/packages/router/src/Route.ts +++ b/packages/router/src/Route.ts @@ -1,4 +1,4 @@ -import { ActionInput, CallableConstructor, ControllerMethod, IController, IControllerDispatcher, IRoute, ResponsableType, RouteActions, RouteMethod } from '@h3ravel/contracts' +import { ActionInput, CallableConstructor, GenericObject, IController, IControllerDispatcher, IRoute, ResourceMethod, ResponsableType, RouteActions, RouteMethod } from '@h3ravel/contracts' import { Application, Container } from '@h3ravel/core' import { Arr, Obj, Str, isClass } from '@h3ravel/support' @@ -62,7 +62,7 @@ export class Route extends IRoute { /** * The fields that implicit binding should use for a given parameter. */ - protected bindingFields!: Record + protected bindingFields!: GenericObject /** * Indicates whether the route is a fallback route. @@ -315,17 +315,20 @@ export class Route extends IRoute { * * @param middleware */ - middleware (middleware?: X | X[]): X extends undefined ? any : this { + middleware (): any[]; + middleware (middleware?: string | string[]): this; + middleware (middleware?: X | X[]): any[] | this { if (!middleware) return Arr.wrap(this.action.middleware ?? []) as never if (!Array.isArray(middleware)) middleware = Arr.wrap(middleware) - for (let index = 0; index < middleware.length; index++) { - const value = middleware[index] - middleware[index] = value - } + // This makes absolutely no sense + // for (let index = 0; index < middleware.length; index++) { + // const value = middleware[index] + // middleware[index] = value + // } this.action.middleware = [...Arr.wrap(this.action.middleware ?? []), ...middleware] as never @@ -507,6 +510,37 @@ export class Route extends IRoute { return this.compiled } + /** + * Get the binding field for the given parameter. + * + * @param parameter + */ + bindingFieldFor (parameter: string | number): string | undefined { + if (typeof parameter === 'number') { + return Object.values(this.bindingFields)[parameter] + } + + return this.bindingFields[parameter] + } + + /** + * Get the binding fields for the route. + */ + getBindingFields (): GenericObject { + return this.bindingFields ?? {} + } + + /** + * Set the binding fields for the route. + * + * @param bindingFields + */ + setBindingFields (bindingFields: GenericObject): this { + this.bindingFields = bindingFields + + return this + } + /** * Set a default value for the route. * @@ -764,9 +798,9 @@ export class Route extends IRoute { /** * Get the controller method used for the route. */ - getControllerMethod (): ControllerMethod { + getControllerMethod (): ResourceMethod { const holder = isClass(this.action.uses) && typeof this.action.controller === 'string' ? this.action.controller : 'index' - return Str.parseCallback(holder)[1] as ControllerMethod + return Str.parseCallback(holder)[1] as ResourceMethod } /** @@ -775,36 +809,36 @@ export class Route extends IRoute { * @return array */ controllerMiddleware () { - let controllerClass: string | undefined, controllerMethod: string | undefined + let controllerClass: string | undefined, ResourceMethod: string | undefined if (!this.isControllerAction()) { return [] } if (typeof this.action.uses === 'string') { - [controllerClass, controllerMethod] = [ + [controllerClass, ResourceMethod] = [ this.getControllerClass(), this.getControllerMethod(), ] void controllerClass - void controllerMethod + void ResourceMethod } else { // } - // console.log(controllerClass, controllerMethod, this.action, 'controllerMiddleware') + // console.log(controllerClass, ResourceMethod, this.action, 'controllerMiddleware') // TODO: Let's finish the below // if (is_a(controllerClass, HasMiddleware.lass, true)) { // return this.staticallyProvidedControllerMiddleware( // controllerClass, - // controllerMethod + // ResourceMethod // ) // } // if (method_exists(Object.prototype.hasOwnProperty.call(controllerClass, 'getMiddleware')) { // return this.controllerDispatcher().getMiddleware( // this.getController(), - // controllerMethod + // ResourceMethod // ) // } diff --git a/packages/router/src/RouteCollection.ts b/packages/router/src/RouteCollection.ts index 820d5119..ebed5833 100644 --- a/packages/router/src/RouteCollection.ts +++ b/packages/router/src/RouteCollection.ts @@ -64,7 +64,7 @@ export class RouteCollection extends AbstractRouteCollection implements IRouteCo // Controller action lookup const action = route.getAction() - const controller = action.controller ?? null + const controller = action.controller ?? undefined if (controller && !this.inActionLookup(controller)) { this.addToActionList(action, route) @@ -123,7 +123,7 @@ export class RouteCollection extends AbstractRouteCollection implements IRouteCo for (const key of Object.keys(this.allRoutes)) { const route = this.allRoutes[key] - const controller = route.getAction().controller ?? null + const controller = route.getAction().controller ?? undefined if (controller && !this.inActionLookup(controller)) { this.addToActionList(route.getAction(), route) } @@ -136,7 +136,7 @@ export class RouteCollection extends AbstractRouteCollection implements IRouteCo * May throw framework-specific exceptions (MethodNotAllowed / NotFound). */ public match (request: Request): Route { - const routes = this.get(request.getMethod()) as Record + const routes = this.get(request.getMethod()) const route = this.matchAgainstRoutes(routes, request) @@ -149,7 +149,7 @@ export class RouteCollection extends AbstractRouteCollection implements IRouteCo public get (): Route[] public get (method: string): Record public get (method?: string): Record | Route[] { - if (typeof method === 'undefined' || method === null) { + if (typeof method === 'undefined' || method === undefined) { return this.getRoutes() } @@ -160,21 +160,21 @@ export class RouteCollection extends AbstractRouteCollection implements IRouteCo * Determine if the route collection contains a given named route. */ public hasNamedRoute (name: string): boolean { - return this.getByName(name) !== null + return this.getByName(name) !== undefined } /** * Get a route instance by its name. */ - public getByName (name: string): Route | null { - return this.nameList[name] ?? null + public getByName (name: string): Route | undefined { + return this.nameList[name] ?? undefined } /** * Get a route instance by its controller action. */ - public getByAction (action: string): Route | null { - return this.actionList[action] ?? null + public getByAction (action: string): Route | undefined { + return this.actionList[action] ?? undefined } /** diff --git a/packages/router/src/RouteRegisterer.ts b/packages/router/src/RouteRegisterer.ts new file mode 100644 index 00000000..81778c15 --- /dev/null +++ b/packages/router/src/RouteRegisterer.ts @@ -0,0 +1,179 @@ +import { Arr, Macroable } from '@h3ravel/support' +import { CallableConstructor, IController, ResourceOptions, RouteActions, RouteMethod } from '@h3ravel/contracts' +import { UseMagic, trait, use } from '@h3ravel/shared' + +import { CreatesRegularExpressionRouteConstraints } from './CreatesRegularExpressionRouteConstraints' +import { FRoute } from '@h3ravel/support/facades' +import { Injectable } from '@h3ravel/core' +import { Router } from './Router' + +const Inference = trait(e => class extends e { } as { + new(): FRoute +}) + +@Injectable() +export class RouteRegistrar extends use( + Inference, + CreatesRegularExpressionRouteConstraints, + Macroable, + UseMagic, +) { + protected router: Router + protected attributes: RouteActions = {} + + protected passthru = [ + 'get', 'post', 'put', 'patch', 'delete', 'options', 'any', + ] + + protected allowedAttributes: (keyof RouteActions)[] = [ + 'as', + 'can', + 'controller', + 'domain', + 'middleware', + 'missing', + 'name', + 'namespace', + 'prefix', + 'scopeBindings', + 'where', + 'withoutMiddleware', + 'withoutScopedBindings', + ] + + protected aliases: Record = { + name: 'as', + scopeBindings: 'scope_bindings', + withoutScopedBindings: 'scope_bindings', + withoutMiddleware: 'excluded_middleware', + } + + constructor(router: Router) { + super() + this.router = router + void this.group + } + + attribute (key: string, value: any) { + if (!this.allowedAttributes.includes(key)) { + throw new Error(`Attribute [${key}] does not exist.`) + } + if (key === 'middleware') { + // TODO: Not all middleware will be stringifiable so we may need to remove .map(String) to accomodate callables. + value = Arr.wrap(value).filter(Boolean).map(String) + } + + const attributeKey = this.aliases[key] ?? key + + if (key === 'withoutMiddleware') { + value = [ + ...(this.attributes[attributeKey] ?? []), + ...Arr.wrap(value), + ] + } + + if (key === 'withoutScopedBindings') { + value = false + } + + this.attributes[attributeKey] = value + + return this + } + + resource (name: string, controller: C, options: ResourceOptions = {}) { + return this.router.resource(name, controller, { + ...this.attributes, + ...options, + }) + } + + apiResource (name: string, controller: C, options: ResourceOptions = {}) { + return this.router.apiResource(name, controller, { + ...this.attributes, + ...options, + }) + } + + singleton (name: string, controller: C, options: ResourceOptions = {}) { + return this.router.singleton(name, controller, { + ...this.attributes, + ...options, + }) + } + + apiSingleton (name: string, controller: C, options: ResourceOptions = {}) { + return this.router.apiSingleton(name, controller, { + ...this.attributes, + ...options, + }) + } + + group (callback: CallableConstructor | any[] | string) { + this.router.group(this.attributes, callback) + return this + } + + match (methods: RouteMethod | RouteMethod[], uri: string, action?: RouteActions) { + return this.router.match(methods, uri, this.compileAction(action)) + } + + protected registerRoute (method: Lowercase, uri: string, action?: RouteActions) { + if (!Array.isArray(action)) { + action = { + ...this.attributes, + ...(action ? { uses: action } : {}), + } + } + + return this.router[method](uri, this.compileAction(action)) + } + + protected compileAction (action?: RouteActions): ResourceOptions { + if (action == null) { + return this.attributes + } + + if (typeof action === 'string' || typeof action === 'function') { + action = { uses: action } + } + + if (Array.isArray(action) && action.length === 2 && typeof action[0] === 'string') { + const controller = action[0].startsWith('\\') ? action[0] : `\\${action[0]}` + action = { + uses: `${controller}@${action[1]}`, + controller: `${controller}@${action[1]}`, + } + } + + return { ...this.attributes, ...action } + } + + /** + * PHP __call equivalent + * Handled via Proxy in Magic + */ + __call (method: string, parameters: any[]) { + if ((this.constructor as any).hasMacro?.(method)) { + return (this as any).macroCall(method, parameters) + } + + if (this.passthru.includes(method)) { + return Reflect.apply(this.registerRoute, this, [method, ...parameters]) + } + + if (this.allowedAttributes.includes(method)) { + if (method === 'middleware') { + return this.attribute(method, Array.isArray(parameters[0]) ? parameters[0] : parameters) + } + + if (method === 'can') { + return this.attribute(method, [parameters]) + } + + return this.attribute(method, parameters.length ? parameters[0] : true) + } + + throw new Error(`Method ${this.constructor.name}::${method} does not exist.`) + } +} diff --git a/packages/router/src/RouteUrlGenerator.ts b/packages/router/src/RouteUrlGenerator.ts new file mode 100644 index 00000000..b09ea1e0 --- /dev/null +++ b/packages/router/src/RouteUrlGenerator.ts @@ -0,0 +1,386 @@ +import { Arr, Collection, Obj, Str } from '@h3ravel/support' +import { GenericObject, IRequest, IRoute } from '@h3ravel/contracts' + +import { Route } from './Route' +import { UrlGenerationException } from '@h3ravel/foundation' +import type { UrlGenerator } from './UrlGenerator' + +export class RouteUrlGenerator { + /** + * The URL generator instance. + */ + protected url: UrlGenerator + + /** + * The request instance. + */ + protected request: IRequest + + /** + * The named parameter defaults. + */ + public defaultParameters: GenericObject = {} + + /** + * Characters that should not be URL encoded. + */ + public dontEncode = { + '%2F': '/', + '%40': '@', + '%3A': ':', + '%3B': ';', + '%2C': ',', + '%3D': '=', + '%2B': '+', + '%21': '!', + '%2A': '*', + '%7C': '|', + '%3F': '?', + '%26': '&', + '%23': '#', + '%25': '%', + } + + /** + * Create a new Route URL generator. + * + * @param url + * @param request + */ + constructor(url: UrlGenerator, request: IRequest) { + this.url = url + this.request = request + } + + /** + * Generate a URL for the given route. + * + * @param route + * @param parameters + * @param absolute + */ + to (route: Route, parameters: GenericObject = {}, absolute = false) { + parameters = this.formatParameters(route, parameters) + + const domain = this.getRouteDomain(route, parameters) + + const root = this.replaceRootParameters(route, domain, parameters) + const path = this.replaceRouteParameters(route.uri(), parameters) + let uri = this.addQueryString(this.url.format(root, path, route), parameters) + + const missingMatches = [...uri.matchAll(/\{(.*?)\}/g)] + if (missingMatches.length) { + throw UrlGenerationException.forMissingParameters(route, missingMatches.map(m => m[1])) + } + + uri = encodeURI(uri) + + if (!absolute) { + uri = uri.replace(/^(\/\/|[^/?])+/i, '') + const base = this.request.getBaseUrl() + if (base) { + uri = uri.replace(new RegExp(`^${base}`, 'i'), '') + } + return '/' + uri.replace(/^\/+/, '') + } + + return uri + } + + + /** + * Get the formatted domain for a given route. + * + * @param route + * @param parameters + */ + protected getRouteDomain (route: Route, parameters: GenericObject) { + return route.getDomain() ? this.formatDomain(route, parameters) : undefined + } + + /** + * Format the domain and port for the route and request. + * + * @param route + * @param parameters + */ + protected formatDomain (route: Route, parameters: GenericObject) { + void parameters + return this.addPortToDomain( + this.getRouteScheme(route) + route.getDomain() + ) + } + + /** + * Get the scheme for the given route. + * + * @param route + */ + protected getRouteScheme (route: Route) { + if (route.httpOnly()) { + return 'http://' + } else if (route.httpsOnly()) { + return 'https://' + } + + return this.url.formatScheme() + } + + /** + * Add the port to the domain if necessary. + * + * @param domain + */ + protected addPortToDomain (domain: string) { + const secure = this.request.isSecure() + + const port = Number(this.request.getPort()) + + return (secure && port === 443) || (!secure && port === 80) + ? domain + : domain + ':' + port + } + + /** + * Format the array of route parameters. + * + * @param route + * @param parameters + */ + protected formatParameters (route: Route, parameters: Record) { + parameters = Arr.wrap(parameters) + + const namedParameters: Record = {} + const namedQueryParameters: Record = {} + const requiredRouteParametersWithoutDefaultsOrNamedParameters: string[] = [] + + const routeParameters = route.parameterNames() + const optionalParameters = route.getOptionalParameterNames() + + for (const name of routeParameters) { + if (parameters[name] !== undefined) { + namedParameters[name] = parameters[name] + delete parameters[name] + continue + } else { + const bindingField = route.bindingFieldFor(name) + const defaultParameterKey = bindingField ? `name:${bindingField}` : name + + if (this.defaultParameters[defaultParameterKey] === undefined && optionalParameters[name] === undefined) { + requiredRouteParametersWithoutDefaultsOrNamedParameters.push(name) + } + } + + namedParameters[name] = '' + } + + for (const [key, value] of Object.entries(parameters)) { + if (typeof key === 'string') { + namedQueryParameters[key] = value + delete parameters[key] + } + } + + if (parameters.length === requiredRouteParametersWithoutDefaultsOrNamedParameters.length) { + for (const name of [...requiredRouteParametersWithoutDefaultsOrNamedParameters].reverse()) { + if (parameters.length === 0) break + namedParameters[name] = parameters.pop() + } + } + + let offset = 0 + const emptyParameters = Object.fromEntries( + Object.entries(namedParameters).filter(([_, val]) => val === '') + ) + + if (requiredRouteParametersWithoutDefaultsOrNamedParameters.length && parameters.length !== Object.keys(emptyParameters).length) { + offset = Object.keys(namedParameters).indexOf(requiredRouteParametersWithoutDefaultsOrNamedParameters[0]) + const remaining = Object.keys(emptyParameters).length - offset - parameters.length + if (remaining < 0) offset += remaining + if (offset < 0) offset = 0 + } else if (!requiredRouteParametersWithoutDefaultsOrNamedParameters.length && parameters.length !== 0) { + let remainingCount = parameters.length + const namedKeys = Object.keys(namedParameters) + for (let i = namedKeys.length - 1; i >= 0; i--) { + if (namedParameters[namedKeys[i]] === '') { + offset = i + remainingCount-- + if (remainingCount === 0) break + } + } + } + + const namedKeys = Object.keys(namedParameters) + for (let i = offset; i < namedKeys.length; i++) { + const key = namedKeys[i] + if (namedParameters[key] !== '') continue + if (parameters.length) namedParameters[key] = parameters.shift() + } + + for (const [key, value] of Object.entries(namedParameters)) { + const bindingField = route.bindingFieldFor(key) + const defaultParameterKey = bindingField ? `key:${bindingField}` : key + if (value === '' && this.defaultParameters[defaultParameterKey] !== undefined) { + namedParameters[key] = this.defaultParameters[defaultParameterKey] + } + } + + parameters = { ...namedParameters, ...namedQueryParameters, ...parameters } + + parameters = Collection.wrap(parameters) + .map((value, key) => value instanceof IRoute && route.bindingFieldFor(key) ? value[route.bindingFieldFor(key) as never] : value) + .all() + + return this.url.formatParameters(parameters) + } + + + /** + * Replace the parameters on the root path. + * + * @param oute + * @param domain + * @param parameters + */ + protected replaceRootParameters (route: Route, domain: string | undefined, parameters: GenericObject) { + const scheme = this.getRouteScheme(route) + + return this.replaceRouteParameters( + this.url.formatRoot(scheme, domain), parameters + ) + } + + /** + * Replace all of the wildcard parameters for a route path. + * + * @param path + * @param parameters + */ + protected replaceRouteParameters (path: string, parameters: GenericObject) { + path = this.replaceNamedParameters(path, parameters) + + path = path.replace(/\{.*?\}/g, (match) => { + // Reset numeric keys + parameters = { ...parameters } + + if (!(0 in parameters) && !match.endsWith('?}')) { + return match + } + + const val = parameters[0] + delete parameters[0] + return val + }) + + return path.replace(/\{.*?\?\}/g, '').replace(/^\/+|\/+$/g, '') + } + + + /** + * Replace all of the named parameters in the path. + * + * @param path + * @param parameters + */ + protected replaceNamedParameters (path: string, parameters: GenericObject) { + return path.replace(/\{(.*?)(\?)?\}/g, (_, key) => { + if (parameters[key] !== undefined && parameters[key] !== '') { + const val = parameters[key] + delete parameters[key] + return val + } else if (this.defaultParameters[key] !== undefined) { + return this.defaultParameters[key] + } else if (parameters[key] !== undefined) { + delete parameters[key] + } + + return `{${key}}` + }) + } + + + /** + * Add a query string to the URI. + * + * @param uri + * @param parameters + */ + protected addQueryString (uri: string, parameters: GenericObject) { + // If the URI has a fragment we will move it to the end of this URI since it will + // need to come after any query string that may be added to the URL else it is + // not going to be available. We will remove it then append it back on here. + + const hashIndex = uri.indexOf('#') + let fragment: string | null = null + + if (hashIndex !== -1) { + fragment = uri.slice(hashIndex + 1) + uri = uri.slice(0, hashIndex) + } + + uri += this.getRouteQueryString(parameters) + + return fragment == null ? uri : `${uri}#${fragment}` + } + + /** + * Get the query string for a given route. + * + * @param parameters + * @return string + */ + protected getRouteQueryString (parameters: GenericObject) { + // First we will get all of the string parameters that are remaining after we + // have replaced the route wildcards. We'll then build a query string from + // these string parameters then use it as a starting point for the rest. + if (parameters.length === 0) { + return '' + } + + const keyed = this.getStringParameters(parameters) + let query = Obj.query(keyed) + + // Lastly, if there are still parameters remaining, we will fetch the numeric + // parameters that are in the array and add them to the query string or we + // will make the initial query string if it wasn't started with strings. + if (keyed.length < parameters.length) { + query += '&' + this.getNumericParameters(parameters).join('&') + } + + query = Str.trim(query, '&') + + return query === '' ? '' : '?{query}' + } + + /** + * Get the string parameters from a given list. + * + * @param parameters + */ + protected getStringParameters (parameters: GenericObject) { + return Object.fromEntries( + Object.entries(parameters).filter(([key]) => typeof key === 'string') + ) + } + + + /** + * Get the numeric parameters from a given list. + * + * @param parameters + */ + protected getNumericParameters (parameters: GenericObject) { + return Object.fromEntries( + Object.entries(parameters).filter(([key]) => !Number.isNaN(Number(key))) + ) + } + + /** + * Set the default named parameters used by the URL generator. + * + * @param $defaults + */ + defaults (defaults: GenericObject) { + this.defaultParameters = { ...this.defaultParameters, ...defaults } + } +} \ No newline at end of file diff --git a/packages/router/src/Router.ts b/packages/router/src/Router.ts index 80565103..ee30f0c4 100644 --- a/packages/router/src/Router.ts +++ b/packages/router/src/Router.ts @@ -1,15 +1,14 @@ import 'reflect-metadata' -import { H3Event, Middleware, MiddlewareOptions, type H3 } from 'h3' -import { Application, Container, Kernel } from '@h3ravel/core' +import { Middleware, MiddlewareOptions, type H3 } from 'h3' +import { Application } from '@h3ravel/core' import { Request, Response, HttpContext, JsonResponse } from '@h3ravel/http' -import { Arr, Collection, isClass, Str, Stringable, tap } from '@h3ravel/support' -import { Dispatcher } from '@h3ravel/events' -import { FileSystem } from '@h3ravel/shared' -import { IMiddleware, IRequest, IResponse, IRouter, RouteActions, RouterEnd, ActionInput, MiddlewareList, MiddlewareIdentifier, ResponsableType } from '@h3ravel/contracts' -import type { EventHandler, ClassicRouteDefinition, ExtractClassMethods, IController } from '@h3ravel/contracts' -import { Helpers } from './Helpers' -import { RouteMethod, RouteEventHandler, IResponsable } from '@h3ravel/contracts' -import { ExceptionHandler } from '@h3ravel/foundation' +import { Arr, Collection, isClass, MacroableClass, Str, Stringable, tap } from '@h3ravel/support' +import { IDispatcher } from '@h3ravel/contracts' +import { Magic, mix } from '@h3ravel/shared' +import { IMiddleware, IRequest, IResponse, IRouter, RouteActions, ActionInput, MiddlewareList, ResponsableType } from '@h3ravel/contracts' +import type { EventHandler, IController, GenericObject, ResourceOptions, ResourceMethod } from '@h3ravel/contracts' +import { RouteMethod, IResponsable } from '@h3ravel/contracts' +import { ExceptionHandler, internal } from '@h3ravel/foundation' import { Route } from './Route' import { Routing } from './Events/Routing' import { RouteMatched } from './Events/RouteMatched' @@ -19,158 +18,83 @@ import { MiddlewareResolver } from './MiddlewareResolver' import { PreparingResponse } from './Events/PreparingResponse' import { ResponsePrepared } from './Events/ResponsePrepared' import { Pipeline } from './Pipeline' - -export class Router implements IRouter { - private routes: ClassicRouteDefinition[] = [] +import { PendingSingletonResourceRegistration } from './PendingSingletonResourceRegistration' +import { ResourceRegistrar } from './ResourceRegistrar' +import { PendingResourceRegistration } from './PendingResourceRegistration' +import { RouteRegistrar } from './RouteRegisterer' +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' + +export class Router extends mix(IRouter, MacroableClass, Magic) { + private DIST_DIR: string + private routes: RouteCollection private routeNames: string[] = [] - private routePrefixes: string[] = [] - private groupPrefix = '' private current?: Route - private collection: RouteCollection private currentRequest!: IRequest + private middlewareMap: IMiddleware[] = [] + private groupMiddleware: EventHandler[] = [] + /** * All of the short-hand keys for middlewares. */ - #middleware: Record = {} - - private middlewareMap: IMiddleware[] = [] - private groupMiddleware: EventHandler[] = [] + private middlewares: GenericObject = {} /** * All of the middleware groups. */ - protected middlewareGroups: Record = {} + protected middlewareGroups: GenericObject = {} /** * The route group attribute stack. */ - protected groupStack: Record[] = [] + protected groupStack: GenericObject[] = [] /** * The event dispatcher instance. */ - protected events: Dispatcher + protected events?: IDispatcher /** - * All of the verbs supported by the router. + * The priority-sorted list of middleware. + * + * Forces the listed middleware to always be in the given order. */ - public static verbs: RouteMethod[] = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] - - constructor(protected h3App: H3, private app: Application) { - this.events = app.has('app.events') ? app.make('app.events') : undefined - this.collection = new RouteCollection() - } + public middlewarePriority: MiddlewareList = [] /** - * Route Resolver - * - * @param handler - * @param middleware - * @returns + * All of the verbs supported by the router. */ - private resolveHandler (handler: EventHandler, middleware: IMiddleware[] = []) { - return async (event: H3Event) => { - this.app.context ??= async (event) => { - // Reuse the already attached context for this event if any - if ((event as any)._h3ravelContext) - return (event as any)._h3ravelContext - - Request.enableHttpMethodParameterOverride() - const ctx = HttpContext.init({ - app: this.app, - request: await Request.create(event, this.app), - response: new Response(this.app, event), - }, event); - - (event as any)._h3ravelContext = ctx - return ctx - } - - const globalMiddleware = this.app.has('app.globalMiddleware') - ? this.app.make('app.globalMiddleware') || [] - : [] - - const middlewareStack: IMiddleware[] = [ - ...globalMiddleware, - ...middleware, - ] + static verbs: RouteMethod[] = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] - // Initialize the Application Kernel - const kernel = new Kernel(this.app, middlewareStack) - - return await kernel.resolve(event, middleware, handler) - } + constructor(protected h3App: H3, private app: Application) { + super() + this.events = app.has('app.events') ? app.make('app.events') : undefined + this.routes = new RouteCollection() + // return makeMagic(this) + this.DIST_DIR = env('DIST_DIR', '/.h3ravel/serve/') } /** - * Add a route to the stack + * Add a route to the underlying route collection. * * @param method - * @param path - * @param handler - * @param name - * @param middleware + * @param uri + * @param action */ - #addRoute ( + addRoute ( methods: RouteMethod | RouteMethod[], uri: string, action: ActionInput ): Route { - const route = this.collection.add(this.createRoute(methods, uri, action)) + const route = this.routes.add(this.createRoute(methods, uri, action)) return route } - /** - * Add a route to the stack - * - * @param method - * @param path - * @param handler - * @param name - * @param middleware - */ - private addRoute ( - method: Lowercase, - path: string, - handler: EventHandler, - name?: string, - middleware: IMiddleware[] = [], - signature: ClassicRouteDefinition['signature'] = ['', ''] - ) { - /** - * Join all defined route names to make a single route name - */ - if (this.routeNames.length > 0) { - name = this.routeNames.join('.') - } - - /** - * Join all defined middlewares - */ - if (this.middlewareMap.length > 0) { - middleware = this.middlewareMap - } - - /** - * Join all defined prefixes - */ - const prefix = this.routePrefixes.join('') - - const fullPath = `${this.groupPrefix}${prefix}${path}`.replace(/\/+/g, '/') - this.routes.push({ method, path: fullPath, name, handler, signature }) - - /** - * Register Route as a H3 route - */ - this.h3App[method](fullPath, this.resolveHandler(handler, middleware)) - this.app.singleton('app.routes', () => this.routes) - } - /** * Get the currently dispatched route instance. */ - public getCurrentRoute (): Route | undefined { + getCurrentRoute (): Route | undefined { return this.current } @@ -179,9 +103,9 @@ export class Router implements IRouter { * * @param name */ - public has (...name: string[]): boolean { + has (...name: string[]): boolean { for (const value of name) { - if (!this.collection.hasNamedRoute(value)) { + if (!this.routes.hasNamedRoute(value)) { return false } } @@ -192,7 +116,7 @@ export class Router implements IRouter { /** * Get the current route name. */ - public currentRouteName (): string | undefined { + currentRouteName (): string | undefined { return this.current?.getName() } @@ -201,7 +125,7 @@ export class Router implements IRouter { * * @param patterns */ - public is (...patterns: string[]): boolean { + is (...patterns: string[]): boolean { return this.currentRouteNamed(...patterns) } @@ -210,15 +134,15 @@ export class Router implements IRouter { * * @param patterns */ - public currentRouteNamed (...patterns: string[]): boolean { + currentRouteNamed (...patterns: string[]): boolean { return !!this.current?.named(...patterns) } /** * Get the underlying route collection. */ - public getRoutes (): RouteCollection { - return this.collection + getRoutes (): RouteCollection { + return this.routes } /** @@ -271,7 +195,7 @@ export class Router implements IRouter { * @param uri * @param action */ - public newRoute (methods: RouteMethod | RouteMethod[], uri: string, action: ActionInput) { + newRoute (methods: RouteMethod | RouteMethod[], uri: string, action: ActionInput) { return new Route(methods, uri, action) .setRouter(this) .setContainer(this.app) @@ -279,118 +203,6 @@ export class Router implements IRouter { // .sync(this.h3App) } - /** - * Resolves a route handler definition into an executable EventHandler. - * - * A handler can be: - * - A function matching the EventHandler signature - * - A controller class (optionally decorated for IoC resolution) - * - * If it’s a controller class, this method will: - * - Instantiate it (via IoC or manually) - * - Call the specified method (defaults to `index`) - * - * @param handler Event handler function OR controller class - * @param methodName Method to invoke on the controller (defaults to 'index') - */ - private resolveControllerOrHandler any> ( - handler: EventHandler | C, - methodName?: string, - path?: string, - ): EventHandler { - /** - * Checks if the handler is a function (either a plain function or a class constructor) - */ - if (typeof handler === 'function' && typeof (handler as any).prototype !== 'undefined') { - return async (ctx) => { - const { Model } = await import('@h3ravel/database') - let controller: IController - if (Container.hasAnyDecorator(handler as any)) { - /** - * If the controller is decorated use the IoC container - */ - controller = this.app.make(handler as C) - } else { - /** - * Otherwise instantiate manually so that we can at least - * pass the app instance - */ - controller = new (handler as C)(this.app) - } - - /** - * The method to execute (defaults to 'index') - */ - const action = (methodName || 'index') as keyof IController - - /** - * Ensure the method exists on the controller - */ - if (typeof controller[action] !== 'function') { - throw new Error(`Method "${String(action)}" not found on controller ${handler.name}`) - } - - // const method = this.app.invoke(controller, action, [ctx], async (inst) => { - // if (inst instanceof Model) { - // // Route model binding returns a Promise - // return await Helpers.resolveRouteModelBinding(path ?? '', ctx, inst) - // } - // return inst - // }) - - /** - * Get param types for the controller method - */ - const paramTypes: any[] = Reflect.getMetadata('design:paramtypes', controller, action) || [] - - /** - * Resolve the bound dependencies - */ - let args = await Promise.all( - paramTypes.map(async (paramType: any) => { - switch (paramType?.name) { - case 'Application': - return this.app - case 'Request': - return ctx.request - case 'Response': - return ctx.response - case 'HttpContext': - return ctx - default: { - const inst = this.app.make(paramType) - if (inst instanceof Model) { - // Route model binding returns a Promise - return await Helpers.resolveRouteModelBinding(path ?? '', ctx, inst) - } - return inst - } - } - }) - ) - - /** - * Ensure that the HttpContext is always available - */ - if (args.length < 1) { - args = [ctx] - } - - /** - * Call the controller method, passing all resolved dependencies - */ - return await this.handleResponse(async () => await (controller[action] as any)?.(...args), ctx) - } - } - - /** - * Call the route callback handler - */ - return async (ctx) => { - return await this.handleResponse(handler as EventHandler, ctx) - } - } - /** * Gracefully handle the outgoing response and pass all caught errors * to the exception handler. @@ -427,7 +239,7 @@ export class Router implements IRouter { * * @param request */ - public async dispatch (request: Request) { + async dispatch (request: Request) { this.currentRequest = request return await this.dispatchToRoute(request) } @@ -437,7 +249,7 @@ export class Router implements IRouter { * * @param request */ - public async dispatchToRoute (request: Request) { + async dispatchToRoute (request: Request) { return await this.runRoute(request, this.findRoute(request)) } @@ -447,9 +259,9 @@ export class Router implements IRouter { * @param request */ protected findRoute (request: Request) { - this.events.dispatch(new Routing(request)) + this.events?.dispatch(new Routing(request)) - const route = this.collection.match(request) + const route = this.routes.match(request) this.current = route @@ -469,7 +281,7 @@ export class Router implements IRouter { protected async runRoute (request: Request, route: Route) { request.setRouteResolver(() => route) - this.events.dispatch(new RouteMatched(route, request)) + this.events?.dispatch(new RouteMatched(route, request)) const response = await this.prepareResponse(request, await this.runRouteWithinStack(route, request)) return response @@ -498,8 +310,8 @@ export class Router implements IRouter { * * @return array */ - getMiddleware () { - return this.#middleware + getMiddleware (): GenericObject { + return this.middlewares } /** @@ -508,8 +320,8 @@ export class Router implements IRouter { * @param name * @param class */ - aliasMiddleware (name: string, cls: IMiddleware) { - this.#middleware[name] = cls + aliasMiddleware (name: string, cls: IMiddleware): this { + this.middlewares[name] = cls return this } @@ -519,7 +331,7 @@ export class Router implements IRouter { * * @param route */ - public gatherRouteMiddleware (route: Route) { + gatherRouteMiddleware (route: Route): any { return this.resolveMiddleware( route.gatherMiddleware(), route.excludedMiddleware() @@ -533,17 +345,17 @@ export class Router implements IRouter { * @param excluded * @return array */ - resolveMiddleware (middleware: IMiddleware[], excluded: IMiddleware[] = []) { + resolveMiddleware (middleware: IMiddleware[], excluded: IMiddleware[] = []): any { excluded = excluded.length === 0 ? excluded : (new Collection(excluded)) - .map((name) => MiddlewareResolver.setApp(this.app).resolve(name, this.#middleware, this.middlewareGroups)) + .map((name) => MiddlewareResolver.setApp(this.app).resolve(name, this.middlewares, this.middlewareGroups)) .flatten() .values() .all() as never const middlewares = (new Collection(middleware)) - .map((name) => MiddlewareResolver.setApp(this.app).resolve(name, this.#middleware, this.middlewareGroups)) + .map((name) => MiddlewareResolver.setApp(this.app).resolve(name, this.middlewares, this.middlewareGroups)) .flatten() middlewares.when( @@ -606,10 +418,10 @@ export class Router implements IRouter { * @param response */ async prepareResponse (request: IRequest, response: ResponsableType) { - this.events.dispatch(new PreparingResponse(request, response)) + this.events?.dispatch(new PreparingResponse(request, response)) return tap(Router.toResponse(request, response), (response) => { - this.events.dispatch(new ResponsePrepared(request, response)) + this.events?.dispatch(new ResponsePrepared(request, response)) }) } @@ -623,7 +435,7 @@ export class Router implements IRouter { if (response instanceof IResponsable) { response = response.toResponse(request) } - + // console.log(response) // if (response instanceof Model && response.wasRecentlyCreated) { // response = new JsonResponse(response, 201) // } @@ -651,254 +463,216 @@ export class Router implements IRouter { /** * Registers a route that responds to HTTP GET requests. - * - * @param path The URL pattern to match (can include parameters, e.g., '/users/:id'). - * @param definition Either: - * - An EventHandler function - * - A tuple: [ControllerClass, methodName] - * @param name Optional route name (for URL generation or referencing). - * @param middleware Optional array of middleware functions to execute before the handler. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns */ - get any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], - name?: string, - middleware: IMiddleware[] = [] - ): Omit { - - const handler = Array.isArray(definition) ? definition[0] : definition - const methodName = Array.isArray(definition) ? definition[1] : undefined - - // Add the route to the route stack - this.addRoute( - 'get', - path, - this.resolveControllerOrHandler(handler, methodName, path), - name, - middleware, - [handler.name, methodName] - ) + get (uri: string, action: ActionInput): Route { + return this.addRoute(['GET'], uri, action) + } - return this + /** + * Registers a route that responds to HTTP HEAD requests. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns + */ + head (uri: string, action: ActionInput): Route { + return this.addRoute(['HEAD'], uri, action) } /** * Registers a route that responds to HTTP POST requests. - * - * @param path The URL pattern to match (can include parameters, e.g., '/users'). - * @param definition Either: - * - An EventHandler function - * - A tuple: [ControllerClass, methodName] - * @param name Optional route name (for URL generation or referencing). - * @param middleware Optional array of middleware functions to execute before the handler. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns */ - post any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], - name?: string, - middleware: IMiddleware[] = [] - ): Omit { - - const handler = Array.isArray(definition) ? definition[0] : definition - const methodName = Array.isArray(definition) ? definition[1] : undefined - - // Add the route to the route stack - this.addRoute( - 'post', - path, - this.resolveControllerOrHandler(handler, methodName, path), - name, - middleware, - [handler.name, methodName] - ) - - return this + post (uri: string, action: ActionInput): Route { + return this.addRoute(['POST'], uri, action) } /** * Registers a route that responds to HTTP PUT requests. - * - * @param path The URL pattern to match (can include parameters, e.g., '/users/:id'). - * @param definition Either: - * - An EventHandler function - * - A tuple: [ControllerClass, methodName] - * @param name Optional route name (for URL generation or referencing). - * @param middleware Optional array of middleware functions to execute before the handler. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns */ - put any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], - name?: string, - middleware: IMiddleware[] = [] - ): Omit { - - const handler = Array.isArray(definition) ? definition[0] : definition - const methodName = Array.isArray(definition) ? definition[1] : undefined - - // Add the route to the route stack - this.addRoute( - 'put', - path, - this.resolveControllerOrHandler(handler, methodName, path), - name, - middleware, - [handler.name, methodName] - ) - return this + put (uri: string, action: ActionInput): Route { + return this.addRoute(['PUT'], uri, action) } /** * Registers a route that responds to HTTP PATCH requests. - * - * @param path The URL pattern to match (can include parameters, e.g., '/users/:id'). - * @param definition Either: - * - An EventHandler function - * - A tuple: [ControllerClass, methodName] - * @param name Optional route name (for URL generation or referencing). - * @param middleware Optional array of middleware functions to execute before the handler. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns */ - patch any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], - name?: string, - middleware: IMiddleware[] = [] - ): Omit { - - const handler = Array.isArray(definition) ? definition[0] : definition - const methodName = Array.isArray(definition) ? definition[1] : undefined - - // Add the route to the route stack - this.addRoute( - 'patch', - path, - this.resolveControllerOrHandler(handler, methodName, path), - name, - middleware, - [handler.name, methodName] - ) + patch (uri: string, action: ActionInput): Route { + return this.addRoute(['PATCH'], uri, action) + } - return this + /** + * Registers a route that responds to HTTP OPTIONS requests. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns + */ + options (uri: string, action: ActionInput): Route { + return this.addRoute(['OPTIONS'], uri, action) } /** * Registers a route that responds to HTTP DELETE requests. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns + */ + delete (uri: string, action: ActionInput): Route { + return this.addRoute(['DELETE'], uri, action) + } + + /** + * Registers a route the matches the provided methods. + * + * @param methods - The route methods to match. + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + */ + match (methods: RouteMethod | RouteMethod[], uri: string, action: ActionInput): Route { + return this.addRoute(Arr.wrap(methods), uri, action) + } + + /** + * Route a resource to a controller. * - * @param path The URL pattern to match (can include parameters, e.g., '/users/:id'). - * @param definition Either: - * - An EventHandler function - * - A tuple: [ControllerClass, methodName] - * @param name Optional route name (for URL generation or referencing). - * @param middleware Optional array of middleware functions to execute before the handler. + * @param name + * @param controller + * @param options */ - delete any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractClassMethods>], - name?: string, - middleware: IMiddleware[] = [] - ): Omit { - - const handler = Array.isArray(definition) ? definition[0] : definition - const methodName = Array.isArray(definition) ? definition[1] : undefined - - // Add the route to the route stack - this.addRoute( - 'delete', - path, - this.resolveControllerOrHandler(handler, methodName, path), + resource (name: string, controller: C, options: ResourceOptions = {}): PendingResourceRegistration { + let registrar: ResourceRegistrar + if (this.app && this.app.bound(ResourceRegistrar)) { + registrar = this.app.make(ResourceRegistrar) + } else { + registrar = new ResourceRegistrar(this) + } + + return new PendingResourceRegistration( + registrar, name, - middleware, - [handler.name, methodName] - ) + controller, + options + ).$finalize() + } - return this + /** + * Register an array of API resource controllers. + * + * @param resources + * @param options + */ + apiResources (resources: GenericObject, options: ResourceOptions = {}): void { + for (const [name, controller] of Object.entries(resources)) { + this.apiResource(name, controller, options) + } } /** - * API Resource support - * - * @param path - * @param controller + * Route an API resource to a controller. + * + * @param name + * @param controller + * @param options */ - apiResource any> ( - path: string, - Controller: C, - middleware: IMiddleware[] = [] - ): Omit { - path = path.replace(/\//g, '/') - - const basePath = `/${path}`.replace(/\/+$/, '').replace(/(\/)+/g, '$1') - const name = basePath.substring(basePath.lastIndexOf('/') + 1).replaceAll(/\/|:/g, '') || '' - const param = Str.singular(name) - - this.get(basePath, [Controller, 'index'], `${name}.index`, middleware) - this.post(basePath, [Controller, 'store'], `${name}.store`, middleware) - this.get(`${basePath}/:${param}`, [Controller, 'show'], `${name}.show`, middleware) - this.put(`${basePath}/:${param}`, [Controller, 'update'], `${name}.update`, middleware) - this.patch(`${basePath}/:${param}`, [Controller, 'update'], `${name}.update`, middleware) - this.delete(`${basePath}/:${param}`, [Controller, 'destroy'], `${name}.destroy`, middleware) + apiResource (name: string, controller: C, options: ResourceOptions = {}): PendingResourceRegistration { + let only: ResourceMethod[] = ['index', 'show', 'store', 'update', 'destroy'] - return this + if (typeof options.except !== 'undefined') { + only = only.filter(value => !options.except?.includes(value)) + } + + return this.resource(name, controller, Object.assign({}, { only }, options)) } /** - * Registers a route the matches the provided methods. - * @param methods - The route methods to match. - * @param uri - The route uri. - * @param action - The handler function or [controller class, method] array. + * Register an array of singleton resource controllers. + * + * @param singletons + * @param options */ - match (methods: Lowercase[], uri: string, action: ActionInput): Route { - return this.#addRoute(Arr.wrap(methods).map(e => e.toUpperCase() as RouteMethod), uri, action) + singletons (singletons: GenericObject, options: ResourceOptions = {}): void { + for (const [name, controller] of Object.entries(singletons)) { + this.singleton(name, controller, options) + } } /** - * Named route URL generator - * - * @param name - * @param params - * @returns + * Route a singleton resource to a controller. + * + * @param name + * @param controller + * @param options */ - route (name: string, params: Record = {}): string | undefined { - const found = this.routes.find(r => r.name === name) - if (!found) return undefined + singleton (name: string, controller: C, options: ResourceOptions = {}): PendingSingletonResourceRegistration { + let registrar: ResourceRegistrar - let url = found.path - for (const [key, value] of Object.entries(params)) { - url = url.replace(`:${key}`, value) + if (this.app && this.app.bound(ResourceRegistrar)) { + registrar = this.app.make(ResourceRegistrar) + } else { + registrar = new ResourceRegistrar(this) } - return url + + return new PendingSingletonResourceRegistration( + registrar, + name, + controller, + options + ).$finalize() } /** - * Grouping - * - * @param options - * @param callback + * Register an array of API singleton resource controllers. + * + * @param singletons + * @param options */ - // group (options: { prefix?: string; middleware?: EventHandler[] }, callback: (_e: this) => void) { - // const prevPrefix = this.groupPrefix - // const prevMiddleware = [...this.groupMiddleware] + apiSingletons (singletons: GenericObject, options: ResourceOptions = {}): void { + for (const [name, controller] of Object.entries(singletons)) { + this.apiSingleton(name, controller, options) + } + } - // this.groupPrefix += options.prefix || '' - // this.groupMiddleware.push(...(options.middleware || [])) + /** + * Route an API singleton resource to a controller. + * + * @param name + * @param controller + * @param options + */ + apiSingleton (name: string, controller: C, options: ResourceOptions = {}): PendingSingletonResourceRegistration { + let only: ResourceMethod[] = ['store', 'show', 'update', 'destroy'] - // callback(this) + if (typeof options.except !== 'undefined') { + only = only.filter(v => !options.except?.includes(v)) + } - // /** - // * Restore state after group - // */ - // this.groupPrefix = prevPrefix - // this.groupMiddleware = prevMiddleware - // return this - // } + return this.singleton(name, controller, Object.assign({ only }, options)) + } /** * Create a route group with shared attributes. * * @param attributes * @param routes - * @return $this */ - public group void) | string> (attributes: RouteActions, routes: C | C[]) { + group void) | string> (attributes: RouteActions, routes: C | C[]) { for (const groupRoutes of Arr.wrap(routes)) { this.updateGroupStack(attributes) @@ -932,7 +706,7 @@ export class Router implements IRouter { * @param newItems * @param prependExistingPrefix */ - public mergeWithLastGroup (newItems: RouteActions, prependExistingPrefix = true) { + mergeWithLastGroup (newItems: RouteActions, prependExistingPrefix = true) { return RouteGroup.merge(newItems, Arr.last(this.groupStack, true)[0], prependExistingPrefix) } @@ -942,18 +716,18 @@ export class Router implements IRouter { * @param routes */ protected async loadRoutes (routes: string | ((_e: this) => void)) { + const require = createRequire(import.meta.url) if (typeof routes === 'function') { routes(this) - } else if (await FileSystem.fileExists(routes)) { - const { default: route } = await import(routes) - route(this) + } else if (existsSync(this.app.paths.distPath(routes))) { + require(this.app.paths.distPath(routes)) } } /** * Get the prefix from the last group on the stack. */ - public getLastGroupPrefix () { + getLastGroupPrefix () { if (this.hasGroupStack()) { const last = Arr.last(this.groupStack, true)[0] @@ -978,7 +752,7 @@ export class Router implements IRouter { /** * Determine if the router currently has a group stack. */ - public hasGroupStack () { + hasGroupStack () { return this.groupStack.length > 0 } @@ -997,19 +771,21 @@ export class Router implements IRouter { * * @param uri */ + @internal protected prefix (uri: string) { return Str.trim(Str.trim(this.getLastGroupPrefix(), '/') + '/' + Str.trim(uri, '/'), '/') || '/' } /** - * Registers middleware for a specific path. - * @param path - The path to apply the middleware. + * Registers H3 middleware for a specific path. + * + * @param path - The middleware or path to apply the middleware. * @param handler - The middleware handler. * @param opts - Optional middleware options. */ - middleware ( + h3middleware ( path: string | IMiddleware[] | Middleware, - handler: Middleware | MiddlewareOptions, + handler?: Middleware | MiddlewareOptions, opts?: MiddlewareOptions ): this { opts = typeof handler === 'object' ? handler : (typeof opts === 'function' ? opts : {}) @@ -1025,4 +801,32 @@ export class Router implements IRouter { return this } + + /** + * Dynamically handle calls into the router instance. + * + * @param method + * @param parameters + */ + protected __call (method: string, parameters: any[]) { + // console.log(method, this.constructor.name, 'this.constructor.name') + if (Router.hasMacro(method)) { + return this.macroCall(method, parameters) + } + + if (method === 'middleware') { + return new RouteRegistrar(this).attribute(method, Array.isArray(parameters[0]) ? parameters[0] : parameters) + } + + if (method === 'can') { + return new RouteRegistrar(this).attribute(method, [parameters]) + } + + if (method !== 'where' && Str.startsWith(method, 'where')) { + const registerer = new RouteRegistrar(this) + return Reflect.apply(registerer[method], registerer, parameters) + } + + return new RouteRegistrar(this).attribute(method, parameters?.[0] ?? true) + } } diff --git a/packages/router/src/TraitLike/FiltersControllerMiddleware.ts b/packages/router/src/Traits/FiltersControllerMiddleware.ts similarity index 100% rename from packages/router/src/TraitLike/FiltersControllerMiddleware.ts rename to packages/router/src/Traits/FiltersControllerMiddleware.ts diff --git a/packages/router/src/TraitLike/RouteDependencyResolver.ts b/packages/router/src/Traits/RouteDependencyResolver.ts similarity index 86% rename from packages/router/src/TraitLike/RouteDependencyResolver.ts rename to packages/router/src/Traits/RouteDependencyResolver.ts index 403fa7eb..38ddd162 100644 --- a/packages/router/src/TraitLike/RouteDependencyResolver.ts +++ b/packages/router/src/Traits/RouteDependencyResolver.ts @@ -1,9 +1,9 @@ import 'reflect-metadata' -import { ControllerMethod, IController } from '@h3ravel/contracts' +import { IController, ResourceMethod } from '@h3ravel/contracts' import { Application } from '@h3ravel/core' -import { LogicException } from '@h3ravel/foundation' +import { RuntimeException } from '@h3ravel/support' export class RouteDependencyResolver { constructor(protected container: Application) { } @@ -15,7 +15,7 @@ export class RouteDependencyResolver { * @param instance * @param method */ - public async resolveClassMethodDependencies (parameters: Record, instance: IController, method: ControllerMethod) { + public async resolveClassMethodDependencies (parameters: Record, instance: IController, method: ResourceMethod) { if (!Object.prototype.hasOwnProperty.call(instance, method)) { return parameters } @@ -24,7 +24,7 @@ export class RouteDependencyResolver { * Ensure the method exists on the controller */ if (typeof instance[method] !== 'function') { - throw new LogicException(`Method "${method}" not found on controller ${instance.constructor.name}`) + throw new RuntimeException(`[${method}] not found on controller [${instance.constructor.name}]`) } /** diff --git a/packages/router/src/UrlGenerator.ts b/packages/router/src/UrlGenerator.ts new file mode 100644 index 00000000..c30c2043 --- /dev/null +++ b/packages/router/src/UrlGenerator.ts @@ -0,0 +1,484 @@ +import { CallableConstructor, GenericObject, IRequest, IRoute } from '@h3ravel/contracts' +import { optional, tap } from '@h3ravel/support' + +import { Route } from './Route' +import { RouteCollection } from './RouteCollection' +import { RouteNotFoundException } from '@h3ravel/foundation' +import { RouteUrlGenerator } from './RouteUrlGenerator' +import crypto from 'crypto' + +export class UrlGenerator { + protected routes: RouteCollection + protected request: IRequest + + protected assetRoot?: string + protected forcedRoot?: string + protected forceScheme?: string + + protected cachedRoot?: string + protected cachedScheme?: string + + protected keyResolver?: () => string | string[] + protected missingNamedRouteResolver?: CallableConstructor + + /** + * The session resolver callable. + */ + protected sessionResolver?: CallableConstructor + + /** + * The route URL generator instance. + */ + protected routeGenerator?: RouteUrlGenerator + + /** + * The named parameter defaults. + */ + public defaultParameters: GenericObject = {} + + /** + * The callback to use to format hosts. + */ + #formatHostUsing?: CallableConstructor + + /** + * The callback to use to format paths. + */ + #formatPathUsing?: CallableConstructor + + constructor(routes: RouteCollection, request: IRequest, assetRoot?: string) { + this.routes = routes + this.request = request + this.assetRoot = assetRoot + } + + /** + * Get the full URL for the current request, + * including the query string. + * + * Example: + * https://example.com/users?page=2 + */ + full (): string { + return this.request.fullUrl() + } + + /** + * Get the URL for the current request path + * without modifying the query string. + */ + current (): string { + return this.to(this.request.getPathInfo()) + } + + /** + * Get the URL for the previous request. + * + * Resolution order: + * 1. HTTP Referer header + * 2. Session-stored previous URL + * 3. Fallback (if provided) + * 4. Root "/" + * + * @param fallback Optional fallback path or URL + */ + previous (fallback: string | false = false): string { + const referrer = this.request.headers.get('referer') + + const url = referrer ? this.to(referrer) : this.getPreviousUrlFromSession() + + if (url) { + return url + } else if (fallback) { + return this.to(fallback) + } + + return this.to('/') + } + + /** + * Generate an absolute URL to the given path. + * + * - Accepts relative paths or full URLs + * - Automatically prefixes scheme + host + * - Encodes extra path parameters safely + * + * @param path Relative or absolute path + * @param extra Additional path segments + * @param secure Force HTTPS or HTTP + */ + to (path: string, extra: any[] = [], secure: boolean | null = null): string { + if (this.isValidUrl(path)) { + return path + } + + const tail = extra.map(v => encodeURIComponent(String(v))).join('/') + const root = this.formatRoot(this.formatScheme(secure)) + const [cleanPath, query] = this.extractQueryString(path) + + return this.format( + root, + '/' + [cleanPath, tail].filter(Boolean).join('/') + ) + query + } + + /** + * Generate a secure (HTTPS) absolute URL. + * + * @param path + * @param parameters + * @returns + */ + secure (path: string, parameters: any[] = []) { + return this.to(path, parameters, true) + } + + /** + * Generate a URL to a public asset. + * + * - Skips URL generation if path is already absolute + * - Removes index.php from root if present + * + * @param path Asset path + * @param secure Force HTTPS + */ + asset (path: string, secure: boolean | null = null): string { + if (this.isValidUrl(path)) { + return path + } + + const root = this.assetRoot ?? this.formatRoot(this.formatScheme(secure)) + return this.removeIndex(root).replace(/\/$/, '') + '/' + path.replace(/^\/+/, '') + } + /** + * Generate a secure (HTTPS) asset URL. + * + * @param path + * @returns + */ + secureAsset (path: string) { + return this.asset(path, true) + } + + /** + * Resolve the URL scheme to use. + * + * Priority: + * 1. Explicit `secure` flag + * 2. Forced scheme + * 3. Request scheme (cached) + * + * @param secure + */ + formatScheme (secure: boolean | null = null): string { + if (secure !== null) { + return secure ? 'https://' : 'http://' + } + + if (!this.cachedScheme) { + this.cachedScheme = this.forceScheme ?? `${this.request.getScheme()}://` + } + + return this.cachedScheme + } + /** + * Format the base root URL. + * + * - Applies forced root if present + * - Replaces scheme while preserving host + * - Result is cached per request + * + * @param scheme URL scheme + * @param root Optional custom root + */ + formatRoot (scheme: string, root?: string): string { + const base = root ?? this.forcedRoot ?? `${this.request.getScheme()}://${this.request.getHost()}` + return base.replace(/^https?:\/\//, scheme) + } + + signedRoute ( + name: string, + parameters: Record = {}, + expiration?: number, + absolute = true + ): string { + if (!this.keyResolver) { + throw new Error('No key resolver configured.') + } + + if (expiration) { + parameters.expires = expiration + } + + const url = this.route(name, parameters, absolute) + const resolvedKeys = this.keyResolver() + const keys = Array.isArray(resolvedKeys) ? resolvedKeys : [resolvedKeys] + + const signature = crypto + .createHmac('sha256', keys[0]) + .update(url) + .digest('hex') + + return this.route(name, { ...parameters, signature }, absolute) + } + + hasValidSignature (request: IRequest): boolean { + const signature = request.query('signature') + if (!signature || !this.keyResolver) return false + + const original = request.url() + const resolvedKeys = this.keyResolver() + const keys = Array.isArray(resolvedKeys) ? resolvedKeys : [resolvedKeys] + + return keys.some(key => + crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from( + crypto.createHmac('sha256', key).update(original).digest('hex') + ) + ) + ) + } + + route (name: string, parameters: GenericObject = {}, absolute = true): string { + const route = this.routes.getByName(name) + + if (route != null) { + return this.toRoute(route, parameters, absolute) + } + + if (this.missingNamedRouteResolver) { + const url = this.missingNamedRouteResolver(name, parameters, absolute) + if (url != null) return url + } + + throw new RouteNotFoundException(`Route [${name}] not defined.`) + } + + /** + * Get the URL for a given route instance. + * + * @param route + * @param parameters + * @param absolute + */ + toRoute (route: Route, parameters: GenericObject = {}, absolute: boolean = true) { + return this.routeUrl().to( + route, + parameters, + absolute + ) + } + + /** + * Combine root and path into a final URL. + * + * Allows optional host and path formatters + * to modify the output dynamically. + * + * @param root + * @param path + * @param route + * @returns + */ + format (root: string, path: string, route?: Route): string { + let finalPath = '/' + path.replace(/^\/+/, '') + + if (this.#formatHostUsing) { + root = this.#formatHostUsing(root, route) + } + + if (this.#formatPathUsing) { + finalPath = this.#formatPathUsing(finalPath, route) + } + + return (root + finalPath).replace(/\/+$/, '') + } + + /** + * Format the array of URL parameters. + * + * @param parameters + */ + formatParameters (parameters: GenericObject) { + for (const [key, parameter] of Object.entries(parameters)) { + if (typeof parameter.getRouteKey === 'function') { + parameters[key] = parameter.getRouteKey() + } + } + + return parameters + } + + protected extractQueryString (path: string): [string, string] { + const i = path.indexOf('?') + return i === -1 ? [path, ''] : [path.slice(0, i), path.slice(i)] + } + + /** + * @param root + */ + protected removeIndex (root: string): string { + return root + } + + /** + * Determine whether a string is a valid URL. + * + * Supports: + * - Absolute URLs + * - Protocol-relative URLs + * - Anchors and special schemes + * + * @param path + * @returns + */ + isValidUrl (path: string): boolean { + if (/^(#|\/\/|https?:\/\/|(mailto|tel|sms):)/.test(path)) { + return true + } + + try { + new URL(path) + return true + } catch { + return false + } + } + + /** + * Get the Route URL generator instance. + */ + protected routeUrl (): RouteUrlGenerator { + if (!this.routeGenerator) { + this.routeGenerator = new RouteUrlGenerator(this, this.request) + } + + return this.routeGenerator + } + + /** + * Force HTTPS for all generated URLs. + * + * @param force + */ + forceHttps (force = true) { + if (force) this.forceScheme = 'https://' + } + + /** + * Set the origin (scheme + host) for generated URLs. + * + * @param root + */ + useOrigin (root?: string) { + this.forcedRoot = root?.replace(/\/$/, '') + this.cachedRoot = undefined + } + + useAssetOrigin (root?: string) { + this.assetRoot = root?.replace(/\/$/, '') + } + + setKeyResolver (resolver: () => string | string[]) { + this.keyResolver = resolver + } + + resolveMissingNamedRoutesUsing (resolver: CallableConstructor) { + this.missingNamedRouteResolver = resolver + } + + formatHostUsing (callback: CallableConstructor) { + this.#formatHostUsing = callback + return this + } + + formatPathUsing (callback: CallableConstructor) { + this.#formatPathUsing = callback + return this + } + + /** + * Get the request instance. + */ + getRequest () { + return this.request + } + + /** + * Set the current request instance. + * + * @param request + */ + setRequest (request: IRequest) { + this.request = request + + this.cachedRoot = undefined + this.cachedScheme = undefined + + tap(optional(this.routeGenerator).defaultParameters || [], (defaults) => { + this.routeGenerator = undefined + + if (defaults) { + this.defaults(defaults) + } + }) + } + + /** + * Set the route collection. + * + * @param routes + */ + setRoutes (routes: RouteCollection) { + this.routes = routes + + return this + } + + /** + * Get the session implementation from the resolver. + */ + protected getSession () { + if (this.sessionResolver) { + return this.sessionResolver() + } + } + + /** + * Set the session resolver for the generator. + * + * @param sessionResolver + */ + setSessionResolver (sessionResolver: CallableConstructor) { + this.sessionResolver = sessionResolver + + return this + } + + /** + * Clone a new instance of the URL generator with a different encryption key resolver. + * + * @param keyResolver + */ + withKeyResolver (keyResolver: () => string | string[]) { + return structuredClone(this).setKeyResolver(keyResolver) + } + + /** + * Set the default named parameters used by the URL generator. + * + * @param array $defaults + * @return void + */ + defaults (defaults: GenericObject) { + this.defaultParameters = Object.assign({}, this.defaultParameters, defaults) + } + + /** + * Get the previous URL from the session if possible. + */ + protected getPreviousUrlFromSession () { + return this.getSession()?.previousUrl() + } +} diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 01acf664..3de9f890 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -6,6 +6,7 @@ export * from './Contracts/IRouteValidator' export * from './Contracts/Utilities' export * from './Controller' export * from './ControllerDispatcher' +export * from './CreatesRegularExpressionRouteConstraints' export * from './Events/PreparingResponse' export * from './Events/ResponsePrepared' export * from './Events/RouteMatched' @@ -17,15 +18,19 @@ export * from './Matchers/SchemeValidator' export * from './Matchers/UriValidator' export * from './Middleware/SubstituteBindings' export * from './MiddlewareResolver' +export * from './PendingResourceRegistration' +export * from './PendingSingletonResourceRegistration' export * from './Pipeline' -export * from './Providers/AssetsServiceProvider' -export * from './Providers/RouteServiceProvider' +export * from './ResourceRegistrar' export * from './Route' export * from './RouteAction' export * from './RouteCollection' export * from './RouteGroup' export * from './RouteParameterBinder' export * from './Router' +export * from './RouteRegisterer' export * from './RouteUri' -export * from './TraitLike/FiltersControllerMiddleware' -export * from './TraitLike/RouteDependencyResolver' +export * from './RouteUrlGenerator' +export * from './Traits/FiltersControllerMiddleware' +export * from './Traits/RouteDependencyResolver' +export * from './UrlGenerator' diff --git a/packages/session/src/Providers/SessionServiceProvider.ts b/packages/session/src/Providers/SessionServiceProvider.ts index 373d93bc..5c88741c 100644 --- a/packages/session/src/Providers/SessionServiceProvider.ts +++ b/packages/session/src/Providers/SessionServiceProvider.ts @@ -1,7 +1,7 @@ import { dbBuilder, fileBuilder, memoryBuilder, redisBuilder } from '../adapters' import { MakeSessionTableCommand } from '../Commands/MakeSessionTableCommand' -import { ServiceProvider } from '@h3ravel/foundation' +import { ServiceProvider } from '@h3ravel/support' import { SessionStore } from '../SessionStore' export class SessionServiceProvider extends ServiceProvider { diff --git a/packages/shared/src/Container.ts b/packages/shared/src/Container.ts new file mode 100644 index 00000000..1046a8d9 --- /dev/null +++ b/packages/shared/src/Container.ts @@ -0,0 +1 @@ +export const INTERNAL_METHODS = Symbol('internal_methods') \ No newline at end of file diff --git a/packages/shared/src/Mixins/MixinSystem.ts b/packages/shared/src/Mixins/MixinSystem.ts new file mode 100644 index 00000000..00731c9e --- /dev/null +++ b/packages/shared/src/Mixins/MixinSystem.ts @@ -0,0 +1,85 @@ +import { ClassConstructor } from '@h3ravel/contracts' + +/** + * Helper to convert a Union (A | B) into an Intersection (A & B) + */ +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; + +/** + * Infers the mixed type of all base classes provided + */ +type MixedClass = UnionToIntersection & + (new (...args: any[]) => UnionToIntersection>); + +/** + * Helper to mix multiple classes into one, this allows extending multiple classes by any single class + * + * @param bases + * @returns + */ +export const mix = (...bases: T): MixedClass => { + // This is the base class that will manage the lifecycle + class Base { + constructor(...args: any[]) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let instance: Base = this + + for (const constructor of bases) { + // Reflect.construct triggers the base constructor logic. + // If the constructor returns a Proxy, 'result' will be that Proxy. + const result = Reflect.construct(constructor, args, new.target) + + if (result && (typeof result === 'object' || typeof result === 'function')) { + // If a Proxy or object was returned, we merge existing properties + // into it and make it our primary instance. + if (result !== instance) { + Object.assign(result, instance) + instance = result + } + } + } + // Returning 'instance' here overrides the 'this' of the new ChildClass() + return instance + } + } + + // Chain Statics and Prototypes + for (let i = 0; i < bases.length; i++) { + const currentBase = bases[i] + const nextBase = bases[i + 1] + + // Copy prototype methods (for type inference and runtime access) + Object.getOwnPropertyNames(currentBase.prototype).forEach(prop => { + if (prop !== 'constructor') { + Object.defineProperty( + Base.prototype, + prop, + Object.getOwnPropertyDescriptor(currentBase.prototype, prop)! + ) + } + }) + + // Copy static methods on extended classes + Object.getOwnPropertyNames(currentBase).forEach(prop => { + if (!['prototype', 'name', 'length'].includes(prop)) { + Object.defineProperty( + Base, + prop, + Object.getOwnPropertyDescriptor(currentBase, prop)! + ) + } + }) + + // Link Prototype Chain (for X instanceof ParentClass) + if (nextBase) { + Object.setPrototypeOf(currentBase.prototype, nextBase.prototype) + Object.setPrototypeOf(currentBase, nextBase) + } + } + + // Finally, link our internal Base to the head of the chain + Object.setPrototypeOf(Base.prototype, bases[0].prototype) + Object.setPrototypeOf(Base, bases[0]) + + return Base as any +} \ No newline at end of file diff --git a/packages/shared/src/Mixins/TraitSystem.ts b/packages/shared/src/Mixins/TraitSystem.ts new file mode 100644 index 00000000..b57e387f --- /dev/null +++ b/packages/shared/src/Mixins/TraitSystem.ts @@ -0,0 +1,426 @@ +/* +** Extracted from @traits-ts/core - Traits for TypeScript Classes +** Copyright (c) 2025 Dr. Ralf S. Engelschall +** Licensed under MIT license +*/ + +/* eslint no-use-before-define: off */ + +/* ==== UTILITY DEFINITIONS ==== */ + +/* utility function: CRC32-hashing a string into a unique identifier */ +const crcTable = [] as number[] +for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) + c = ((c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)) + crcTable[n] = c +} +export const crc32 = (str: string) => { + let crc = 0 ^ (-1) + for (let i = 0; i < str.length; i++) + crc = (crc >>> 8) ^ crcTable[(crc ^ str.charCodeAt(i)) & 0xFF] + return (crc ^ (-1)) >>> 0 +} + +type ResolveTraitLike> = + T extends TypeFactory + ? ExtractFactory> + : T extends Trait + ? ExtractFactory + : unknown; + +type Combine = + T extends [infer Head, ...infer Tail] + ? Head & Combine + : object; + +type MapClassesToPrototypes any) & { prototype: any }>> = { + [K in keyof T]: T[K]['prototype']; +} + +type MapClassesToInstances any) & { prototype: any }>> = { + [K in keyof T]: InstanceType; +} + +type CombineClasses any) & { prototype: any }>> = + (new () => Combine>) & { prototype: Combine> }; + +type ResolveTraitLikeArray>> = CombineClasses<{ + [K in keyof T]: ResolveTraitLike; +}>; + +/* utility type and function: constructor (function) */ +type Cons = + new (...args: any[]) => T +const isCons = + + (fn: unknown): fn is Cons => + typeof fn === 'function' && !!fn.prototype && !!fn.prototype.constructor + +/* utility type and function: constructor factory (function) */ +type ConsFactory = + (base: B) => T + +/* utility type and function: type factory (function) */ +type TypeFactory = + () => T +const isTypeFactory = + + (fn: unknown): fn is TypeFactory => + typeof fn === 'function' && !fn.prototype && fn.length === 0 + +/* utility type: map an object type into a bare properties type */ +type Explode = + { [P in keyof T]: T[P] } + +/* utility type: convert two arrays of types into an array of union types */ +type MixParams = + T1 extends [] ? ( + T2 extends [] ? [] : T2 + ) : ( + T2 extends [] ? T1 : ( + T1 extends [infer H1, ...infer R1] ? ( + T2 extends [infer H2, ...infer R2] ? + [H1 & H2, ...MixParams] + : [] + ) : [] + ) + ) + +/* ==== TRAIT DEFINITION ==== */ + +/* API: trait type */ +type TraitDefTypeT = ConsFactory +type TraitDefTypeST = (Trait | TypeFactory)[] | undefined +export type Trait< + T extends TraitDefTypeT = TraitDefTypeT, + ST extends TraitDefTypeST = TraitDefTypeST +> = { + id: number /* unique id (primary, for hasTrait) */ + symbol: symbol /* unique id (secondary, currently unused) */ + factory: T + superTraits: ST +} + +/* API: generate trait (regular variant) */ +/* eslint no-redeclare: off */ +export function trait< + T extends ConsFactory +> (factory: T): Trait + +/* API: generate trait (super-trait variant) */ +export function trait< + const ST extends (Trait | TypeFactory)[], + T extends ConsFactory> +> (superTraits: ST, factory: T): Trait + +/* API: generate trait (technical implementation) */ +export function trait (...args: any[]): Trait { + const factory: ConsFactory = (args.length === 2 ? args[1] : args[0]) + const superTraits: (Trait | TypeFactory)[] = (args.length === 2 ? args[0] : undefined) + return { + id: crc32(factory.toString()), + symbol: Symbol('trait'), + factory, + superTraits + } +} + +/* ==== TRAIT DERIVATION ==== */ + +/* ---- TRAIT PART EXTRACTION ---- */ + +/* utility types: extract factory from a trait */ +type ExtractFactory< + T extends Trait +> = + T extends Trait< + ConsFactory, + TraitDefTypeST + > ? C : never + +/* utility types: extract supertraits from a trait */ +type ExtractSuperTrait< + T extends Trait +> = + T extends Trait< + TraitDefTypeT, + infer ST extends TraitDefTypeST + > ? ST : never + +/* ---- TRAIT CONSTRUCTOR DERIVATION ---- */ + +/* utility type: derive type constructor: merge two constructors */ +type DeriveTraitsConsConsMerge< + A extends Cons, + B extends Cons +> = + A extends (new (...args: infer ArgsA) => infer RetA) ? ( + B extends (new (...args: infer ArgsB) => infer RetB) ? ( + new (...args: MixParams) => RetA & RetB + ) : never + ) : never + +/* utility type: derive type constructor: extract plain constructor */ +type DeriveTraitsConsCons< + T extends Cons +> = + new (...args: ConstructorParameters) => InstanceType + +/* utility type: derive type constructor: from trait parts */ +type DeriveTraitsConsTraitParts< + C extends Cons, + ST extends ((Trait | TypeFactory)[] | undefined) +> = + ST extends undefined ? DeriveTraitsConsCons : + ST extends [] ? DeriveTraitsConsCons : + DeriveTraitsConsConsMerge< + DeriveTraitsConsCons, + DeriveTraitsConsAll> /* RECURSION */ + +/* utility type: derive type constructor: from single trait */ +type DeriveTraitsConsTrait< + T extends Trait +> = + DeriveTraitsConsTraitParts< + ExtractFactory, + ExtractSuperTrait> + +/* utility type: derive type constructor: from single trait or trait factory */ +type DeriveTraitsConsOne< + T extends (Trait | TypeFactory) +> = + T extends Trait ? DeriveTraitsConsTrait : + T extends TypeFactory ? DeriveTraitsConsTrait> : + never + +/* utility type: derive type constructor: from one or more traits or trait factories */ +type DeriveTraitsConsAll< + T extends (((Trait | TypeFactory)[] | [...(Trait | TypeFactory)[], Cons]) | undefined) +> = + T extends [...infer Others extends (Trait | TypeFactory)[], infer Last extends Cons] ? ( + DeriveTraitsConsConsMerge< + DeriveTraitsConsAll, /* RECURSION */ + DeriveTraitsConsCons> + ) : + T extends (Trait | TypeFactory)[] ? ( + T extends [infer First extends (Trait | TypeFactory)] ? ( + DeriveTraitsConsOne + ) : ( + T extends [ + infer First extends (Trait | TypeFactory), + ...infer Rest extends (Trait | TypeFactory)[]] ? ( + DeriveTraitsConsConsMerge< + DeriveTraitsConsOne, + DeriveTraitsConsAll> /* RECURSION */ + ) : never + ) + ) : never + +/* utility type: derive type constructor */ +type DeriveTraitsCons< + T extends ((Trait | TypeFactory)[] | [...(Trait | TypeFactory)[], Cons]) +> = + DeriveTraitsConsAll + +/* ---- TRAIT STATICS DERIVATION ---- */ + +/* utility type: derive type statics: merge two objects with statics */ +type DeriveTraitsStatsConsMerge< + T1 extends object, + T2 extends object +> = + T1 & T2 + +/* utility type: derive type statics: extract plain statics */ +type DeriveTraitsStatsCons< + T extends Cons +> = + Explode + +/* utility type: derive type statics: from trait parts */ +type DeriveTraitsStatsTraitParts< + C extends Cons, + ST extends ((Trait | TypeFactory)[] | undefined) +> = + ST extends undefined ? DeriveTraitsStatsCons : + ST extends [] ? DeriveTraitsStatsCons : + DeriveTraitsStatsConsMerge< + DeriveTraitsStatsCons, + DeriveTraitsStatsAll> /* RECURSION */ + +/* utility type: derive type statics: from single trait */ +type DeriveTraitsStatsTrait< + T extends Trait +> = + DeriveTraitsStatsTraitParts< + ExtractFactory, + ExtractSuperTrait> + +/* utility type: derive type statics: from single trait or trait factory */ +type DeriveTraitsStatsOne< + T extends (Trait | TypeFactory) +> = + T extends Trait ? DeriveTraitsStatsTrait : + T extends TypeFactory ? DeriveTraitsStatsTrait> : + never + +/* utility type: derive type statics: from one or more traits or trait factories */ +type DeriveTraitsStatsAll< + T extends (((Trait | TypeFactory)[] | [...(Trait | TypeFactory)[], Cons]) | undefined) +> = + T extends [...infer Others extends (Trait | TypeFactory)[], infer Last extends Cons] ? ( + DeriveTraitsStatsConsMerge< + DeriveTraitsStatsAll, /* RECURSION */ + DeriveTraitsStatsCons> + ) : + T extends (Trait | TypeFactory)[] ? ( + T extends [infer First extends (Trait | TypeFactory)] ? ( + DeriveTraitsStatsOne + ) : ( + T extends [ + infer First extends (Trait | TypeFactory), + ...infer Rest extends (Trait | TypeFactory)[]] ? ( + DeriveTraitsStatsConsMerge< + DeriveTraitsStatsOne, + DeriveTraitsStatsAll> /* RECURSION */ + ) : never + ) + ) : never + +/* utility type: derive type statics */ +type DeriveTraitsStats< + T extends ((Trait | TypeFactory)[] | [...(Trait | TypeFactory)[], Cons]) +> = + DeriveTraitsStatsAll + +/* ---- TRAIT DERIVATION ---- */ + +/* utility type: derive type from one or more traits or trait type factories */ +type DeriveTraits< + T extends ((Trait | TypeFactory)[] | [...(Trait | TypeFactory)[], Cons]) +> = + DeriveTraitsCons & + DeriveTraitsStats + +/* ---- TRAIT DERIVATION RUNTIME ---- */ + +/* utility function: add an additional invisible property to an object */ +const extendProperties = + (cons: Cons, field: string | symbol, value: any) => + Object.defineProperty(cons, field, { value, enumerable: false, writable: false }) + +/* utility function: get raw trait */ +const rawTrait = (x: (Trait | TypeFactory)) => + isTypeFactory(x) ? x() : x + +/* utility function: derive a trait */ +const deriveTrait = ( + trait$: Trait | TypeFactory, + baseClz: Cons, + derived: Map +) => { + /* get real trait */ + const trait = rawTrait(trait$) + + /* start with base class */ + let clz = baseClz + + /* in case we still have not derived this trait... */ + if (!derived.has(trait.id)) { + derived.set(trait.id, true) + + /* iterate over all of its super traits */ + if (trait.superTraits !== undefined) + for (const superTrait of reverseTraitList(trait.superTraits)) + clz = deriveTrait(superTrait, clz, derived) /* RECURSION */ + + /* derive this trait */ + clz = trait.factory(clz) + extendProperties(clz, 'id', crc32(trait.factory.toString())) + extendProperties(clz, trait.symbol, true) + } + + return clz +} + +/* utility function: get reversed trait list */ +const reverseTraitList = (traits: (Trait | TypeFactory)[]) => + traits.slice().reverse() as (Trait | TypeFactory)[] + +/* API: type derive */ +export function use + , ...(Trait | TypeFactory)[]] | + [...(Trait | TypeFactory)[], Cons] + )> + (...traits: T): DeriveTraits { + /* run-time sanity check */ + if (traits.length === 0) + throw new Error('invalid number of parameters (expected one or more traits)') + + /* determine the base class (clz) and the list of traits (lot) */ + let clz: Cons + let lot: (Trait | TypeFactory)[] + const last = traits[traits.length - 1] + if (isCons(last) && !isTypeFactory(last)) { + /* case 1: with trailing regular class */ + clz = last + lot = traits.slice(0, -1) as (Trait | TypeFactory)[] + } + else { + /* case 2: just regular traits or trait type factories */ + clz = class ROOT { } + lot = traits as (Trait | TypeFactory)[] + } + + /* track already derived traits */ + const derived = new Map() + + /* iterate over all traits */ + for (const trait of reverseTraitList(lot)) + clz = deriveTrait(trait, clz, derived) + + return clz as DeriveTraits +} + +/* ==== TRAIT TYPE-GUARDING ==== */ + +/* internal type: implements trait type */ +type DerivedType = + InstanceType> + +/* internal type: implements trait type or trait type factory */ +export type Derived | Cons)> = + T extends TypeFactory ? DerivedType> : + T extends Trait ? DerivedType : + T extends Cons ? T : + never + +/* API: type guard for checking whether class instance is derived from a trait */ +export function uses + | Cons)> + (instance: unknown, trait: T): instance is Derived { + /* ensure the class instance is really an object */ + if (typeof instance !== 'object' || instance === null) + return false + let obj = instance + + /* special case: regular class */ + if (isCons(trait) && !isTypeFactory(trait)) + return (instance instanceof trait) + + /* regular case: trait or trait type factory... */ + const t = (isTypeFactory(trait) ? trait() : trait) as Trait + const idTrait = t['id'] + while (obj) { + if (Object.hasOwn(obj, 'constructor')) { + const id = ((obj.constructor as any)['id'] as number) ?? 0 + if (id === idTrait) + return true + } + obj = Object.getPrototypeOf(obj) + } + return false +} diff --git a/packages/shared/src/Mixins/UseFinalizable.ts b/packages/shared/src/Mixins/UseFinalizable.ts new file mode 100644 index 00000000..d1f32036 --- /dev/null +++ b/packages/shared/src/Mixins/UseFinalizable.ts @@ -0,0 +1,34 @@ +/* +** Extracted from @traits-ts/stdlib - Traits for TypeScript Classes: Standard Library +** Copyright (c) 2025 Dr. Ralf S. Engelschall +** Licensed under MIT license +*/ + +import { trait } from './TraitSystem' + +/** + * the central class instance registry + */ +const registry = new FinalizationRegistry((fn: () => void) => { + if (typeof fn === 'function' && !(fn as any).finalized) { + (fn as any).finalized = true + fn() + } +}) + +/** + * the API trait "Finalizable" + */ +export const Finalizable = trait((base) => class Finalizable extends base { + constructor(...args: any[]) { + super(...args) + + /* register class instance */ + const fn1 = this.$finalize + if (typeof fn1 !== 'function') + throw new Error('trait Finalizable requires a $finalize method to be defined') + const fn2 = () => { fn1(this) } + fn2.finalized = false + registry.register(this, fn2, this) + } +}) diff --git a/packages/shared/src/Mixins/UseMagic.ts b/packages/shared/src/Mixins/UseMagic.ts new file mode 100644 index 00000000..a4f90598 --- /dev/null +++ b/packages/shared/src/Mixins/UseMagic.ts @@ -0,0 +1,175 @@ +import { trait } from './TraitSystem' + +/** + * Wraps an object in a Proxy to emulate PHP magic methods. + * + * Supported: + * - __call(method, args) + * - __get(property) + * - __set(property, value) + * - __isset(property) + * - __unset(property) + * + * Called automatically by Magic's constructor. + * + * Return in any class constructor to use + * + * @param target + * @returns + */ +export function makeMagic (target: T): T { + return new Proxy(target, { + /** + * Intercepts property access and missing method calls. + */ + get (obj, prop, receiver) { + if (typeof prop === 'string') { + // Real property / method: return normally + if (prop in obj) + return Reflect.get(obj, prop, receiver) + + // Missing method: __call + if ((obj as any).__call) + return (...args: any[]) => (obj as any).__call(prop, args) + + // Missing property: __get + if ((obj as any).__get) + return (obj as any).__get(prop) + } + return undefined + }, + + /** + * Intercepts property assignment. + */ + set (obj, prop, value) { + if (typeof prop === 'string' && (obj as any).__set) { + ; (obj as any).__set(prop, value) + return true + } + return Reflect.set(obj, prop, value) + }, + + /** + * Intercepts `in` operator and existence checks. + */ + has (obj, prop) { + if (typeof prop === 'string' && (obj as any).__isset) { + return (obj as any).__isset(prop) + } + return Reflect.has(obj, prop) + }, + + /** + * Intercepts `delete obj.prop`. + */ + deleteProperty (obj, prop) { + if (typeof prop === 'string' && (obj as any).__unset) { + ; (obj as any).__unset(prop) + return true + } + return Reflect.deleteProperty(obj, prop) + } + }) +} + +/** + * Wraps a class constructor in a Proxy to emulate static PHP magic methods. + * + * Supported: + * - __callStatic(method, args) + * - static __get(property) + * - static __set(property, value) + * - static __isset(property) + * - static __unset(property) + * + * @param cls + * @returns + */ +export function makeStaticMagic any) | (abstract new (...args: any[]) => any)> (cls: T): T { + return new Proxy(cls, { + /** + * Intercepts static property access and missing static calls. + */ + get (target, prop) { + if (typeof prop === 'string') { + // Real static property / method + if (prop in target) { + return (target as any)[prop] + } + + // Missing static method → __callStatic + if ((target as any).__callStatic) { + return (...args: any[]) => + (target as any).__callStatic(prop, args) + } + + // Missing static property → __get + if ((target as any).__get) { + return (target as any).__get(prop) + } + } + return undefined + }, + + /** + * Intercepts static property assignment. + */ + set (target, prop, value) { + if (typeof prop === 'string' && (target as any).__set) { + ; (target as any).__set(prop, value) + return true + } + return Reflect.set(target, prop, value) + }, + + /** + * Intercepts `prop in Class`. + */ + has (target, prop) { + if (typeof prop === 'string' && (target as any).__isset) { + return (target as any).__isset(prop) + } + return Reflect.has(target, prop) + }, + + /** + * Intercepts `delete Class.prop`. + */ + deleteProperty (target, prop) { + if (typeof prop === 'string' && (target as any).__unset) { + ; (target as any).__unset(prop) + return true + } + return Reflect.deleteProperty(target, prop) + } + }) +} + +/** + * Base class that enables PHP-style magic methods automatically. + * + * Any subclass may implement: + * - __call + * - __get + * - __set + * - __isset + * - __unset + * + * The constructor returns a Proxy transparently. + */ +export abstract class Magic { + constructor() { + return makeMagic(this) + } +} + + +export const UseMagic = trait(Base => { + return class Magic extends Base { + constructor(...args: any[]) { + super(...args) + return makeMagic(this) + } + } +}) \ No newline at end of file diff --git a/packages/shared/src/Utils/PathLoader.ts b/packages/shared/src/Utils/PathLoader.ts index ae2b8e39..d0e7877c 100644 --- a/packages/shared/src/Utils/PathLoader.ts +++ b/packages/shared/src/Utils/PathLoader.ts @@ -11,6 +11,7 @@ export class PathLoader { public: '/public', storage: '/storage', database: '/src/database', + commands: '/src/App/Console/Commands/' } /** @@ -54,4 +55,14 @@ export class PathLoader { this.paths[name] = path } + + distPath (path: string, skipExt = false) { + path = path.replace('/src/', `/${process.env.DIST_DIR ?? 'src'}/`.replace(/([^:]\/)\/+/g, '$1')) + + if (!skipExt) { + path = path.replace(/\.(ts|tsx|mts|cts)$/, '.js') + } + + return nodepath.normalize(path) + } } diff --git a/packages/shared/src/Utils/scripts.ts b/packages/shared/src/Utils/scripts.ts index 8c20ed85..d6391de1 100644 --- a/packages/shared/src/Utils/scripts.ts +++ b/packages/shared/src/Utils/scripts.ts @@ -13,7 +13,7 @@ export const mainTsconfig = { }, target: 'es2022', module: 'es2022', - moduleResolution: 'Node', + moduleResolution: 'bundler', esModuleInterop: true, strict: true, allowJs: true, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a2632914..8bc61f05 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,11 @@ +export * from './Container' export * from './Contracts/ObjContract' export * from './Contracts/PromptsContract' export * from './Contracts/Utils' +export * from './Mixins/MixinSystem' +export * from './Mixins/TraitSystem' +export * from './Mixins/UseFinalizable' +export * from './Mixins/UseMagic' export * from './Utils/Console' export * from './Utils/EnvParser' export * from './Utils/FileSystem' diff --git a/packages/shared/tests/mixin.spec.ts b/packages/shared/tests/mixin.spec.ts new file mode 100644 index 00000000..7d0be627 --- /dev/null +++ b/packages/shared/tests/mixin.spec.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest' +import { trait, use, uses } from '../src/Mixins/TraitSystem' + +import { mix } from '../src/Mixins/MixinSystem' + +describe('Mixins', () => { + describe('Mixin System', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => { }) + + abstract class Magic { + makeMagic () { + return 'makeMagic' + } + } + + abstract class Magical { + play () { + return 'Playing' + } + + static pause () { + return 'Paused' + } + } + + abstract class IRouter { + static call () { + return 'Called' + } + } + + abstract class Proxiable { + constructor() { + return new Proxy(this, { + get (target, prop, receiver) { + const val = Reflect.get(target, prop, receiver) as any + if (typeof val === 'function' && val.name === 'proxied') return () => val().toUpperCase() + + return val + } + }) + } + + proxied () { + return 'it worked' + } + } + + class Router extends mix(IRouter, Magic, Magical, Proxiable) { + constructor() { + super() + console.log(this.makeMagic()) + console.log(this.play()) + } + } + + const router = new Router() + + it('child class constructor has access to all parent methods', () => { + expect(spy).toHaveBeenCalledTimes(2) + expect(spy).toHaveBeenCalledWith('Playing') + expect(spy).toHaveBeenCalledWith('makeMagic') + spy.mockReset() + }) + + it('extended classes can implement proxies', () => { + expect(router.proxied()).toBe('IT WORKED') + }) + + it('child class has acccess to all parent methods', () => { + expect(router.makeMagic()).toBeTruthy() + expect(router.play()).toBe('Playing') + }) + + it('child class has acccess to all static parent methods', () => { + expect(Router.call()).toBe('Called') + expect(Router.pause()).toBe('Paused') + }) + + it('child class is an instance of all mixed classes', () => { + expect(router instanceof Magic).toBeTruthy() + expect(router instanceof Magical).toBeTruthy() + expect(router instanceof IRouter).toBeTruthy() + }) + }) + + describe('Trait System', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => { }) + + const Magic = trait(Base => class Magic extends Base { + makeMagic () { + return 'makeMagic' + } + }) + + const Magical = trait(Base => class Magical extends Base { + play () { + return 'Playing' + } + + static pause () { + return 'Paused' + } + }) + + const IRouter = trait(Base => class IRouter extends Base { + static call () { + return 'Called' + } + }) + + const Proxiable = trait(Base => class Proxiable extends Base { + constructor() { + super() + return new Proxy(this, { + get (target, prop, receiver) { + const val = Reflect.get(target, prop, receiver) as any + if (typeof val === 'function' && val.name === 'proxied') return () => val().toUpperCase() + + return val + } + }) + } + + proxied () { + return 'it worked' + } + }) + + class Router extends use(IRouter, Magic, Magical, Proxiable) { + constructor() { + super() + console.log(this.makeMagic()) + console.log(this.play()) + } + } + + const router = new Router() + + it('child class constructor has access to all parent methods', () => { + expect(spy).toHaveBeenCalledTimes(2) + expect(spy).toHaveBeenCalledWith('Playing') + expect(spy).toHaveBeenCalledWith('makeMagic') + spy.mockReset() + }) + + it('traits can implement proxies', () => { + expect(router.proxied()).toBe('IT WORKED') + }) + + it('child class has acccess to all parent methods', () => { + expect(router.makeMagic()).toBeTruthy() + expect(router.play()).toBe('Playing') + }) + + it('child class has acccess to all static parent methods', () => { + expect(Router.call()).toBe('Called') + expect(Router.pause()).toBe('Paused') + }) + + it('child class can be confirmed to be using all mixed classes', () => { + expect(uses(router, Magic)).toBeTruthy() + expect(uses(router, Magical)).toBeTruthy() + expect(uses(router, IRouter)).toBeTruthy() + }) + }) +}) \ No newline at end of file diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 1dcb8687..dfea10bd 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,5 +1,28 @@ { "compilerOptions": { + "paths": { + "@h3ravel/cache": ["../cache/src/index.ts"], + "@h3ravel/events": ["../events/src/index.ts"], + "@h3ravel/config": ["../config/src/index.ts"], + "@h3ravel/console": ["../console/src/index.ts"], + "@h3ravel/core": ["../core/src/index.ts"], + "@h3ravel/database": ["../database/src/index.ts"], + "@h3ravel/filesystem": ["../filesystem/src/index.ts"], + "@h3ravel/hashing": ["../hashing/src/index.ts"], + "@h3ravel/http": ["../http/src/index.ts"], + "@h3ravel/mail": ["../mail/src/index.ts"], + "@h3ravel/queue": ["../queue/src/index.ts"], + "@h3ravel/router": ["../router/src/index.ts"], + "@h3ravel/shared": ["../shared/src/index.ts"], + "@h3ravel/support": ["../support/src/index.ts"], + "@h3ravel/support/facades": ["../support/src/Facades/index.ts"], + "@h3ravel/url": ["../url/src/index.ts"], + "@h3ravel/view": ["../view/src/index.ts"], + "@h3ravel/session": ["../session/src/index.ts"], + "@h3ravel/foundation": ["../foundation/src/index.ts"], + "@h3ravel/validation": ["../validation/src/index.ts"], + "@h3ravel/contracts": ["../contracts/src/index.ts"] + }, "experimentalDecorators": true, "emitDecoratorMetadata": true, "target": "es2022", diff --git a/packages/support/package.json b/packages/support/package.json index afc33b3b..a48942e7 100644 --- a/packages/support/package.json +++ b/packages/support/package.json @@ -11,20 +11,17 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, + "./facades": { + "import": "./dist/facades.js", + "require": "./dist/facades.cjs" + }, "./package.json": "./package.json" }, "files": [ "dist" ], "publishConfig": { - "access": "public", - "exports": { - ".": { - "import": "./dist/index.js", - "require": "./dist/index.cjs" - }, - "./*": "./*" - } + "access": "public" }, "homepage": "https://h3ravel.toneflix.net", "repository": { @@ -58,6 +55,7 @@ "typescript": "^5.4.0" }, "dependencies": { + "@h3ravel/contracts": "workspace:^", "dayjs": "catalog:", "luxon": "catalog:" } diff --git a/packages/support/src/Contracts/Helpers.ts b/packages/support/src/Contracts/Helpers.ts new file mode 100644 index 00000000..629a0557 --- /dev/null +++ b/packages/support/src/Contracts/Helpers.ts @@ -0,0 +1,29 @@ +import type { HigherOrderTapProxy } from '../HigherOrderTapProxy' + +export interface Tap { + > (value: X): HigherOrderTapProxy + > (value: X, callback?: (val: X) => void): X +} + +export interface OptionalFn { + (value: Nullable): OptionalProxy + (value: Nullable, callback: (value: T) => R): R | undefined +} + +export type Macro = (...args: any[]) => any + +export type MacroMap = Record any> + +export type WithMacros = { + [K in keyof M]: M[K] +} + +export type Nullable = T | null | undefined + +export type OptionalProxy = { + [K in keyof T]: T[K] extends (...args: infer A) => infer R + ? (...args: A) => OptionalProxy + : OptionalProxy +} & { + value (): T | undefined +} \ No newline at end of file diff --git a/packages/support/src/Exceptions/BadMethodCallException.ts b/packages/support/src/Exceptions/BadMethodCallException.ts new file mode 100644 index 00000000..7f8d11de --- /dev/null +++ b/packages/support/src/Exceptions/BadMethodCallException.ts @@ -0,0 +1,5 @@ +/** + * Exception thrown if an error with a method call occurs. + */ +export class BadMethodCallException extends Error { +} diff --git a/packages/support/src/Facades/.gitkeep b/packages/support/src/Facades/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/support/src/Facades/Facades.ts b/packages/support/src/Facades/Facades.ts new file mode 100644 index 00000000..b8fed951 --- /dev/null +++ b/packages/support/src/Facades/Facades.ts @@ -0,0 +1,138 @@ +import { ClassConstructor, ConcreteConstructor, IApplication, IBinding } from '@h3ravel/contracts' + +import { RuntimeException } from '../Exceptions/RuntimeException' +import { isInternal } from '@h3ravel/foundation' + +export abstract class Facades { + /** + * The application instance being facaded. + */ + protected static app?: IApplication + + /** + * The resolved object instances. + */ + protected static resolvedInstance = new Map() + + /** + * Indicates if the resolved instance should be cached. + */ + protected static cached = true + + /** + * Called once during bootstrap + * + * @param app + */ + static setApplication (app: IApplication) { + this.app = app + } + + /** + * Get the application instance behind the facade. + */ + static getApplication () { + return this.app + } + + /** + * Get the registered name of the component. + * Each facade must define its container key + * + * @return string + * + * @throws {RuntimeException} + */ + protected static getFacadeAccessor (): string { + throw new RuntimeException('Facade accessor not implemented.') + } + + /** + * Get the root object behind the facade. + */ + static getFacadeRoot () { + return this.resolveInstance(this.getFacadeAccessor()) + } + + /** + * Resolve the facade root instance from the container. + * + * @param name + */ + static resolveInstance (name: string | IBinding) { + if (this.resolvedInstance.has(name)) { + return this.resolvedInstance.get(name) + } + + if (this.app) { + const instance = this.app.make>>(name as never) + + if (this.cached) { + this.resolvedInstance.set(name, instance as never) + } + + return instance + } + } + + /** + * Clear a resolved facade instance. + * + * @param name + */ + static clearResolvedInstance (name: string | IBinding) { + this.resolvedInstance.delete(name) + } + + /** + * Clear all of the resolved instances. + */ + static clearResolvedInstances () { + this.resolvedInstance.clear() + } + + /** + * Hotswap the underlying instance behind the facade. + * + * @param instance + */ + static swap (instance: ConcreteConstructor) { + this.resolvedInstance.set(this.getFacadeAccessor(), instance) + + if (this.app) { + this.app.instance(this.getFacadeAccessor(), instance) + } + } + + static __callStatic (method: string, args: any[]) { + const instance = this.getFacadeRoot() + if (!instance) throw new Error('Facade root not resolved.') + + // If method is not internal, call it directly + if (typeof instance[method] === 'function' && !isInternal(instance, method)) { + return Reflect.apply(instance[method as never], instance, args) + } + + // Otherwise, forward to __call + if (typeof (instance as any).__call === 'function') { + return (instance as any).__call(method, args) + } + + // Fallback if method does not exist at all + throw new Error( + `Method [${method}] does not exist on [${instance.constructor.name}] facade root.` + ) + } + + static createFacade () { + return new Proxy( + {}, + { + get: (_target, prop: string) => { + return (...args: any[]) => + this.__callStatic(prop, args) + } + } + ) as T + } +} diff --git a/packages/support/src/Facades/RouteFacade.ts b/packages/support/src/Facades/RouteFacade.ts new file mode 100644 index 00000000..40fd2367 --- /dev/null +++ b/packages/support/src/Facades/RouteFacade.ts @@ -0,0 +1,14 @@ +import { IRouteRegistrar, IRouter } from '@h3ravel/contracts' + +import { Facades } from './Facades' + +class RouteFacade extends Facades { + protected static getFacadeAccessor () { + return 'router' + } +} + +export type FRoute = + Omit + +export const Route = RouteFacade.createFacade() \ No newline at end of file diff --git a/packages/support/src/Facades/index.ts b/packages/support/src/Facades/index.ts new file mode 100644 index 00000000..05c58bee --- /dev/null +++ b/packages/support/src/Facades/index.ts @@ -0,0 +1,2 @@ +export * from './Facades' +export * from './RouteFacade' diff --git a/packages/support/src/Helpers.ts b/packages/support/src/Helpers.ts index dbde67c9..bb84f149 100644 --- a/packages/support/src/Helpers.ts +++ b/packages/support/src/Helpers.ts @@ -1,3 +1,5 @@ +import { Nullable, OptionalFn, OptionalProxy, Tap } from './Contracts/Helpers' + import { HigherOrderTapProxy } from './HigherOrderTapProxy' /** @@ -6,11 +8,6 @@ import { HigherOrderTapProxy } from './HigherOrderTapProxy' * @param value * @param callback */ -interface Tap { - > (value: X): HigherOrderTapProxy - > (value: X, callback?: (val: X) => void): X -} - export const tap: Tap = (value: any, callback?: (val: X) => void) => { if (!callback) { return new HigherOrderTapProxy(value) @@ -21,16 +18,94 @@ export const tap: Tap = (value: any, callback?: (val: X) => void) => { return value } +/** + * Optional Proxy factory + * + * @param value + * @returns + */ +export const createOptionalProxy = (value: Nullable): OptionalProxy => { + const handler: ProxyHandler = { + get (_, prop) { + if (prop === 'value') { + return () => value ?? undefined + } + + if (value == null) { + return createOptionalProxy(undefined) + } + + const result = (value as any)[prop] + + if (typeof result === 'function') { + return (...args: any[]) => { + try { + return createOptionalProxy(result.apply(value, args)) + } catch { + return createOptionalProxy(undefined) + } + } + } + + return createOptionalProxy(result) + } + } + + return new Proxy({}, handler) as OptionalProxy +} + +/** + * Provide access to optional objects. + * + * @param value + * @param callback + */ +export const optional: OptionalFn = (value: Nullable, callback?: (value: T) => R) => { + if (callback) { + return value != null ? callback(value) : undefined + } + + return createOptionalProxy(value) +} + +/** + * Variadic helper function + * + * @param args + */ +export default function variadic (args: X[]) { + if (Array.isArray(args[0])) { + return args[0] + } + + return args +} + +/** + * Checks if the givevn value is a class + * + * @param C + */ export const isClass = (C: any): C is new (...args: any[]) => any => { return typeof C === 'function' && C.prototype !== undefined && Object.toString.call(C).substring(0, 5) === 'class' } +/** + * Checks if the givevn value is an abstract class + * + * @param C + */ export const isAbstract = (C: any): C is new (...args: any[]) => any => { return isClass(C) && C.name.startsWith('I') } +/** + * Checks if the givevn value is callable + * + * @param C + */ export const isCallable = (C: any): C is (...args: any[]) => any => { return typeof C === 'function' && !isClass(C) } \ No newline at end of file diff --git a/packages/support/src/Helpers/Str.ts b/packages/support/src/Helpers/Str.ts index f9afeb28..863307a6 100644 --- a/packages/support/src/Helpers/Str.ts +++ b/packages/support/src/Helpers/Str.ts @@ -595,7 +595,9 @@ export class Str { return true } - pattern = pattern.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&').replace(/\\\*/g, '.*') + pattern = pattern + .replace(/[\\^$*+?.()|[\]{}]/g, '\\$&') + .replace(/\\\*/g, '.*') const regex: RegExp = new RegExp('^' + pattern + '$', ignoreCase ? 'iu' : 'u') diff --git a/packages/support/src/Macroable.ts b/packages/support/src/Macroable.ts new file mode 100644 index 00000000..355c2467 --- /dev/null +++ b/packages/support/src/Macroable.ts @@ -0,0 +1,122 @@ +import { Macro, MacroMap, WithMacros } from './Contracts/Helpers' + +import { BadMethodCallException } from './Exceptions/BadMethodCallException' +import { trait } from '@h3ravel/shared' + +export const Macroable = () => trait((Base) => { + return class Macroable extends Base { + static macros: Record = {} + + constructor(...args: any[]) { + super(...args) + return new Proxy(this, { + get (target, prop, receiver) { + if (typeof prop === 'string') { + const ctor = target.constructor as unknown as Macroable + + if (ctor.hasMacro(prop)) { + return (...args: any[]) => + ctor.macros[prop].apply(receiver, args) + } + } + + return Reflect.get(target, prop, receiver) + } + }) as this & WithMacros + + } + + static macro (name: string, macro: Macro) { + this.macros[name] = macro + } + + static hasMacro (name: string): boolean { + return Object.prototype.hasOwnProperty.call(this.macros, name) + } + + static flushMacros () { + this.macros = {} + } + + static mixin (mixin: object, replace = true) { + const proto = Object.getPrototypeOf(mixin) + + for (const key of Object.getOwnPropertyNames(proto)) { + if (key === 'constructor') continue + + const desc = Object.getOwnPropertyDescriptor(proto, key) + if (!desc || typeof desc.value !== 'function') continue + + if (replace || !this.hasMacro(key)) { + this.macro(key, desc.value.bind(mixin)) + } + } + } + + static createProxy (this: T): T { + return new Proxy(this, { + get (target, prop, receiver) { + if (typeof prop === 'string' && (target as any).hasMacro(prop)) { + return (...args: any[]) => (target as any).macros[prop](...args) + } + + return Reflect.get(target, prop, receiver) + } + }) + } + + /** + * Dynamically handle calls to the class. + * + * @param method + * @param parameters + * + * @throws {BadMethodCallException} + */ + static macroCallStatic (method: string, parameters: any[] = []) { + if (!Macroable.hasMacro(method)) { + throw new BadMethodCallException( + `Method ${Macroable.constructor.name}.${method} does not exist.` + ) + } + + let macro = Macroable.macros[method] + + if (typeof macro === 'function') { + macro = macro.bind(this) + } + + if (typeof macro === 'function') { + macro = macro.bind(this) + } + + return macro(...parameters) + } + + /** + * Dynamically handle calls to the class. + * + * @param method + * @param parameters + * + * @throws {BadMethodCallException} + */ + macroCall (method: string, parameters: any[] = []) { + if (!Macroable.hasMacro(method)) { + throw new BadMethodCallException( + `Method ${Macroable.constructor.name}.${method} does not exist.` + ) + } + + let macro = Macroable.macros[method] + + if (typeof macro === 'function') { + macro = macro.bind(this) + } + + return macro(...parameters) + } + } +}) + +export const MacroableClass = Macroable().factory(class { }) diff --git a/packages/router/src/Providers/AssetsServiceProvider.ts b/packages/support/src/Providers/AssetsServiceProvider.ts similarity index 93% rename from packages/router/src/Providers/AssetsServiceProvider.ts rename to packages/support/src/Providers/AssetsServiceProvider.ts index f7739ee2..116f7eed 100644 --- a/packages/router/src/Providers/AssetsServiceProvider.ts +++ b/packages/support/src/Providers/AssetsServiceProvider.ts @@ -1,7 +1,7 @@ import { readFile, stat } from 'node:fs/promises' -import { ServiceProvider } from '@h3ravel/core' -import { Str } from '@h3ravel/support' +import { ServiceProvider } from '../Providers/ServiceProvider' +import { Str } from '../Helpers/Str' import { join } from 'node:path' import { serveStatic } from 'h3' import { statSync } from 'node:fs' @@ -21,7 +21,7 @@ export class AssetsServiceProvider extends ServiceProvider { /** * Use a middleware to check if this request for for a file */ - app.middleware((event) => { + app.h3middleware((event) => { const { pathname } = new URL(event.req.url) /** diff --git a/packages/support/src/Providers/RouteServiceProvider.ts b/packages/support/src/Providers/RouteServiceProvider.ts new file mode 100644 index 00000000..5ff92e3c --- /dev/null +++ b/packages/support/src/Providers/RouteServiceProvider.ts @@ -0,0 +1,123 @@ +import { CallableConstructor, IRouter } from '@h3ravel/contracts' + +import { Logger } from '@h3ravel/shared' +import { ServiceProvider } from '../Providers/ServiceProvider' + +/** + * Handles routing registration + * + * Load route files (web.ts, api.ts). + * Map controllers to routes. + * Register route-related middleware. + */ +export class RouteServiceProvider extends ServiceProvider { + public static priority = 997 + + /** + * The callback that should be used to load the application's routes. + */ + protected loadRoutesUsing?: CallableConstructor + + /** + * The global callback that should be used to load the application's routes. + */ + protected static alwaysLoadRoutesUsing?: CallableConstructor + + async register () { + const { RouteListCommand, Router, SubstituteBindings } = await import('@h3ravel/router') + + this.app.bindMiddleware('SubstituteBindings', SubstituteBindings) + + this.booted(() => { + const router = this.app.make(IRouter) + if (typeof router.getRoutes === 'function') { + router.getRoutes().refreshActionLookups() + router.getRoutes().refreshNameLookups() + } + }) + + const router = () => { + try { + const h3App = this.app.make('http.app') + + return new Router(h3App, this.app as never) + } catch (error: any) { + if (String(error.message).includes('http.app')) + Logger.log([ + ['The', 'white'], + ['@h3ravel/http', ['italic', 'gray']], + ['package is required to use the routing system.', 'white'] + ], ' ') + else Logger.log(error, 'white') + } + return {} as InstanceType + } + + this.app.singleton('router', router) + this.app.alias(Router, 'router') + this.app.alias(IRouter, 'router') + + this.registerCommands([RouteListCommand]) + } + + /** + * Load routes from src/routes + */ + async boot () { + await this.loadRoutes() + } + + /** + * Register the callback that will be used to load the application's routes. + * + * @param routesCallback + */ + protected routes (routesCallback: CallableConstructor) { + this.loadRoutesUsing = routesCallback + return this + } + + /** + * Register the callback that will be used to load the application's routes. + * + * @param routesCallback + */ + public static loadRoutesUsing (routesCallback?: CallableConstructor) { + this.alwaysLoadRoutesUsing = routesCallback + } + + /** + * Load the application routes. + */ + protected async loadRoutes () { + if (RouteServiceProvider.alwaysLoadRoutesUsing != null) { + this.app.call(RouteServiceProvider.alwaysLoadRoutesUsing) + } + + if (this.loadRoutesUsing != null) { + this.app.call(this.loadRoutesUsing) + } else if (typeof (this as any)['map'] === 'function') { + this.app.call((this as any)['map']) + } + // try { + // const routePath = this.app.getPath('routes') + + // const files = (await readdir(routePath)).filter((e) => { + // return !e.includes('.d.') && !e.includes('.map') + // }) + + // for (const file of files) { + // const { default: route } = await import(path.join(routePath, file)) + + // if (typeof route === 'function') { + // const router = this.app.make('router') + // route(router) + // } + // } + // } catch (e: any) { + // if (!this.app.runningUnitTests()) { + // Logger.log([['Route autoloading error', 'white'], [e.message, ['grey', 'italic']]], ': ') + // } + // } + } +} diff --git a/packages/foundation/src/Core/ServiceProvider.ts b/packages/support/src/Providers/ServiceProvider.ts similarity index 94% rename from packages/foundation/src/Core/ServiceProvider.ts rename to packages/support/src/Providers/ServiceProvider.ts index 8abd2e34..3f211d5d 100644 --- a/packages/foundation/src/Core/ServiceProvider.ts +++ b/packages/support/src/Providers/ServiceProvider.ts @@ -25,11 +25,11 @@ export abstract class ServiceProvider extends IServiceProvider { /** * Indicate that this service provider only runs in console */ - static console = false + static console?: boolean = false /** * Indicate that this service provider only runs in console */ - console = false + console?: boolean = false /** * Indicate that this service provider only runs in console @@ -39,7 +39,7 @@ export abstract class ServiceProvider extends IServiceProvider { /** * List of registered console commands */ - registeredCommands?: (new (app: any, kernel: any) => any)[] + registeredCommands: (new (app: any, kernel: any) => any)[] = [] /** * All of the registered booted callbacks. diff --git a/packages/support/src/Tappable.ts b/packages/support/src/Tappable.ts new file mode 100644 index 00000000..74f63ef7 --- /dev/null +++ b/packages/support/src/Tappable.ts @@ -0,0 +1,18 @@ +import { CallableConstructor, ClassConstructor, ConcreteConstructor } from '@h3ravel/contracts' + +import { tap } from './Helpers' + +export const Tappable = < + X extends ConcreteConstructor +> (Base: X) => { + return class extends Base { + /** + * Call the given Closure with this instance then return the instance. + * + * @param callback + */ + tap (callback?: CallableConstructor) { + return tap(this, callback) + } + } +} diff --git a/packages/support/src/index.ts b/packages/support/src/index.ts index 86b6b910..e16188d2 100644 --- a/packages/support/src/index.ts +++ b/packages/support/src/index.ts @@ -1,7 +1,9 @@ export * from './Collection' +export * from './Contracts/Helpers' export * from './Contracts/ObjContract' export * from './Contracts/StrContract' export * from './Contracts/TypeCast' +export * from './Exceptions/BadMethodCallException' export * from './Exceptions/InvalidArgumentException' export * from './Exceptions/RuntimeException' export * from './GlobalBootstrap' @@ -16,3 +18,8 @@ export { Obj, dot, extractProperties, getValue, modObj, safeDot, setNested, slug export { Str, Mode, Stringable, HtmlString, str } from './Helpers/Str' export * from './Helpers/Time' export * from './HigherOrderTapProxy' +export * from './Macroable' +export * from './Providers/AssetsServiceProvider' +export * from './Providers/RouteServiceProvider' +export * from './Providers/ServiceProvider' +export * from './Tappable' diff --git a/packages/support/tests/collection.test.ts b/packages/support/tests/collection.test.ts index 57e744e6..a53f10c9 100644 --- a/packages/support/tests/collection.test.ts +++ b/packages/support/tests/collection.test.ts @@ -1,4 +1,4 @@ -import { Collection, collection } from '../src/Collection' +import { Collection, collect } from '../src/Collection' import { describe, expect, test } from 'vitest' describe('Collection', () => { @@ -7,6 +7,6 @@ describe('Collection', () => { // console.log(new Collection({ name: 'james' }), new Collection([1, 2, 3]), collection('Men')) expect(new Collection({ name: 'james' }).get('name')).toBe('james') - expect(collection([1, 2, 3]).all()).toEqual([1, 2, 3]) + expect(collect([1, 2, 3]).all()).toEqual([1, 2, 3]) }) }) diff --git a/packages/support/tsdown.config.ts b/packages/support/tsdown.config.ts new file mode 100644 index 00000000..77b7da14 --- /dev/null +++ b/packages/support/tsdown.config.ts @@ -0,0 +1,11 @@ +import { baseConfig } from '../../tsdown.config' +import { defineConfig } from 'tsdown' + +export default defineConfig({ + ...baseConfig, + clean: true, + entry: { + index: 'src/index.ts', + facades: 'src/Facades/index.ts', + }, +}) diff --git a/packages/url/src/Providers/UrlServiceProvider.ts b/packages/url/src/Providers/UrlServiceProvider.ts index fe723606..baa0daad 100644 --- a/packages/url/src/Providers/UrlServiceProvider.ts +++ b/packages/url/src/Providers/UrlServiceProvider.ts @@ -1,5 +1,5 @@ /// -import { ServiceProvider } from '@h3ravel/foundation' +import { ServiceProvider } from '@h3ravel/support' import { Url } from '../Url' import { createUrlHelper } from '../RequestAwareHelpers' import { createUrlHelpers } from '../Helpers' diff --git a/packages/url/src/RequestAwareHelpers.ts b/packages/url/src/RequestAwareHelpers.ts index 20595c69..9a02653e 100644 --- a/packages/url/src/RequestAwareHelpers.ts +++ b/packages/url/src/RequestAwareHelpers.ts @@ -94,7 +94,7 @@ export class RequestAwareHelpers { */ query (): RouteParams { const request = this.getCurrentRequest() - return request.query || {} + return request._query || {} } } diff --git a/packages/url/src/Url.ts b/packages/url/src/Url.ts index 0296444e..b5c13bf5 100644 --- a/packages/url/src/Url.ts +++ b/packages/url/src/Url.ts @@ -77,7 +77,6 @@ export class Url { /** * Create a URL from a named route */ - // Route parameter map (declaration-mergeable by consumers) static route ( name: TName, params: TParams = {} as TParams, @@ -89,15 +88,18 @@ export class Url { // Use (app as any).make to avoid TS error if make is not typed on Application const router = app.make('router') - if (!router || typeof router.route !== 'function') { + if (!router) { throw new Error('Router not available or does not support route generation') } - if (typeof router.route !== 'function') { + if (typeof router.getRoutes !== 'function') { throw new Error('Router does not support route generation') } - const routeUrl = router.route(name, params) + const routeUrl = router.getRoutes().getByName(name)?.uri() + // TODO: Provide route params + // const routeUrl = router.route(name, params) + void params if (!routeUrl) { throw new Error(`Route "${name}" not found`) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02275a8b..22691b8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,11 +146,11 @@ catalogs: specifier: ^0.6.17 version: 0.6.17 '@h3ravel/collect.js': - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.3.3 + version: 5.3.3 '@h3ravel/musket': - specifier: ^0.4.0 - version: 0.4.0 + specifier: ^0.6.8 + version: 0.6.8 h3: specifier: 2.0.1-rc.5 version: 2.0.1-rc.5 @@ -308,7 +308,7 @@ importers: version: link:../../packages/mail '@h3ravel/musket': specifier: catalog:prod - version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.9.2) + version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.9.2) '@h3ravel/queue': specifier: workspace:^ version: link:../../packages/queue @@ -370,9 +370,9 @@ importers: packages/cache: dependencies: - '@h3ravel/foundation': + '@h3ravel/support': specifier: workspace:^ - version: link:../foundation + version: link:../support devDependencies: typescript: specifier: ^5.4.0 @@ -382,7 +382,7 @@ importers: dependencies: '@h3ravel/musket': specifier: catalog:prod - version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -408,15 +408,18 @@ importers: '@h3ravel/core': specifier: workspace:^ version: link:../core + '@h3ravel/foundation': + specifier: workspace:^ + version: link:../foundation '@h3ravel/musket': specifier: catalog:prod - version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.8(@h3ravel/support@0.15.6)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared '@h3ravel/support': specifier: workspace:^ - version: link:../support + version: 0.15.6 chalk: specifier: ^5.6.2 version: 5.6.2 @@ -445,6 +448,9 @@ importers: specifier: 'catalog:' version: 4.20.6 devDependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts typescript: specifier: ^5.9.2 version: 5.9.2 @@ -455,6 +461,9 @@ importers: specifier: catalog:prod version: 2.0.1-rc.5 devDependencies: + '@h3ravel/musket': + specifier: catalog:prod + version: 0.6.8(@h3ravel/support@0.15.6)(@types/node@24.10.0) edge.js: specifier: 'catalog:' version: 6.3.0 @@ -530,7 +539,7 @@ importers: version: link:../filesystem '@h3ravel/musket': specifier: catalog:prod - version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -562,7 +571,7 @@ importers: version: link:../core '@h3ravel/musket': specifier: catalog:prod - version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -576,6 +585,9 @@ importers: packages/foundation: dependencies: + '@h3ravel/musket': + specifier: catalog:prod + version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -628,7 +640,7 @@ importers: version: link:../foundation '@h3ravel/musket': specifier: catalog:prod - version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/session': specifier: workspace:^ version: link:../session @@ -706,7 +718,7 @@ importers: version: link:../http '@h3ravel/musket': specifier: catalog:prod - version: 0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -775,6 +787,9 @@ importers: packages/support: dependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts dayjs: specifier: 'catalog:' version: 1.11.19 @@ -784,7 +799,7 @@ importers: devDependencies: '@h3ravel/collect.js': specifier: catalog:prod - version: 5.3.2 + version: 5.3.3 '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -855,7 +870,7 @@ importers: version: link:../http '@h3ravel/musket': specifier: catalog:prod - version: 0.4.0(@h3ravel/support@0.15.6)(@types/node@24.10.0) + version: 0.6.8(@h3ravel/support@0.15.6)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:* version: link:../shared @@ -1617,11 +1632,11 @@ packages: engines: {node: '>=14', pnpm: '>=4'} hasBin: true - '@h3ravel/collect.js@5.3.2': - resolution: {integrity: sha512-oCI+1cUOE5il+z6OTiE0NuYB4gniqvK161XuepAlqEv1LdI+T86oGGww2s97pket1sYHbHfggvvRnKiSJKiSHQ==} + '@h3ravel/collect.js@5.3.3': + resolution: {integrity: sha512-mD0BP1KdBVvnh1CYAu9J3eCePHu4Qf6P0hgkg5G5SUCI6TvdxlnDQYvvOomYkW4ojcEnHuKA/1pKa6V7oyDTrg==} - '@h3ravel/musket@0.4.0': - resolution: {integrity: sha512-avecKyX+jDNm5AFUBLARoCSNYUJm8mda6MvykoGhaMhULaKUQFzxu9wwzvI+HKY+VOFjyErXbO2aOYe+kvbC2g==} + '@h3ravel/musket@0.6.8': + resolution: {integrity: sha512-OCFIi9lxVvnc1fU0QRWYNvbbwWvhRlDEVD19akXBmRxHaOGlhwJc3oF7Fl5F84gSzfBqgQtANbL33Tsqu81SHg==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@h3ravel/support': ^0.15.6 @@ -7089,9 +7104,9 @@ snapshots: - sqlite3 - supports-color - '@h3ravel/collect.js@5.3.2': {} + '@h3ravel/collect.js@5.3.3': {} - '@h3ravel/musket@0.4.0(@h3ravel/support@0.15.6)(@types/node@24.10.0)': + '@h3ravel/musket@0.6.8(@h3ravel/support@0.15.6)(@types/node@24.10.0)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.10.0) '@h3ravel/support': 0.15.6 @@ -7108,7 +7123,7 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.4.0(@h3ravel/support@packages+support)(@types/node@24.10.0)': + '@h3ravel/musket@0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.10.0) '@h3ravel/support': link:packages/support @@ -7125,7 +7140,7 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.4.0(@h3ravel/support@packages+support)(@types/node@24.9.2)': + '@h3ravel/musket@0.6.8(@h3ravel/support@packages+support)(@types/node@24.9.2)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.9.2) '@h3ravel/support': link:packages/support @@ -8408,7 +8423,7 @@ snapshots: '@types/mute-stream@0.0.1': dependencies: - '@types/node': 20.19.24 + '@types/node': 24.10.0 '@types/node@12.20.55': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5e1f7b18..bdf7a37e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -57,23 +57,23 @@ catalog: resolve-from: ^5.0.0 rimraf: ^6.1.0 semver: ^7.7.2 + simple-body-validator: ^1.3.9 source-map-support: ^0.5.21 sqlite3: 5.1.7 ts-node: ^10.9.2 tsconfig-paths: ^4.2.0 + tsdown: ^0.16.8 tslib: ^2.8.1 tsx: ^4.20.6 typescript-eslint: ^8.46.3 utility-types: ^3.11.0 vite-tsconfig-paths: ^5.1.4 - tsdown: ^0.16.8 - simple-body-validator: ^1.3.9 catalogs: prod: '@h3ravel/arquebus': ^0.6.17 - '@h3ravel/musket': ^0.4.0 - '@h3ravel/collect.js': ^5.3.2 + '@h3ravel/collect.js': ^5.3.3 + '@h3ravel/musket': ^0.6.8 h3: 2.0.1-rc.5 ignoredBuiltDependencies: diff --git a/tsconfig.base.json b/tsconfig.base.json index d9711a19..17aab07b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,6 +16,7 @@ "@h3ravel/router": ["packages/router/src/index.ts"], "@h3ravel/shared": ["packages/shared/src/index.ts"], "@h3ravel/support": ["packages/support/src/index.ts"], + "@h3ravel/support/facades": ["packages/support/src/Facades/index.ts"], "@h3ravel/url": ["packages/url/src/index.ts"], "@h3ravel/view": ["packages/view/src/index.ts"], "@h3ravel/session": ["packages/session/src/index.ts"], From 7c1da260df7c13aa15b98095b6d835e4ee47ab93 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 7 Jan 2026 18:21:53 +0100 Subject: [PATCH 14/28] refactor: reorganize imports and enhance route handling - Moved `CreatesRegularExpressionRouteConstraints` import to a new `Traits` directory for better structure. - Updated `Pipeline` class to improve error logging for unbound middlewares. - Enhanced `Route` class with new methods for parameter management and trashed model bindings. - Introduced `ImplicitRouteBinding` for resolving implicit route bindings with soft delete support. - Added `RouteParameter` and `RouteSignatureParameters` classes for better parameter handling in routes. - Implemented new exception classes for model not found scenarios. - Updated shared package version to 0.28.0 and `@h3ravel/arquebus` to 0.7.3. - Created a new `ProjectController` and `Project` model in the example app for demonstration. --- .../app/Http/Controllers/ProjectController.ts | 44 ++++++ .../app/Http/Controllers/UserController.ts | 9 +- examples/basic-app/src/app/Models/project.ts | 10 ++ examples/basic-app/src/routes/api.ts | 2 + packages/contracts/package.json | 3 +- packages/contracts/src/Core/IApplication.ts | 38 +++++ packages/contracts/src/Database/IModel.ts | 33 +++++ packages/contracts/src/Routing/IRoute.ts | 87 ++++++++++-- packages/contracts/src/Routing/IRouter.ts | 47 +++++- .../src/Routing/Traits/UrlRoutable.ts | 29 ++++ packages/contracts/src/Utilities/Utilities.ts | 3 +- packages/contracts/src/index.ts | 2 + packages/core/src/Application.ts | 23 +++ packages/core/src/Container.ts | 8 +- packages/core/src/ProviderRegistry.ts | 2 +- packages/database/src/Model.ts | 86 +++++++++-- packages/database/src/index.ts | 3 - .../src/Configuration/AppBuilder.ts | 4 - .../foundation/src/Console/ConsoleKernel.ts | 2 +- .../Exceptions/ModelNotFoundException.ts | 10 +- .../Exceptions/RecordNotFoundException.ts | 0 .../Exceptions/RecordsNotFoundException.ts | 0 packages/foundation/src/Http/Kernel.ts | 4 +- packages/foundation/src/index.ts | 3 + packages/http/src/HttpContext.ts | 2 + packages/http/src/JsonResponse.ts | 2 +- packages/router/src/Contracts/Utilities.ts | 9 +- packages/router/src/ControllerDispatcher.ts | 2 +- packages/router/src/ImplicitRouteBinding.ts | 98 +++++++++++++ .../src/Middleware/SubstituteBindings.ts | 15 +- .../router/src/PendingResourceRegistration.ts | 2 +- .../PendingSingletonResourceRegistration.ts | 2 +- packages/router/src/Pipeline.ts | 11 +- packages/router/src/Route.ts | 120 ++++++++++++++-- packages/router/src/RouteAction.ts | 7 +- packages/router/src/RouteParameter.ts | 20 +++ packages/router/src/RouteRegisterer.ts | 2 +- .../router/src/RouteSignatureParameters.ts | 99 +++++++++++++ packages/router/src/Router.ts | 134 +++++++++++------- ...reatesRegularExpressionRouteConstraints.ts | 0 .../src/Traits/RouteDependencyResolver.ts | 22 ++- packages/router/src/index.ts | 5 +- packages/shared/package.json | 2 +- pnpm-lock.yaml | 19 +-- pnpm-workspace.yaml | 2 +- 45 files changed, 881 insertions(+), 146 deletions(-) create mode 100644 examples/basic-app/src/app/Http/Controllers/ProjectController.ts create mode 100644 examples/basic-app/src/app/Models/project.ts create mode 100644 packages/contracts/src/Database/IModel.ts create mode 100644 packages/contracts/src/Routing/Traits/UrlRoutable.ts rename packages/{database/src => foundation/src/Database}/Exceptions/ModelNotFoundException.ts (75%) rename packages/{database/src => foundation/src/Database}/Exceptions/RecordNotFoundException.ts (100%) rename packages/{database/src => foundation/src/Database}/Exceptions/RecordsNotFoundException.ts (100%) create mode 100644 packages/router/src/ImplicitRouteBinding.ts create mode 100644 packages/router/src/RouteParameter.ts create mode 100644 packages/router/src/RouteSignatureParameters.ts rename packages/router/src/{ => Traits}/CreatesRegularExpressionRouteConstraints.ts (100%) diff --git a/examples/basic-app/src/app/Http/Controllers/ProjectController.ts b/examples/basic-app/src/app/Http/Controllers/ProjectController.ts new file mode 100644 index 00000000..b13b0796 --- /dev/null +++ b/examples/basic-app/src/app/Http/Controllers/ProjectController.ts @@ -0,0 +1,44 @@ +import { Controller, Injectable } from '@h3ravel/core' +import { HttpContext, Request, Response } from '@h3ravel/http' + +import { Project } from 'src/app/Models/project' +import { User } from 'App/Models/user' + +export class ProjectController extends Controller { + index (user: User) { + return user.toJSON() + } + + @Injectable() + async store (request: Request, response: Response, user: User) { + const validate = await request.validate({ + name: ['required', 'string'], + }) + + console.log(validate) + + return response + .setStatusCode(202) + .json({ message: `User ${request.input('name')} created` }) + } + + @Injectable() + async show (response: Response, user: User, project: Project) { + console.log(project.user_id, 'response, user') + // console.log(response, user, project.getRelation('user'), 'response, user') + // return response + // .setCache({ max_age: 50011, private: false }) + // .setStatusCode(202) + // .setContent(JSON.stringify({ id: user.id, name: user.name, created_at: user.created_at })) + } + + async update ({ request, response }: HttpContext, user: User, project: Project) { + return response + .setStatusCode(201) + .json({ message: `User ${request.input('name')} updated`, user, project }) + } + + destroy ({ request }: HttpContext, user: User, project: Project) { + return { message: `User ${request.input('id')} deleted`, user, project } + } +} diff --git a/examples/basic-app/src/app/Http/Controllers/UserController.ts b/examples/basic-app/src/app/Http/Controllers/UserController.ts index c42cc38e..23af0b7a 100644 --- a/examples/basic-app/src/app/Http/Controllers/UserController.ts +++ b/examples/basic-app/src/app/Http/Controllers/UserController.ts @@ -23,10 +23,11 @@ export class UserController extends Controller { @Injectable() async show (response: Response, user: User) { - return response - .setCache({ max_age: 50011, private: false }) - .setStatusCode(202) - .setContent(JSON.stringify({ id: user.id, name: user.name, created_at: user.created_at })) + console.log(response, user, 'response, user') + // return response + // .setCache({ max_age: 50011, private: false }) + // .setStatusCode(202) + // .setContent(JSON.stringify({ id: user.id, name: user.name, created_at: user.created_at })) } async update ({ request, response }: HttpContext) { diff --git a/examples/basic-app/src/app/Models/project.ts b/examples/basic-app/src/app/Models/project.ts new file mode 100644 index 00000000..d71257ac --- /dev/null +++ b/examples/basic-app/src/app/Models/project.ts @@ -0,0 +1,10 @@ +import { Model } from '@h3ravel/database' +import { User } from './user' + +export class Project extends Model { + protected table: string | null = 'projects' + + relationUser () { + return this.belongsTo(User, 'user_id') + } +} diff --git a/examples/basic-app/src/routes/api.ts b/examples/basic-app/src/routes/api.ts index c757e69b..f59cf206 100644 --- a/examples/basic-app/src/routes/api.ts +++ b/examples/basic-app/src/routes/api.ts @@ -1,9 +1,11 @@ import { AuthMiddleware } from 'App/Http/Middlewares/AuthMiddleware' +import { ProjectController } from 'src/app/Http/Controllers/ProjectController' import { Route } from '@h3ravel/support/facades' import { UserController } from 'App/Http/Controllers/UserController' Route.prefix('/').group(() => { Route.apiResource('/users', UserController).middleware([new AuthMiddleware()]) + Route.apiResource('/users/{user}/projects', ProjectController).middleware([new AuthMiddleware()]) }) Route.get('/hello', () => 'Hello').name('hello.route') diff --git a/packages/contracts/package.json b/packages/contracts/package.json index f349f67c..6201c3bb 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -63,7 +63,8 @@ "devDependencies": { "edge.js": "catalog:", "simple-body-validator": "catalog:", - "@h3ravel/musket": "catalog:prod" + "@h3ravel/musket": "catalog:prod", + "@h3ravel/arquebus": "catalog:prod" }, "dependencies": { "h3": "catalog:prod" diff --git a/packages/contracts/src/Core/IApplication.ts b/packages/contracts/src/Core/IApplication.ts index 173d3295..4823b7ef 100644 --- a/packages/contracts/src/Core/IApplication.ts +++ b/packages/contracts/src/Core/IApplication.ts @@ -17,10 +17,12 @@ export abstract class IApplication extends IContainer { * List of registered console commands */ abstract registeredCommands: (new (app: any, kernel: any) => any)[] + /** * Get all registered providers */ abstract getRegisteredProviders (): IServiceProvider[]; + /** * Configure and Dynamically register all configured service providers, then boot the app. * @@ -31,12 +33,14 @@ export abstract class IApplication extends IContainer { * @returns */ abstract initialize (providers: Array>, filtered?: string[], autoRegisterProviders?: boolean): this; + /** * Dynamically register all configured providers * * @param autoRegister If set to false, service providers will not be auto discovered and registered. */ abstract registerConfiguredProviders (autoRegister?: boolean): Promise; + /** * Register service providers * @@ -44,62 +48,74 @@ export abstract class IApplication extends IContainer { * @param filtered */ abstract registerProviders (providers: Array>, filtered?: string[]): void; + /** * Register a provider */ abstract register (provider: IServiceProvider): Promise; + /** * Register the listed service providers. * * @param commands An array of console commands to register. */ abstract withCommands (commands: (new (app: any, kernel: any) => any)[]): this; + /** * checks if the application is running in CLI */ abstract runningInConsole (): boolean; + /** * checks if the application is running in Unit Test */ abstract runningUnitTests (): boolean; abstract getRuntimeEnv (): 'browser' | 'node' | 'unknown'; + /** * Determine if the application has booted. */ abstract isBooted (): boolean + /** * Boot all service providers after registration */ abstract boot (): Promise; + /** * Register a new boot listener. * * @param callable $callback */ abstract booting (callback: (app: this) => void): void + /** * Register a new "booted" listener. * * @param callback */ abstract booted (callback: (app: this) => void): void + /** * Handle the incoming HTTP request and send the response to the browser. * * @param request */ abstract handleRequest (event: H3Event): Promise + /** * Get the URI resolver callback. */ abstract getUriResolver (): () => typeof IUrl | undefined + /** * Set the URI resolver callback. * * @param callback */ abstract setUriResolver (callback: () => typeof IUrl): this + /** * Determine if middleware has been disabled for the application. */ @@ -109,12 +125,14 @@ export abstract class IApplication extends IContainer { * Provide safe overides for the app */ abstract configure (): IAppBuilder; + /** * Check if the current application environment matches the one provided * * @param env */ abstract environment (env: E): E extends undefined ? string : boolean; + /** * Fire up the developement server using the user provided arguments * @@ -126,6 +144,7 @@ export abstract class IApplication extends IContainer { */ abstract fire (): Promise; abstract fire (h3App: H3, preferredPort?: number): Promise; + /** * Fire up the developement server using the user provided arguments * @@ -135,16 +154,19 @@ export abstract class IApplication extends IContainer { * @param preferedPort If provided, this will overide the port set in the evironment */ abstract serve (h3App?: H3, preferredPort?: number): Promise; + /** * Run the given array of bootstrap classes. * * @param bootstrappers */ abstract bootstrapWith (bootstrappers: ConcreteConstructor[]): void | Promise + /** * Determine if the application has been bootstrapped before. */ abstract hasBeenBootstrapped (): boolean + /** * Save the curretn H3 instance for possible future use. * @@ -152,12 +174,26 @@ export abstract class IApplication extends IContainer { * @returns */ abstract setH3App (h3App?: H3): this; + + /** + * Set the HttpContext. + * + * @param ctx + */ + abstract setHttpContext (ctx: IHttpContext): this + + /** + * Get the HttpContext. + */ + abstract getHttpContext (): IHttpContext | undefined + /** * Get the base path of the app * * @returns */ abstract getBasePath (): string; + /** * Dynamically retrieves a path property from the class. * Any property ending with "Path" is accessible automatically. @@ -166,6 +202,7 @@ export abstract class IApplication extends IContainer { * @returns */ abstract getPath (name: IPathName, suffix?: string): string; + /** * Programatically set the paths. * @@ -174,6 +211,7 @@ export abstract class IApplication extends IContainer { * @returns */ abstract setPath (name: IPathName, path: string): void; + /** * Returns the installed version of the system core and typescript. * diff --git a/packages/contracts/src/Database/IModel.ts b/packages/contracts/src/Database/IModel.ts new file mode 100644 index 00000000..83691d2c --- /dev/null +++ b/packages/contracts/src/Database/IModel.ts @@ -0,0 +1,33 @@ +import { Builder, Model } from '@h3ravel/arquebus' + +import { IQueryBuilder } from '@h3ravel/arquebus/types' + +export abstract class IModel extends Model { + /** + * Retrieve the model for a bound value. + * + * @param value + * @param field + * @returns + */ + abstract resolveRouteBinding (value: any, field?: undefined | string | null): Promise; + + /** + * Retrieve the model for a bound value. + * + * @param query + * @param value + * @param field + */ + abstract resolveRouteBindingQuery (query: Builder, value: any, field?: undefined | string | null): IQueryBuilder; + + /** + * Get the value of the model's route key. + */ + abstract getRouteKey (): any; + + /** + * Get the route key for the model. + */ + abstract getRouteKeyName (): string; +} \ No newline at end of file diff --git a/packages/contracts/src/Routing/IRoute.ts b/packages/contracts/src/Routing/IRoute.ts index edd62a71..5f00f6da 100644 --- a/packages/contracts/src/Routing/IRoute.ts +++ b/packages/contracts/src/Routing/IRoute.ts @@ -1,15 +1,16 @@ -import type { CallableConstructor, GenericObject, RouteActions, RouteMethod } from '../Utilities/Utilities' +import type { CallableConstructor, ClassConstructor, GenericObject, RouteActions, RouteMethod } from '../Utilities/Utilities' import type { ICompiledRoute } from './ICompiledRoute' import type { IContainer } from '../Core/IContainer' import { IController } from '../Core/IController' import { IRequest } from '../Http/IRequest' +import { MiddlewareList } from '../Foundation/MiddlewareContract' export abstract class IRoute { /** * The default values for the route. */ - public abstract _defaults: Record + public abstract _defaults: GenericObject /** * The compiled version of the route. */ @@ -17,7 +18,7 @@ export abstract class IRoute { /** * The array of matched parameters. */ - public abstract parameters?: Record + public abstract parameters?: GenericObject /** * The route action array. */ @@ -33,7 +34,7 @@ export abstract class IRoute { /** * The computed gathered middleware. */ - public abstract computedMiddleware?: Record + public abstract computedMiddleware?: MiddlewareList /** * The controller instance. */ @@ -164,11 +165,11 @@ export abstract class IRoute { * * @throws {LogicException} */ - abstract originalParameters (): Record; + abstract originalParameters (): GenericObject /** * Get the matched parameters object. */ - abstract getParameters (): Record + abstract getParameters (): GenericObject /** * Get a given parameter from the route. * @@ -191,16 +192,31 @@ export abstract class IRoute { * @param parameter */ abstract bindingFieldFor (parameter: string | number): string | undefined + /** * Get the binding fields for the route. */ abstract getBindingFields (): GenericObject + /** * Set the binding fields for the route. * * @param bindingFields */ abstract setBindingFields (bindingFields: GenericObject): this + + /** + * Get the parent parameter of the given parameter. + * + * @param parameter + */ + abstract parentOfParameter (parameter: string): any + + /** + * Determines if the route allows "trashed" models to be retrieved when resolving implicit model bindings. + */ + abstract allowsTrashedBindings (): boolean + /** * Set a default value for the route. * @@ -208,54 +224,107 @@ export abstract class IRoute { * @param value */ abstract defaults (key: string, value: any): this; + /** * Set the default values for the route. * * @param defaults */ - abstract setDefaults (defaults: Record): this; + abstract setDefaults (defaults: GenericObject): this; + /** * Get the optional parameter names for the route. */ - abstract getOptionalParameterNames (): Record; + abstract getOptionalParameterNames (): GenericObject; + /** * Get all of the parameter names for the route. */ abstract parameterNames (): string[]; + /** * Flush the cached container instance on the route. */ abstract flushController (): void + + /** + * Get the parameters that are listed in the route / controller signature. + * + * @param conditions + */ + abstract signatureParameters (conditions: ClassConstructor | GenericObject): any[] + /** * Compile the route once, cache the result, return compiled data */ abstract compileRoute (): ICompiledRoute; + + /** + * Set a parameter to the given value. + * + * @param name + * @param value + */ + abstract setParameter (name: string, value?: string | GenericObject): void + + /** + * Unset a parameter on the route if it is set. + * + * @param name + */ + abstract forgetParameter (name: string): void + /** * Get the value of the action that should be taken on a missing model exception. */ abstract getMissing (): CallableConstructor | undefined + /** * The route path that can be handled by H3. */ abstract getPath (): string + /** * Define the callable that should be invoked on a missing model exception. * * @param missing */ abstract missing (missing: CallableConstructor): this + /** * Specify middleware that should be removed from the given route. * * @param middleware */ abstract withoutMiddleware (middleware: any): this + /** * Get the middleware that should be removed from the route. */ abstract excludedMiddleware (): any + /** * Get all middleware, including the ones from the controller. */ - abstract gatherMiddleware (): Record + abstract gatherMiddleware (): GenericObject + + /** + * Indicate that the route should enforce scoping of multiple implicit Eloquent bindings. + */ + abstract scopeBindings (): this + + /** + * Indicate that the route should not enforce scoping of multiple implicit Eloquent bindings. + */ + abstract withoutScopedBindings (): this + + /** + * Determine if the route should enforce scoping of multiple implicit Eloquent bindings. + */ + abstract enforcesScopedBindings (): boolean + + /** + * Determine if the route should prevent scoping of multiple implicit Eloquent bindings. + */ + abstract preventsScopedBindings (): boolean } \ No newline at end of file diff --git a/packages/contracts/src/Routing/IRouter.ts b/packages/contracts/src/Routing/IRouter.ts index 1fd9a914..3de91471 100644 --- a/packages/contracts/src/Routing/IRouter.ts +++ b/packages/contracts/src/Routing/IRouter.ts @@ -1,4 +1,5 @@ -import type { ActionInput, GenericObject, ResourceOptions, RouteActions, RouteMethod } from '../Utilities/Utilities' +import type { ActionInput, CallableConstructor, GenericObject, ResourceOptions, RouteActions, RouteMethod } from '../Utilities/Utilities' +import { IResponse, ResponsableType } from '../Http/IResponse' import type { Middleware, MiddlewareOptions } from 'h3' import type { IController } from '../Core/IController' @@ -6,7 +7,6 @@ import type { IMiddleware } from './IMiddleware' import { IPendingResourceRegistration } from './IPendingResourceRegistration' import { IPendingSingletonResourceRegistration } from './IPendingSingletonResourceRegistration' import { IRequest } from '../Http/IRequest' -import { IResponse } from '../Http/IResponse' import type { IRoute } from './IRoute' import type { IRouteCollection } from './IRouteCollection' import { MiddlewareList } from '../Foundation/MiddlewareContract' @@ -249,7 +249,7 @@ export abstract class IRouter { * @param middleware * @param excluded */ - abstract resolveMiddleware (middleware: IMiddleware[], excluded: IMiddleware[]): any + abstract resolveMiddleware (middleware: MiddlewareList, excluded: MiddlewareList): any /** * Register a group of middleware. * @@ -257,4 +257,45 @@ export abstract class IRouter { * @param middleware */ abstract middlewareGroup (name: string, middleware: MiddlewareList): this + + /** + * Register a group of middleware. + * + * @param name + * @param middleware + */ + abstract middlewareGroup (name: string, middleware: MiddlewareList): this + + /** + * Create a response instance from the given value. + * + * @param request + * @param response + */ + abstract prepareResponse (request: IRequest, response: ResponsableType): Promise + + /** + * Substitute the route bindings onto the route. + * + * @param route + * + * @throws {ModelNotFoundException} + */ + abstract substituteBindings (route: IRoute): Promise + + /** + * Substitute the implicit route bindings for the given route. + * + * @param route + * + * @throws {ModelNotFoundException} + */ + abstract substituteImplicitBindings (route: IRoute): Promise + + /** + * Register a callback to run after implicit bindings are substituted. + * + * @param callback + */ + abstract substituteImplicitBindingsUsing (callback: CallableConstructor): this } \ No newline at end of file diff --git a/packages/contracts/src/Routing/Traits/UrlRoutable.ts b/packages/contracts/src/Routing/Traits/UrlRoutable.ts new file mode 100644 index 00000000..ec1dafb4 --- /dev/null +++ b/packages/contracts/src/Routing/Traits/UrlRoutable.ts @@ -0,0 +1,29 @@ +import { IModel } from '../../Database/IModel' + +export abstract class UrlRoutable { + /** + * Get the value of the model's route key. + */ + abstract getRouteKey (): any; + + /** + * Retrieve the model for a bound value. + * + * @param value + * @param field + */ + abstract resolveRouteBinding (value: any, field?: string): Promise>; + + /** + * Retrieve the child model for a bound value. + * + * @param childType + * @param value + * @param field + */ + + /** + * Get the route key for the model. + */ + abstract getRouteKeyName (): string; +} \ No newline at end of file diff --git a/packages/contracts/src/Utilities/Utilities.ts b/packages/contracts/src/Utilities/Utilities.ts index e3819f6d..2a50ef42 100644 --- a/packages/contracts/src/Utilities/Utilities.ts +++ b/packages/contracts/src/Utilities/Utilities.ts @@ -50,7 +50,8 @@ export interface RouteActions { middleware?: MiddlewareList namespace?: string excluded_middleware?: any - scopeBindings?: any + scopeBindings?: boolean + scope_bindings?: boolean withoutMiddleware?: any withoutScopedBindings?: any } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 545abe7b..f3e1905d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -4,6 +4,7 @@ export * from './Core/IContainer' export * from './Core/IController' export * from './Core/IRegisterer' export * from './Core/IServiceProvider' +export * from './Database/IModel' export * from './Events/IDispatcher' export * from './Exceptions/IExceptionHandler' export * from './Foundation/CKernel' @@ -38,6 +39,7 @@ export * from './Routing/IRoute' export * from './Routing/IRouteCollection' export * from './Routing/IRouter' export * from './Routing/IRouteRegistrar' +export * from './Routing/Traits/UrlRoutable' export * from './Session/FlashBag' export * from './Session/ISessionManager' export * from './Session/SessionContract' diff --git a/packages/core/src/Application.ts b/packages/core/src/Application.ts index 5b4de670..8d467db8 100644 --- a/packages/core/src/Application.ts +++ b/packages/core/src/Application.ts @@ -68,6 +68,11 @@ export class Application extends Container implements IApplication { */ private logsDisabled = false + /** + * The conrrent HttpContext + */ + private httpContext?: IHttpContext + constructor(basePath: string, protected initializer?: string) { super() dotenvExpand.expand(dotenv.config({ quiet: true })) @@ -563,6 +568,24 @@ export class Application extends Container implements IApplication { return this } + /** + * Set the HttpContext. + * + * @param ctx + */ + setHttpContext (ctx: IHttpContext): this { + this.httpContext = ctx + + return this + } + + /** + * Get the HttpContext. + */ + getHttpContext (): IHttpContext | undefined { + return this.httpContext + } + /** * Get the base path of the app * diff --git a/packages/core/src/Container.ts b/packages/core/src/Container.ts index 212cd68f..0c83cc03 100644 --- a/packages/core/src/Container.ts +++ b/packages/core/src/Container.ts @@ -27,7 +27,7 @@ export class Container extends IContainer { /** * The container's resolved instances. */ - protected resolvedInstances = new Set() + protected resolvedInstances = new Map() /** * The registered type alias. */ @@ -216,6 +216,10 @@ export class Container extends IContainer { */ let resolved: any + if (this.resolvedInstances.has(abstract)) { + return this.resolvedInstances.get(abstract) + } + if (raiseEvents) this.runBeforeResolvingCallbacks(abstract) @@ -237,7 +241,7 @@ export class Container extends IContainer { if (raiseEvents) this.runAfterResolvingCallbacks(abstract, resolved) - this.resolvedInstances.add(abstract) + this.resolvedInstances.set(abstract, resolved) return resolved } diff --git a/packages/core/src/ProviderRegistry.ts b/packages/core/src/ProviderRegistry.ts index 8f22d351..f5a83cbd 100644 --- a/packages/core/src/ProviderRegistry.ts +++ b/packages/core/src/ProviderRegistry.ts @@ -2,7 +2,7 @@ import { ConcreteConstructor, IServiceProvider } from '@h3ravel/contracts' import type { Application } from './Application' import { ContainerResolver } from '../src/Manager/ContainerResolver' -import { createRequire } from 'node:module' +import { createRequire } from 'module' import fg from 'fast-glob' import path from 'node:path' diff --git a/packages/database/src/Model.ts b/packages/database/src/Model.ts index 7850dfc7..463bdb3a 100644 --- a/packages/database/src/Model.ts +++ b/packages/database/src/Model.ts @@ -1,18 +1,21 @@ -import { Model as BaseModel, Builder } from '@h3ravel/arquebus' +import { BelongsToMany, HasManyThrough } from '@h3ravel/arquebus/relations' +import { IBuilder, Relation } from '@h3ravel/arquebus/types' -import { IQueryBuilder } from '@h3ravel/arquebus/types' +import { Model as BaseModel } from '@h3ravel/arquebus' +import { Str } from '@h3ravel/support' +import { UrlRoutable } from '@h3ravel/contracts' +import { mix } from '@h3ravel/shared' -export class Model extends BaseModel { +export class Model extends mix(UrlRoutable, BaseModel) { /** * Retrieve the model for a bound value. * - * @param {any} value - * @param {String|null} field - * @returns + * @param value + * @param field */ - resolveRouteBinding (value: any, field: undefined | string | null = null): Promise { - // return this.newQuery().where(field ?? 'ids', value).firstOrFail() as unknown as Promise - return this.resolveRouteBindingQuery(this as never, value, field).firstOrFail() as never + // @ts-expect-error because we don't really care + resolveRouteBinding (value: any, field?: string): Promise { + return this.resolveRouteBindingQuery(this.newQuery() as never, value, field).first() as never } /** @@ -22,10 +25,73 @@ export class Model extends BaseModel { * @param value * @param field */ - resolveRouteBindingQuery (query: Builder, value: any, field: undefined | string | null = null): IQueryBuilder { + resolveRouteBindingQuery (query: IBuilder, value: any, field: undefined | string | null = null): IBuilder { return query.where(field ?? this.getRouteKeyName(), value) as never } + /** + * Retrieve the model for a bound value. + * + * @param value + * @param field + */ + resolveSoftDeletableRouteBinding (value: any, field?: string): Promise { + return this.resolveRouteBindingQuery(this.newQuery() as never, value, field).withTrashed().first() + } + + /** + * Retrieve the child model for a bound value. + * + * @param childType + * @param value + * @param field + */ + resolveChildRouteBinding (childType: string, value: any, field: string): Promise { + return this.resolveChildRouteBindingQuery(childType, value, field).first() as never + } + + /** + * Retrieve the child model for a bound value. + * + * @param childType + * @param value + * @param field + */ + resolveSoftDeletableChildRouteBinding (childType: string, value: any, field: string): Promise { + return this.resolveChildRouteBindingQuery(childType, value, field).withTrashed().first() as never + } + + /** + * Retrieve the child model query for a bound value. + * + * @param childType + * @param value + * @param field + */ + protected resolveChildRouteBindingQuery (childType: string, value: any, field: string): Relation> { + const relationship = this[this.childRouteBindingRelationshipName(childType)]() + + field = field || relationship.getRelated().getRouteKeyName() + + if (relationship instanceof HasManyThrough || + relationship instanceof BelongsToMany) { + field = relationship.getRelated().qualifyColumn(field) + } + + return relationship instanceof Model + ? relationship.resolveRouteBindingQuery(relationship.newQuery() as never, value, field) + : relationship.getRelated().resolveRouteBindingQuery(relationship, value, field) + } + + /** + * Retrieve the child route model binding relationship name for the given child type. + * + * @param childType + */ + protected childRouteBindingRelationshipName (childType: string): keyof typeof this { + return Str.plural(Str.camel(childType)) + } + /** * Get the value of the model's route key. */ diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index fce9658f..c726dfa5 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -2,9 +2,6 @@ export * from './Commands/MakeCommand' export * from './Commands/MigrateCommand' export * from './Commands/SeedCommand' export * from './Configuration' -export * from './Exceptions/ModelNotFoundException' -export * from './Exceptions/RecordNotFoundException' -export * from './Exceptions/RecordsNotFoundException' export * from './Model' export * from './Providers/DatabaseServiceProvider' export * from './Query/DB' diff --git a/packages/foundation/src/Configuration/AppBuilder.ts b/packages/foundation/src/Configuration/AppBuilder.ts index c6ee8e44..f88ef65b 100644 --- a/packages/foundation/src/Configuration/AppBuilder.ts +++ b/packages/foundation/src/Configuration/AppBuilder.ts @@ -27,10 +27,6 @@ export class AppBuilder { this.app.singleton(IKernel, Kernel) this.app.singleton(CKernel, () => new ConsoleKernel(this.app)) - this.app.alias([ - [Kernel, IKernel], - [ConsoleKernel, CKernel] - ]) return this } diff --git a/packages/foundation/src/Console/ConsoleKernel.ts b/packages/foundation/src/Console/ConsoleKernel.ts index 21ec7a78..fac68e98 100644 --- a/packages/foundation/src/Console/ConsoleKernel.ts +++ b/packages/foundation/src/Console/ConsoleKernel.ts @@ -12,7 +12,7 @@ import { MakeCommand } from './Commands/MakeCommand' import { PostinstallCommand } from './Commands/PostinstallCommand' import { Terminating } from '../Core/Events/Terminating' import { altLogo } from './logo' -import { createRequire } from 'node:module' +import { createRequire } from 'module' import tsDownConfig from './TsdownConfig' /** diff --git a/packages/database/src/Exceptions/ModelNotFoundException.ts b/packages/foundation/src/Database/Exceptions/ModelNotFoundException.ts similarity index 75% rename from packages/database/src/Exceptions/ModelNotFoundException.ts rename to packages/foundation/src/Database/Exceptions/ModelNotFoundException.ts index a5dfcd45..99f31ba2 100755 --- a/packages/database/src/Exceptions/ModelNotFoundException.ts +++ b/packages/foundation/src/Database/Exceptions/ModelNotFoundException.ts @@ -1,12 +1,13 @@ +import { ConcreteConstructor, IModel } from '@h3ravel/contracts' + import { Arr } from '@h3ravel/support' -import { Model } from '../Model' import { RecordsNotFoundException } from './RecordsNotFoundException' export class ModelNotFoundException extends RecordsNotFoundException { /** * Name of the affected Eloquent model. */ - protected model?: Model + protected model?: ConcreteConstructor /** * The affected model IDs. @@ -19,11 +20,10 @@ export class ModelNotFoundException extends RecordsNotFoundException { * @param model * @param ids */ - public setModel (model: Model, ids: (number | string)[] = []) { + public setModel (model: ConcreteConstructor, ids: (number | string)[] = []) { this.model = model this.ids = Arr.wrap(ids) - - this.message = `No query results for model [{${model.constructor.name}}]` + this.message = `No query results for model [${model.name ?? model.constructor.name}]` if (this.ids.length > 0) { this.message += ' ' + this.ids.join(', ') diff --git a/packages/database/src/Exceptions/RecordNotFoundException.ts b/packages/foundation/src/Database/Exceptions/RecordNotFoundException.ts similarity index 100% rename from packages/database/src/Exceptions/RecordNotFoundException.ts rename to packages/foundation/src/Database/Exceptions/RecordNotFoundException.ts diff --git a/packages/database/src/Exceptions/RecordsNotFoundException.ts b/packages/foundation/src/Database/Exceptions/RecordsNotFoundException.ts similarity index 100% rename from packages/database/src/Exceptions/RecordsNotFoundException.ts rename to packages/foundation/src/Database/Exceptions/RecordsNotFoundException.ts diff --git a/packages/foundation/src/Http/Kernel.ts b/packages/foundation/src/Http/Kernel.ts index 38226063..cd47aa27 100644 --- a/packages/foundation/src/Http/Kernel.ts +++ b/packages/foundation/src/Http/Kernel.ts @@ -431,11 +431,12 @@ export class Kernel extends IKernel { // TODO: Pay Attention to these this.router.middlewarePriority = this.middlewarePriority for (const [key, middleware] of Object.entries(this.middlewareGroups)) { - // this.router.middlewareGroup(key, middleware) + this.router.middlewareGroup(key, middleware) } for (const [key, middleware] of Object.entries(this.middlewareAliases)) { // this.router.aliasMiddleware(key, middleware) + // console.log(key, middleware, 'key, middleware') } } @@ -517,7 +518,6 @@ export class Kernel extends IKernel { public setMiddlewareGroups (groups: Record) { this.middlewareGroups = groups this.syncMiddlewareToRouter() - return this } diff --git a/packages/foundation/src/index.ts b/packages/foundation/src/index.ts index 00ccb575..c05e0064 100644 --- a/packages/foundation/src/index.ts +++ b/packages/foundation/src/index.ts @@ -33,6 +33,9 @@ export * from './Console/Commands/KeyGenerateCommand' export * from './Console/Commands/MakeCommand' export * from './Console/Commands/PostinstallCommand' export * from './Core/Events/Terminating' +export * from './Database/Exceptions/ModelNotFoundException' +export * from './Database/Exceptions/RecordNotFoundException' +export * from './Database/Exceptions/RecordsNotFoundException' export * from './Exceptions/Base/ExceptionHandler' export * from './Exceptions/Base/Exceptions' export * from './Exceptions/Base/Handler' diff --git a/packages/http/src/HttpContext.ts b/packages/http/src/HttpContext.ts index 7a28c7ab..816a4f96 100644 --- a/packages/http/src/HttpContext.ts +++ b/packages/http/src/HttpContext.ts @@ -1,4 +1,5 @@ import { IApplication, IHttpContext, IRequest, IResponse } from '@h3ravel/contracts' + import type { H3Event } from 'h3' /** @@ -29,6 +30,7 @@ export class HttpContext implements IHttpContext { instance.event = event! ctx.request.context = instance ctx.response.context = instance + ctx.app.setHttpContext(instance) if (event) { HttpContext.contexts.set(event, instance) diff --git a/packages/http/src/JsonResponse.ts b/packages/http/src/JsonResponse.ts index eb1255cf..b7dd9f20 100644 --- a/packages/http/src/JsonResponse.ts +++ b/packages/http/src/JsonResponse.ts @@ -30,7 +30,7 @@ export class JsonResponse extends Response { throw new TypeError(`"${this.constructor.name}": If \`json\` is set to true, argument \`data\` must be a string or object implementing toString(), "${typeof data}" given.`) } - data ??= 'new ArrayObject()' + data ??= {} if (json) this.setJson(data) else this.setData(data) diff --git a/packages/router/src/Contracts/Utilities.ts b/packages/router/src/Contracts/Utilities.ts index dde10c41..cf211e57 100644 --- a/packages/router/src/Contracts/Utilities.ts +++ b/packages/router/src/Contracts/Utilities.ts @@ -1,7 +1,12 @@ -import { IMiddleware } from '@h3ravel/contracts' +import { ConcreteConstructor, IMiddleware, UrlRoutable } from '@h3ravel/contracts' export type Pipe = string | (abstract new (...args: any[]) => any) | ((...args: any[]) => any) | IMiddleware export type CompiledRouteToken = | ['variable', string, string, string, boolean] - | ['text', string]; \ No newline at end of file + | ['text', string]; + +export interface RouteActionConditions { + [key: string]: any, + subClass: ConcreteConstructor +} \ No newline at end of file diff --git a/packages/router/src/ControllerDispatcher.ts b/packages/router/src/ControllerDispatcher.ts index 695d98d1..ca171b67 100644 --- a/packages/router/src/ControllerDispatcher.ts +++ b/packages/router/src/ControllerDispatcher.ts @@ -61,7 +61,7 @@ export class ControllerDispatcher extends mix( return [] } - return (new Collection(controller.getMiddleware())) + return (new Collection(controller.getMiddleware?.() ?? {} as IMiddleware)) .reject((data) => ControllerDispatcher.methodExcludedByOptions(method, data.options)) .pluck('middleware') .all() as never diff --git a/packages/router/src/ImplicitRouteBinding.ts b/packages/router/src/ImplicitRouteBinding.ts new file mode 100644 index 00000000..0d21aefa --- /dev/null +++ b/packages/router/src/ImplicitRouteBinding.ts @@ -0,0 +1,98 @@ +import { GenericObject, IModel, UrlRoutable } from '@h3ravel/contracts' + +import { Application } from '@h3ravel/core' +import { Logger } from '@h3ravel/shared' +import { ModelNotFoundException } from '@h3ravel/foundation' +import { Route } from './Route' +import { Str } from '@h3ravel/support' + +export class ImplicitRouteBinding { + /** + * Resolve the implicit route bindings for the given route. + * + * @param container + * @param route + */ + public static async resolveForRoute (container: Application, route: Route): Promise { + const parameters = route.getParameters() + + // Iterate only through parameters that are hinted as Models (UrlRoutable) + for (const parameter of route.signatureParameters({ subClass: UrlRoutable as never })) { + const parameterName = this.getParameterName(parameter.getName(), parameters) + + if (!parameterName) continue + + const parameterValue = parameters[parameterName] + + // If the parameter value is already a resolved object/model, skip it. + if (parameterValue instanceof UrlRoutable) { + continue + } + + // Get the class constructor (e.g., User, Post) + const instanceClass = parameter.getType() + const instance = container.make(instanceClass) + + const parent = route.parentOfParameter(parameterName) + + // Determine if we should use Soft Delete logic + const isSoftDeletable = typeof instanceClass.isSoftDeletable === 'function' && instanceClass.isSoftDeletable() + const useSoftDelete = route.allowsTrashedBindings() && isSoftDeletable + + let model: IModel + + // Scoped Binding (e.g., /users/{user}/posts/{post}) + if ( + parent instanceof UrlRoutable && + !route.preventsScopedBindings() && + (route.enforcesScopedBindings() || parameterName in route.getBindingFields()) + ) { + const childMethod = useSoftDelete + ? 'resolveSoftDeletableChildRouteBinding' + : 'resolveChildRouteBinding' + + model = await Reflect.apply( + (parent as any)[childMethod], + parent, + [parameterName, parameterValue, route.bindingFieldFor(parameterName)] + ) + } + + // Standard Binding (e.g., /users/{user}) + else { + const method = useSoftDelete + ? 'resolveSoftDeletableRouteBinding' + : 'resolveRouteBinding' + + model = await Reflect.apply(instance[method], instance, [parameterValue, route.bindingFieldFor(parameterName)]) + } + + if (!model) { + throw new ModelNotFoundException().setModel(instanceClass, [parameterValue]) + } + + route.setParameter(parameterName, model) + } + } + + /** + * Return the parameter name if it exists in the given parameters. + * + * @param name + * @param parameters + * @returns + */ + protected static getParameterName (name: string, parameters: GenericObject): string | undefined { + if (name in parameters) { + return name + } + + const snakedName = Str.snake(name) + + if (snakedName in parameters) { + return snakedName + } + + return undefined + } +} \ No newline at end of file diff --git a/packages/router/src/Middleware/SubstituteBindings.ts b/packages/router/src/Middleware/SubstituteBindings.ts index 5d1f5f40..de453d7f 100644 --- a/packages/router/src/Middleware/SubstituteBindings.ts +++ b/packages/router/src/Middleware/SubstituteBindings.ts @@ -1,6 +1,8 @@ import { IApplication, IRouter } from '@h3ravel/contracts' +import { Injectable, ModelNotFoundException } from '@h3ravel/foundation' import { Middleware, Request } from '@h3ravel/http' +@Injectable() export class SubstituteBindings extends Middleware { /** * @@ -16,24 +18,23 @@ export class SubstituteBindings extends Middleware { * @param request * @param next */ + @Injectable() async handle (request: Request, next: (request: Request) => Promise) { const route = request.route() - console.log(route, '----') + try { - this.router.substituteBindings(route) - this.router.substituteImplicitBindings(route) + await this.router.substituteBindings(route) + await this.router.substituteImplicitBindings(route) } catch (e) { - const { ModelNotFoundException } = await import('@h3ravel/database') - if (e instanceof ModelNotFoundException) { const getMissing = route.getMissing() if (typeof getMissing !== 'undefined') { return getMissing(request, e) } - - throw e } + + throw e } return next(request) diff --git a/packages/router/src/PendingResourceRegistration.ts b/packages/router/src/PendingResourceRegistration.ts index 6f72f405..afe2cafb 100644 --- a/packages/router/src/PendingResourceRegistration.ts +++ b/packages/router/src/PendingResourceRegistration.ts @@ -1,7 +1,7 @@ import { Arr, Macroable } from '@h3ravel/support' import { IController, MiddlewareIdentifier, MiddlewareList, ResourceMethod, ResourceOptions } from '@h3ravel/contracts' -import { CreatesRegularExpressionRouteConstraints } from './CreatesRegularExpressionRouteConstraints' +import { CreatesRegularExpressionRouteConstraints } from './Traits/CreatesRegularExpressionRouteConstraints' import { ResourceRegistrar } from './ResourceRegistrar' import { RouteCollection } from './RouteCollection' import { Router } from './Router' diff --git a/packages/router/src/PendingSingletonResourceRegistration.ts b/packages/router/src/PendingSingletonResourceRegistration.ts index abe60156..f7d6fd19 100644 --- a/packages/router/src/PendingSingletonResourceRegistration.ts +++ b/packages/router/src/PendingSingletonResourceRegistration.ts @@ -2,7 +2,7 @@ import { Arr, Macroable } from '@h3ravel/support' import { Finalizable, use } from '@h3ravel/shared' import { IController, MiddlewareIdentifier, MiddlewareList, ResourceMethod, ResourceOptions } from '@h3ravel/contracts' -import { CreatesRegularExpressionRouteConstraints } from './CreatesRegularExpressionRouteConstraints' +import { CreatesRegularExpressionRouteConstraints } from './Traits/CreatesRegularExpressionRouteConstraints' import { ResourceRegistrar } from './ResourceRegistrar' import { RouteCollection } from './RouteCollection' import { Router } from './Router' diff --git a/packages/router/src/Pipeline.ts b/packages/router/src/Pipeline.ts index 17b06ed8..78f4ab03 100644 --- a/packages/router/src/Pipeline.ts +++ b/packages/router/src/Pipeline.ts @@ -1,6 +1,6 @@ +import { CallableConstructor, IRequest } from '@h3ravel/contracts' import { Container, ContainerResolver } from '@h3ravel/core' -import { CallableConstructor } from '@h3ravel/contracts' import { Logger } from '@h3ravel/shared' import { Pipe } from './Contracts/Utilities' import { RuntimeException } from '@h3ravel/support' @@ -118,13 +118,15 @@ export class Pipeline { // If pipe is a string (class reference) if (typeof pipe === 'string') { const [name, extras] = this.parsePipeString(pipe) + const bound = this.getContainer().boundMiddlewares(name) if (bound) { instance = this.getContainer().make(bound as never) parameters = [passable, stack, ...extras] } else { - instance = () => { - Logger.error(`Error: Middleware [${name}] not bound: Skipping...`, false) + instance = async function (request: IRequest, next) { + Logger.error(`Error: Middleware [${name}] requested by [${request.getRequestUri()}] not bound: Skipping...`, false) + return next } } @@ -134,7 +136,8 @@ export class Pipeline { } const handler: CallableConstructor = instance[this.method as never] ?? instance - const result = await handler.apply(instance, parameters) + // const result = 'await handler.apply(instance, parameters)' + const result = Reflect.apply(handler, instance, parameters) return await this.handleCarry(result) diff --git a/packages/router/src/Route.ts b/packages/router/src/Route.ts index c74a6a6d..db560bbf 100644 --- a/packages/router/src/Route.ts +++ b/packages/router/src/Route.ts @@ -1,6 +1,7 @@ -import { ActionInput, CallableConstructor, GenericObject, IController, IControllerDispatcher, IRoute, ResourceMethod, ResponsableType, RouteActions, RouteMethod } from '@h3ravel/contracts' +import { ActionInput, CallableConstructor, ClassConstructor, GenericObject, IController, IControllerDispatcher } from '@h3ravel/contracts' import { Application, Container } from '@h3ravel/core' import { Arr, Obj, Str, isClass } from '@h3ravel/support' +import { IRoute, MiddlewareList, ResourceMethod, ResponsableType, RouteActions, RouteMethod } from '@h3ravel/contracts' import { CallableDispatcher } from './CallableDispatcher' import { CompiledRoute } from './CompiledRoute' @@ -12,7 +13,9 @@ import { LogicException } from '@h3ravel/foundation' import { MethodValidator } from './Matchers/MethodValidator' import { Request } from '@h3ravel/http' import { RouteAction } from './RouteAction' +import { RouteActionConditions } from './Contracts/Utilities' import { RouteParameterBinder } from './RouteParameterBinder' +import { RouteSignatureParameters } from './RouteSignatureParameters' import { RouteUri } from './RouteUri' import { Router } from './Router' import { SchemeValidator } from './Matchers/SchemeValidator' @@ -64,6 +67,11 @@ export class Route extends IRoute { */ protected bindingFields!: GenericObject + /** + * Indicates "trashed" models can be retrieved when resolving implicit model bindings for this route. + */ + protected withTrashedBindings = false + /** * Indicates whether the route is a fallback route. */ @@ -87,7 +95,7 @@ export class Route extends IRoute { /** * The computed gathered middleware. */ - computedMiddleware?: any[] + computedMiddleware?: MiddlewareList /** * The controller instance. @@ -479,7 +487,11 @@ export class Route extends IRoute { * Get the matched parameters object. */ getParameters () { - return this.parameters ?? {} + if (typeof this.parameters !== 'undefined') { + return this.parameters + } + + throw new LogicException('Route is not bound.') } /** @@ -541,6 +553,28 @@ export class Route extends IRoute { return this } + /** + * Get the parent parameter of the given parameter. + * + * @param parameter + */ + parentOfParameter (parameter: string): any { + const key = Object.keys(this.getParameters()).findIndex(e => e == parameter) + + if (!key || key === 0) { + return + } + + return Object.values(this.getParameters())[key - 1] + } + + /** + * Determines if the route allows "trashed" models to be retrieved when resolving implicit model bindings. + */ + allowsTrashedBindings (): boolean { + return this.withTrashedBindings + } + /** * Set a default value for the route. * @@ -610,6 +644,21 @@ export class Route extends IRoute { return matches.map(m => m[1]) } + /** + * Get the parameters that are listed in the route / controller signature. + * + * @param conditions + */ + signatureParameters (conditions: ClassConstructor | RouteActionConditions) { + if (isClass(conditions)) { + conditions = { subClass: conditions } + } + + return RouteSignatureParameters + .setRequirements(this.container, this) + .fromAction(this.action, conditions as RouteActionConditions) + } + /** * Compile the route once, cache the result, return compiled data */ @@ -623,6 +672,29 @@ export class Route extends IRoute { return this.compiled } + /** + * Set a parameter to the given value. + * + * @param name + * @param value + */ + setParameter (name: string, value?: string | GenericObject): void { + this.getParameters() + + this.parameters![name] = value + } + + /** + * Unset a parameter on the route if it is set. + * + * @param name + */ + forgetParameter (name: string): void { + this.getParameters() + + delete this.parameters![name] + } + /** * Get the value of the action that should be taken on a missing model exception. */ @@ -665,21 +737,53 @@ export class Route extends IRoute { /** * Get the middleware that should be removed from the route. */ - excludedMiddleware (): any { + excludedMiddleware (): MiddlewareList { return this.action.excluded_middleware ?? {} } /** * Get all middleware, including the ones from the controller. */ - gatherMiddleware () { + gatherMiddleware (): MiddlewareList { if (this.computedMiddleware) { return this.computedMiddleware } - this.computedMiddleware = [] + this.computedMiddleware = Router.uniqueMiddleware([...this.middleware(), ...this.controllerMiddleware()]) + + return this.computedMiddleware + } + + /** + * Indicate that the route should enforce scoping of multiple implicit Eloquent bindings. + */ + scopeBindings () { + this.action['scope_bindings'] = true + + return this + } + + /** + * Indicate that the route should not enforce scoping of multiple implicit Eloquent bindings. + */ + withoutScopedBindings (): this { + this.action['scope_bindings'] = false - return this.computedMiddleware = Router.uniqueMiddleware([...this.middleware(), ...this.controllerMiddleware()]) + return this + } + + /** + * Determine if the route should enforce scoping of multiple implicit Eloquent bindings. + */ + enforcesScopedBindings (): boolean { + return this.action['scope_bindings'] ?? false + } + + /** + * Determine if the route should prevent scoping of multiple implicit Eloquent bindings. + */ + preventsScopedBindings (): boolean { + return typeof this.action['scope_bindings'] !== 'undefined' && this.action['scope_bindings'] === false } /** @@ -800,7 +904,7 @@ export class Route extends IRoute { */ getControllerMethod (): ResourceMethod { const holder = isClass(this.action.uses) && typeof this.action.controller === 'string' ? this.action.controller : 'index' - return Str.parseCallback(holder)[1] as ResourceMethod + return Str.parseCallback(holder).at(1) as ResourceMethod } /** diff --git a/packages/router/src/RouteAction.ts b/packages/router/src/RouteAction.ts index 74077c09..4ea38826 100644 --- a/packages/router/src/RouteAction.ts +++ b/packages/router/src/RouteAction.ts @@ -2,6 +2,7 @@ import type { ActionInput, IController, RouteActions } from '@h3ravel/contracts' import { LogicException } from '@h3ravel/foundation' import { UnexpectedValueException } from '@h3ravel/http' +import { isCallable } from '@h3ravel/support' export class RouteAction { /** @@ -20,7 +21,7 @@ export class RouteAction { /** * Handle closure */ - if (typeof action === 'function' && !this.isClass(action)) { + if (isCallable(action)) { return { uses: action } } @@ -73,7 +74,7 @@ export class RouteAction { /** * uses: function */ - if (typeof uses === 'function' && !this.isClass(uses)) { + if (isCallable(uses)) { return { ...this.action, uses } } @@ -82,9 +83,9 @@ export class RouteAction { */ if (this.isClass(uses)) { return { - ...this.action, uses: this.action, controller: this.action.name + '@index', + ...this.action, } } diff --git a/packages/router/src/RouteParameter.ts b/packages/router/src/RouteParameter.ts new file mode 100644 index 00000000..a2c9de49 --- /dev/null +++ b/packages/router/src/RouteParameter.ts @@ -0,0 +1,20 @@ +export class RouteParameter { + constructor( + private name: string, + private type: any + ) { } + + /** + * Ge the route parameter name + */ + getName () { + return this.name + } + + /** + * Ge the route parameter type + */ + getType () { + return this.type + } +} \ No newline at end of file diff --git a/packages/router/src/RouteRegisterer.ts b/packages/router/src/RouteRegisterer.ts index 81778c15..ffe72307 100644 --- a/packages/router/src/RouteRegisterer.ts +++ b/packages/router/src/RouteRegisterer.ts @@ -2,7 +2,7 @@ import { Arr, Macroable } from '@h3ravel/support' import { CallableConstructor, IController, ResourceOptions, RouteActions, RouteMethod } from '@h3ravel/contracts' import { UseMagic, trait, use } from '@h3ravel/shared' -import { CreatesRegularExpressionRouteConstraints } from './CreatesRegularExpressionRouteConstraints' +import { CreatesRegularExpressionRouteConstraints } from './Traits/CreatesRegularExpressionRouteConstraints' import { FRoute } from '@h3ravel/support/facades' import { Injectable } from '@h3ravel/core' import { Router } from './Router' diff --git a/packages/router/src/RouteSignatureParameters.ts b/packages/router/src/RouteSignatureParameters.ts new file mode 100644 index 00000000..0dc90a34 --- /dev/null +++ b/packages/router/src/RouteSignatureParameters.ts @@ -0,0 +1,99 @@ +import 'reflect-metadata' + +import { IApplication, ResourceMethod, RouteActions } from '@h3ravel/contracts' +import { Str, isClass } from '@h3ravel/support' + +import { Route } from './Route' +import { RouteActionConditions } from './Contracts/Utilities' +import { RouteParameter } from './RouteParameter' + +export class RouteSignatureParameters { + private static app: IApplication + private static route: Route + + /** + * set the current Application and Route instances + * + * @param app + */ + static setRequirements (app: IApplication, route: Route) { + this.app = app + this.route = route + + return this + } + + /** + * Extract the route action's signature parameters. + * + * @param action + * @param conditions + * @returns + */ + public static fromAction (action: RouteActions, conditions = {} as RouteActionConditions) { + const uses = action.uses + let target: any, methodName: string + + if (isClass(uses)) { + target = this.app.make(uses) + methodName = this.getControllerMethod(action) + } else if (Array.isArray(uses)) { + const [_target, _methodName] = uses + target = target.prototype + methodName = _methodName + } else { + // Logic for closures or single-function actions + return [new RouteParameter('context', this.app.getHttpContext()), new RouteParameter('app', this.app)] + } + + // Get types emitted by @Injectable / @Decorator + const types: any[] = Reflect.getMetadata('design:paramtypes', target, methodName) || [] + + // Get names from the current Route object + // Example: { user: 1, house: 5 } -> ['user', 'house'] + const routeParamNames = Object.keys(this.route.getParameters()) + let routeParamIndex = 0 + + // Map Types to Parameters + const parameters = types.map(type => { + let name = 'unknown' + + // Determine if this type should "consume" one of the route parameter names. + // We check if it matches the 'subClass' condition (e.g., UrlRoutable). + const isBindingTarget = conditions.subClass && (type === conditions.subClass || type.prototype instanceof conditions.subClass) + + if (isBindingTarget) { + name = routeParamNames[routeParamIndex++] || 'unnamed_binding' + } else { + // If it's a non-binding parameter (like Request/Response), we give it a placeholder. + // In DI, the type is usually more important than the name. + name = type.name?.toLowerCase() || 'injected' + } + + return new RouteParameter(name, type) + }) + + // Return filtered list based on 'match' conditions + if (conditions.subClass) { + const subClass = conditions.subClass + + return parameters.filter(p => { + const type = p.getType() + return type === subClass || type.prototype instanceof subClass + }) + } + + return parameters + } + + /** + * Get the controller method used for the route. + * + * @param action + * @returns + */ + private static getControllerMethod (action: RouteActions): ResourceMethod { + const holder = isClass(action.uses) && typeof action.controller === 'string' ? action.controller : 'index' + return Str.parseCallback(holder).at(1) as ResourceMethod + } +} \ No newline at end of file diff --git a/packages/router/src/Router.ts b/packages/router/src/Router.ts index ee30f0c4..e772e058 100644 --- a/packages/router/src/Router.ts +++ b/packages/router/src/Router.ts @@ -1,14 +1,14 @@ import 'reflect-metadata' import { Middleware, MiddlewareOptions, type H3 } from 'h3' import { Application } from '@h3ravel/core' -import { Request, Response, HttpContext, JsonResponse } from '@h3ravel/http' +import { Request, Response, JsonResponse } from '@h3ravel/http' import { Arr, Collection, isClass, MacroableClass, Str, Stringable, tap } from '@h3ravel/support' import { IDispatcher } from '@h3ravel/contracts' import { Magic, mix } from '@h3ravel/shared' import { IMiddleware, IRequest, IResponse, IRouter, RouteActions, ActionInput, MiddlewareList, ResponsableType } from '@h3ravel/contracts' -import type { EventHandler, IController, GenericObject, ResourceOptions, ResourceMethod } from '@h3ravel/contracts' +import type { EventHandler, IController, GenericObject, ResourceOptions, ResourceMethod, CallableConstructor, IModel, MiddlewareIdentifier } from '@h3ravel/contracts' import { RouteMethod, IResponsable } from '@h3ravel/contracts' -import { ExceptionHandler, internal } from '@h3ravel/foundation' +import { internal } from '@h3ravel/foundation' import { Route } from './Route' import { Routing } from './Events/Routing' import { RouteMatched } from './Events/RouteMatched' @@ -22,8 +22,9 @@ import { PendingSingletonResourceRegistration } from './PendingSingletonResource import { ResourceRegistrar } from './ResourceRegistrar' import { PendingResourceRegistration } from './PendingResourceRegistration' import { RouteRegistrar } from './RouteRegisterer' -import { createRequire } from 'node:module' +import { createRequire } from 'module' import { existsSync } from 'node:fs' +import { ImplicitRouteBinding } from './ImplicitRouteBinding' export class Router extends mix(IRouter, MacroableClass, Magic) { private DIST_DIR: string @@ -35,6 +36,11 @@ export class Router extends mix(IRouter, MacroableClass, Magic) { private middlewareMap: IMiddleware[] = [] private groupMiddleware: EventHandler[] = [] + /** + * The registered route value binders. + */ + protected binders: Record = {} + /** * All of the short-hand keys for middlewares. */ @@ -62,6 +68,11 @@ export class Router extends mix(IRouter, MacroableClass, Magic) { */ public middlewarePriority: MiddlewareList = [] + /** + * The registered custom implicit binding callback. + */ + protected implicitBindingCallback?: (container: Application, route: Route, defaultFn: CallableConstructor) => any + /** * All of the verbs supported by the router. */ @@ -203,37 +214,6 @@ export class Router extends mix(IRouter, MacroableClass, Magic) { // .sync(this.h3App) } - /** - * Gracefully handle the outgoing response and pass all caught errors - * to the exception handler. - * - * @param handler - * @param ctx - * @returns - */ - private async handleResponse (handler: (ctx: HttpContext) => Promise, ctx: HttpContext): Promise { - const exceptionHandler = this.app.make(ExceptionHandler) - if (!exceptionHandler) { - return await handler(ctx) - } - - try { - return await handler(ctx) - } catch (error) { - /** - * Handle the exception here. - */ - if (typeof exceptionHandler.handle !== 'undefined') { - return await exceptionHandler.handle(error as Error, ctx) as IResponse - } - - /** - * If no exception handler has been defined, throw the original exception. - */ - throw error - } - } - /** * Dispatch the request to the application. * @@ -307,8 +287,6 @@ export class Router extends mix(IRouter, MacroableClass, Magic) { /** * Get all of the defined middleware short-hand names. - * - * @return array */ getMiddleware (): GenericObject { return this.middlewares @@ -331,7 +309,7 @@ export class Router extends mix(IRouter, MacroableClass, Magic) { * * @param route */ - gatherRouteMiddleware (route: Route): any { + gatherRouteMiddleware (route: Route): MiddlewareList { return this.resolveMiddleware( route.gatherMiddleware(), route.excludedMiddleware() @@ -343,18 +321,17 @@ export class Router extends mix(IRouter, MacroableClass, Magic) { * * @param middleware * @param excluded - * @return array */ - resolveMiddleware (middleware: IMiddleware[], excluded: IMiddleware[] = []): any { + resolveMiddleware (middleware: MiddlewareList, excluded: MiddlewareList = []): any { excluded = excluded.length === 0 ? excluded - : (new Collection(excluded)) + : (new Collection(excluded)) .map((name) => MiddlewareResolver.setApp(this.app).resolve(name, this.middlewares, this.middlewareGroups)) .flatten() .values() .all() as never - const middlewares = (new Collection(middleware)) + const middlewares = (new Collection(middleware)) .map((name) => MiddlewareResolver.setApp(this.app).resolve(name, this.middlewares, this.middlewareGroups)) .flatten() @@ -390,8 +367,7 @@ export class Router extends mix(IRouter, MacroableClass, Magic) { /** * Sort the given middleware by priority. * - * @param \Illuminate\Support\Collection $middlewares - * @return array + * @param middlewares */ protected sortMiddleware (middlewares: Collection) { return middlewares.all() @@ -417,7 +393,7 @@ export class Router extends mix(IRouter, MacroableClass, Magic) { * @param request * @param response */ - async prepareResponse (request: IRequest, response: ResponsableType) { + async prepareResponse (request: IRequest, response: ResponsableType): Promise { this.events?.dispatch(new PreparingResponse(request, response)) return tap(Router.toResponse(request, response), (response) => { @@ -431,11 +407,11 @@ export class Router extends mix(IRouter, MacroableClass, Magic) { * @param request * @param response */ - static toResponse (request: IRequest, response: ResponsableType) { + static toResponse (request: IRequest, response: ResponsableType): IResponse { if (response instanceof IResponsable) { response = response.toResponse(request) } - // console.log(response) + // if (response instanceof Model && response.wasRecentlyCreated) { // response = new JsonResponse(response, 201) // } @@ -452,12 +428,74 @@ export class Router extends mix(IRouter, MacroableClass, Magic) { return response.prepare(request) } + /** + * Substitute the route bindings onto the route. + * + * @param route + * + * @throws {ModelNotFoundException} + */ + async substituteBindings (route: Route): Promise { + for (const [key, value] of Object.entries(route.parameters ?? {})) { + if (typeof this.binders[key] !== 'undefined') { + route.setParameter(key, await this.performBinding(key, value, route)) + } + } + + return route + } + + /** + * Substitute the implicit route bindings for the given route. + * + * @param route + * + * @throws {ModelNotFoundException} + */ + async substituteImplicitBindings (route: Route): Promise { + const defaultFn = () => ImplicitRouteBinding.resolveForRoute(this.app, route) + + return await Reflect.apply( + this.implicitBindingCallback ?? defaultFn, + undefined, + [this.app, route, defaultFn] + ) + } + + /** + * Register a callback to run after implicit bindings are substituted. + * + * @param callback + */ + substituteImplicitBindingsUsing (callback: CallableConstructor): this { + this.implicitBindingCallback = callback + + return this + } + + /** + * Call the binding callback for the given key. + * + * @param key + * @param value + * @param route + * + * @throws {ModelNotFoundException} + */ + protected performBinding (key: string, value: string, route: Route): Promise { + return Reflect.apply( + this.binders[key], + undefined, + [value, route] + ) + } + /** * Remove any duplicate middleware from the given array. * * @param middleware */ - static uniqueMiddleware (middleware: MiddlewareList) { + static uniqueMiddleware (middleware: MiddlewareList): MiddlewareIdentifier[] { return Array.from(new Set(middleware)) } diff --git a/packages/router/src/CreatesRegularExpressionRouteConstraints.ts b/packages/router/src/Traits/CreatesRegularExpressionRouteConstraints.ts similarity index 100% rename from packages/router/src/CreatesRegularExpressionRouteConstraints.ts rename to packages/router/src/Traits/CreatesRegularExpressionRouteConstraints.ts diff --git a/packages/router/src/Traits/RouteDependencyResolver.ts b/packages/router/src/Traits/RouteDependencyResolver.ts index 38ddd162..bc86b497 100644 --- a/packages/router/src/Traits/RouteDependencyResolver.ts +++ b/packages/router/src/Traits/RouteDependencyResolver.ts @@ -16,10 +16,6 @@ export class RouteDependencyResolver { * @param method */ public async resolveClassMethodDependencies (parameters: Record, instance: IController, method: ResourceMethod) { - if (!Object.prototype.hasOwnProperty.call(instance, method)) { - return parameters - } - /** * Ensure the method exists on the controller */ @@ -37,25 +33,27 @@ export class RouteDependencyResolver { */ let args = await Promise.all( paramTypes.map(async (paramType: any) => { - const inst = this.container.make(paramType) - // if (inst instanceof Model) { - // Route model binding returns a Promise - // return await Helpers.resolveRouteModelBinding(path ?? '', ctx, inst) - return inst + const instance = Object.values(parameters).find(e => e instanceof paramType) + + if (instance && typeof instance === 'object') { + return instance + } + + return await this.container.make(paramType) }) ) /** - * Ensure that the HttpContext is always available + * Ensure that the HttpContext and Application instances are always available */ if (args.length < 1) { - args = [this.container.make('http.context')] + args = [this.container.getHttpContext(), this.container] } /** * Call the controller method, passing all resolved dependencies */ - return this.resolveMethodDependencies([...args, ...parameters]) + return this.resolveMethodDependencies([...args, ...Object.values(parameters)]) } /** diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 3de9f890..7daf3b51 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -6,12 +6,12 @@ export * from './Contracts/IRouteValidator' export * from './Contracts/Utilities' export * from './Controller' export * from './ControllerDispatcher' -export * from './CreatesRegularExpressionRouteConstraints' export * from './Events/PreparingResponse' export * from './Events/ResponsePrepared' export * from './Events/RouteMatched' export * from './Events/Routing' export * from './Helpers' +export * from './ImplicitRouteBinding' export * from './Matchers/HostValidator' export * from './Matchers/MethodValidator' export * from './Matchers/SchemeValidator' @@ -26,11 +26,14 @@ export * from './Route' export * from './RouteAction' export * from './RouteCollection' export * from './RouteGroup' +export * from './RouteParameter' export * from './RouteParameterBinder' export * from './Router' export * from './RouteRegisterer' +export * from './RouteSignatureParameters' export * from './RouteUri' export * from './RouteUrlGenerator' +export * from './Traits/CreatesRegularExpressionRouteConstraints' export * from './Traits/FiltersControllerMiddleware' export * from './Traits/RouteDependencyResolver' export * from './UrlGenerator' diff --git a/packages/shared/package.json b/packages/shared/package.json index 7d59f1f7..5b105db3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/shared", - "version": "0.27.7", + "version": "0.28.0", "description": "Shared Utilities.", "type": "module", "main": "./dist/index.cjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22691b8e..f940d87f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,8 +143,8 @@ catalogs: version: 5.1.4 prod: '@h3ravel/arquebus': - specifier: ^0.6.17 - version: 0.6.17 + specifier: ^0.7.3 + version: 0.7.3 '@h3ravel/collect.js': specifier: ^5.3.3 version: 5.3.3 @@ -272,7 +272,7 @@ importers: dependencies: '@h3ravel/arquebus': specifier: catalog:prod - version: 0.6.17(@types/node@24.9.2)(sqlite3@5.1.7) + version: 0.7.3(@types/node@24.9.2)(sqlite3@5.1.7) '@h3ravel/cache': specifier: workspace:^ version: link:../../packages/cache @@ -461,6 +461,9 @@ importers: specifier: catalog:prod version: 2.0.1-rc.5 devDependencies: + '@h3ravel/arquebus': + specifier: catalog:prod + version: 0.7.3(@types/node@24.10.0)(sqlite3@5.1.7) '@h3ravel/musket': specifier: catalog:prod version: 0.6.8(@h3ravel/support@0.15.6)(@types/node@24.10.0) @@ -530,7 +533,7 @@ importers: dependencies: '@h3ravel/arquebus': specifier: catalog:prod - version: 0.6.17(@types/node@24.10.0)(sqlite3@5.1.7) + version: 0.7.3(@types/node@24.10.0)(sqlite3@5.1.7) '@h3ravel/core': specifier: workspace:^ version: link:../core @@ -1627,8 +1630,8 @@ packages: '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} - '@h3ravel/arquebus@0.6.17': - resolution: {integrity: sha512-i94Y4qUr42HMaxuWzoscw0YIs9QWyBtmGp5r8vvltnqDFYQ6YZ4R0cgzhjkPrMKAPPjewQ6Y4Pg1nQtGjuLq5w==} + '@h3ravel/arquebus@0.7.3': + resolution: {integrity: sha512-C45R/q9YowDU+HzUG08XREx1Wr1M8ayRrH5N78rQJxH1Oz/N3QeLb6zrc5iFMWy4zNscFe44JZa19T9/ByAL2Q==} engines: {node: '>=14', pnpm: '>=4'} hasBin: true @@ -7042,7 +7045,7 @@ snapshots: '@gar/promisify@1.1.3': optional: true - '@h3ravel/arquebus@0.6.17(@types/node@24.10.0)(sqlite3@5.1.7)': + '@h3ravel/arquebus@0.7.3(@types/node@24.10.0)(sqlite3@5.1.7)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.10.0) '@h3ravel/support': 0.15.6 @@ -7073,7 +7076,7 @@ snapshots: - sqlite3 - supports-color - '@h3ravel/arquebus@0.6.17(@types/node@24.9.2)(sqlite3@5.1.7)': + '@h3ravel/arquebus@0.7.3(@types/node@24.9.2)(sqlite3@5.1.7)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.9.2) '@h3ravel/support': 0.15.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bdf7a37e..d67c7837 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -71,7 +71,7 @@ catalog: catalogs: prod: - '@h3ravel/arquebus': ^0.6.17 + '@h3ravel/arquebus': ^0.7.3 '@h3ravel/collect.js': ^5.3.3 '@h3ravel/musket': ^0.6.8 h3: 2.0.1-rc.5 From 0be5473c90c871fdcced4ba377d2e6d45cbb2066 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 7 Jan 2026 18:41:19 +0100 Subject: [PATCH 15/28] feat: update package versions for @h3ravel/contracts and @h3ravel/support --- packages/contracts/package.json | 2 +- packages/support/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 6201c3bb..2d39ab36 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/contracts", - "version": "0.27.7", + "version": "0.28.0", "description": "H3ravel Contracts.", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/support/package.json b/packages/support/package.json index a48942e7..7eba5018 100644 --- a/packages/support/package.json +++ b/packages/support/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/support", - "version": "0.15.6", + "version": "0.16.0", "description": "Shared helpers, facades and utilities for H3ravel.", "type": "module", "main": "./dist/index.cjs", From 1d8e3b416dee9559ed5b0debb9b2f58eaccfbf81 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 7 Jan 2026 20:46:53 +0100 Subject: [PATCH 16/28] feat: refactor dependency injection and internal method handling across the framework --- .../foundation/src/Container/Decorators.ts | 17 +---------- packages/foundation/src/Http/Kernel.ts | 2 -- packages/router/src/Router.ts | 4 +-- packages/shared/src/Container.ts | 28 ++++++++++++++++++- packages/support/src/Facades/Facades.ts | 2 +- 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/foundation/src/Container/Decorators.ts b/packages/foundation/src/Container/Decorators.ts index 3b180103..d248e1a0 100644 --- a/packages/foundation/src/Container/Decorators.ts +++ b/packages/foundation/src/Container/Decorators.ts @@ -1,5 +1,3 @@ -import { INTERNAL_METHODS } from '@h3ravel/shared' - export function Inject (...dependencies: string[]) { return function (target: any) { target.__inject__ = dependencies @@ -35,17 +33,4 @@ export function Injectable (): MethodDecorator & ClassDecorator { // } // } // }) as any -// } - -export const internal = (target: any, propertyKey: string) => { - if (!target[INTERNAL_METHODS]) { - target[INTERNAL_METHODS] = new Set() - } - target[INTERNAL_METHODS].add(propertyKey) -} - -export const isInternal = (instance: any, prop: string) => { - const proto = Object.getPrototypeOf(instance) - const internalSet: Set = proto[INTERNAL_METHODS] - return internalSet?.has(prop) ?? false -} \ No newline at end of file +// } \ No newline at end of file diff --git a/packages/foundation/src/Http/Kernel.ts b/packages/foundation/src/Http/Kernel.ts index cd47aa27..f01ff03c 100644 --- a/packages/foundation/src/Http/Kernel.ts +++ b/packages/foundation/src/Http/Kernel.ts @@ -1,5 +1,3 @@ -// namespace Illuminate\Foundation\Http; - import { Arr, DateTime, InvalidArgumentException } from '@h3ravel/support' import { ConcreteConstructor, IApplication, IBootstraper, IExceptionHandler, IKernel, IMiddleware, IRequest, IResponse, IRouter, MiddlewareIdentifier, MiddlewareList } from '@h3ravel/contracts' diff --git a/packages/router/src/Router.ts b/packages/router/src/Router.ts index e772e058..26480d03 100644 --- a/packages/router/src/Router.ts +++ b/packages/router/src/Router.ts @@ -6,9 +6,9 @@ import { Arr, Collection, isClass, MacroableClass, Str, Stringable, tap } from ' import { IDispatcher } from '@h3ravel/contracts' import { Magic, mix } from '@h3ravel/shared' import { IMiddleware, IRequest, IResponse, IRouter, RouteActions, ActionInput, MiddlewareList, ResponsableType } from '@h3ravel/contracts' -import type { EventHandler, IController, GenericObject, ResourceOptions, ResourceMethod, CallableConstructor, IModel, MiddlewareIdentifier } from '@h3ravel/contracts' +import type { EventHandler, IController, GenericObject, ResourceOptions, ResourceMethod, CallableConstructor, MiddlewareIdentifier } from '@h3ravel/contracts' import { RouteMethod, IResponsable } from '@h3ravel/contracts' -import { internal } from '@h3ravel/foundation' +import { internal } from '@h3ravel/shared' import { Route } from './Route' import { Routing } from './Events/Routing' import { RouteMatched } from './Events/RouteMatched' diff --git a/packages/shared/src/Container.ts b/packages/shared/src/Container.ts index 1046a8d9..9f56228b 100644 --- a/packages/shared/src/Container.ts +++ b/packages/shared/src/Container.ts @@ -1 +1,27 @@ -export const INTERNAL_METHODS = Symbol('internal_methods') \ No newline at end of file +export const INTERNAL_METHODS = Symbol('internal_methods') + +/** + * Decorator to mark class properties as internal + * + * @param target + * @param propertyKey + */ +export const internal = (target: any, propertyKey: string) => { + if (!target[INTERNAL_METHODS]) { + target[INTERNAL_METHODS] = new Set() + } + target[INTERNAL_METHODS].add(propertyKey) +} + +/** + * Checks if a property is decorated with the @internal decorator + * + * @param instance + * @param prop + * @returns + */ +export const isInternal = (instance: any, prop: string) => { + const proto = Object.getPrototypeOf(instance) + const internalSet: Set = proto[INTERNAL_METHODS] + return internalSet?.has(prop) ?? false +} \ No newline at end of file diff --git a/packages/support/src/Facades/Facades.ts b/packages/support/src/Facades/Facades.ts index b8fed951..ce85ba01 100644 --- a/packages/support/src/Facades/Facades.ts +++ b/packages/support/src/Facades/Facades.ts @@ -1,7 +1,7 @@ import { ClassConstructor, ConcreteConstructor, IApplication, IBinding } from '@h3ravel/contracts' import { RuntimeException } from '../Exceptions/RuntimeException' -import { isInternal } from '@h3ravel/foundation' +import { isInternal } from '@h3ravel/shared' export abstract class Facades { /** From e3d58ff29ba961f45cec821fee2278f7f0812977 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 7 Jan 2026 20:54:31 +0100 Subject: [PATCH 17/28] feat: update version numbers for @h3ravel/shared and @h3ravel/support packages --- packages/shared/package.json | 2 +- packages/support/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/package.json b/packages/shared/package.json index 5b105db3..8b5faab4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/shared", - "version": "0.28.0", + "version": "0.28.1", "description": "Shared Utilities.", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/support/package.json b/packages/support/package.json index 7eba5018..6d66625e 100644 --- a/packages/support/package.json +++ b/packages/support/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/support", - "version": "0.16.0", + "version": "0.16.1", "description": "Shared helpers, facades and utilities for H3ravel.", "type": "module", "main": "./dist/index.cjs", From 1c74a1b2716f40b8937cb876df810260a155b26f Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 7 Jan 2026 21:15:54 +0100 Subject: [PATCH 18/28] feat: update version to 0.28.1 and refactor IModel class structure --- packages/contracts/package.json | 5 ++--- packages/contracts/src/Database/IModel.ts | 8 ++------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 2d39ab36..f033d9ff 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/contracts", - "version": "0.28.0", + "version": "0.28.1", "description": "H3ravel Contracts.", "type": "module", "main": "./dist/index.cjs", @@ -63,8 +63,7 @@ "devDependencies": { "edge.js": "catalog:", "simple-body-validator": "catalog:", - "@h3ravel/musket": "catalog:prod", - "@h3ravel/arquebus": "catalog:prod" + "@h3ravel/musket": "catalog:prod" }, "dependencies": { "h3": "catalog:prod" diff --git a/packages/contracts/src/Database/IModel.ts b/packages/contracts/src/Database/IModel.ts index 83691d2c..88d0e2df 100644 --- a/packages/contracts/src/Database/IModel.ts +++ b/packages/contracts/src/Database/IModel.ts @@ -1,8 +1,4 @@ -import { Builder, Model } from '@h3ravel/arquebus' - -import { IQueryBuilder } from '@h3ravel/arquebus/types' - -export abstract class IModel extends Model { +export abstract class IModel { /** * Retrieve the model for a bound value. * @@ -19,7 +15,7 @@ export abstract class IModel extends Model { * @param value * @param field */ - abstract resolveRouteBindingQuery (query: Builder, value: any, field?: undefined | string | null): IQueryBuilder; + abstract resolveRouteBindingQuery (query: any, value: any, field?: undefined | string | null): any; /** * Get the value of the model's route key. From d4a5b0eb63ea3e268f1caae5b0df9dee405d9306 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Thu, 8 Jan 2026 11:00:12 +0100 Subject: [PATCH 19/28] feat: update package versions and refine configuration files --- .../framework/.sessions/undefined.json | 1 - packages/contracts/package.json | 3 +-- packages/shared/package.json | 9 +++---- packages/shared/tsconfig.dist.json | 15 ++++++++++++ packages/shared/tsconfig.json | 24 +------------------ packages/shared/tsdown.config.ts | 5 ---- 6 files changed, 22 insertions(+), 35 deletions(-) delete mode 100644 Users/legacy/Documents/Marx/OpenSource/H3ravel/framework/.sessions/undefined.json create mode 100644 packages/shared/tsconfig.dist.json diff --git a/Users/legacy/Documents/Marx/OpenSource/H3ravel/framework/.sessions/undefined.json b/Users/legacy/Documents/Marx/OpenSource/H3ravel/framework/.sessions/undefined.json deleted file mode 100644 index 88850a57..00000000 --- a/Users/legacy/Documents/Marx/OpenSource/H3ravel/framework/.sessions/undefined.json +++ /dev/null @@ -1 +0,0 @@ -7ee870110f09753f2725e9065149b096:2fab70d9aff4ec1e621b2aaa23d9c09a \ No newline at end of file diff --git a/packages/contracts/package.json b/packages/contracts/package.json index f033d9ff..4e4209c7 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -21,8 +21,7 @@ } }, "files": [ - "dist", - "tsconfig.json" + "dist" ], "publishConfig": { "access": "public", diff --git a/packages/shared/package.json b/packages/shared/package.json index 8b5faab4..b8040216 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/shared", - "version": "0.28.1", + "version": "0.28.2", "description": "Shared Utilities.", "type": "module", "main": "./dist/index.cjs", @@ -11,8 +11,7 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, - "./package.json": "./package.json", - "./tsconfig.json": "./tsconfig.json" + "./package.json": "./package.json" }, "typesVersions": { "*": { @@ -59,7 +58,9 @@ "lint": "eslint . --ext .ts", "test": "jest --passWithNoTests", "release:patch": "pnpm build && pnpm version patch && git add . && git commit -m \"version: bump shared package and publish\" && pnpm publish --tag latest", - "version-patch": "pnpm version patch" + "version-patch": "pnpm version patch", + "prepack": "cp tsconfig.json tsconfig.temp.json && cp tsconfig.dist.json tsconfig.json", + "postpack": "cp tsconfig.temp.json tsconfig.json && rm tsconfig.temp.json" }, "dependencies": { "@inquirer/prompts": "^7.9.0", diff --git a/packages/shared/tsconfig.dist.json b/packages/shared/tsconfig.dist.json new file mode 100644 index 00000000..1dcb8687 --- /dev/null +++ b/packages/shared/tsconfig.dist.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "exclude": ["./dist", "./**/dist", "./node_modules"] +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index dfea10bd..05757340 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,28 +1,6 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { - "paths": { - "@h3ravel/cache": ["../cache/src/index.ts"], - "@h3ravel/events": ["../events/src/index.ts"], - "@h3ravel/config": ["../config/src/index.ts"], - "@h3ravel/console": ["../console/src/index.ts"], - "@h3ravel/core": ["../core/src/index.ts"], - "@h3ravel/database": ["../database/src/index.ts"], - "@h3ravel/filesystem": ["../filesystem/src/index.ts"], - "@h3ravel/hashing": ["../hashing/src/index.ts"], - "@h3ravel/http": ["../http/src/index.ts"], - "@h3ravel/mail": ["../mail/src/index.ts"], - "@h3ravel/queue": ["../queue/src/index.ts"], - "@h3ravel/router": ["../router/src/index.ts"], - "@h3ravel/shared": ["../shared/src/index.ts"], - "@h3ravel/support": ["../support/src/index.ts"], - "@h3ravel/support/facades": ["../support/src/Facades/index.ts"], - "@h3ravel/url": ["../url/src/index.ts"], - "@h3ravel/view": ["../view/src/index.ts"], - "@h3ravel/session": ["../session/src/index.ts"], - "@h3ravel/foundation": ["../foundation/src/index.ts"], - "@h3ravel/validation": ["../validation/src/index.ts"], - "@h3ravel/contracts": ["../contracts/src/index.ts"] - }, "experimentalDecorators": true, "emitDecoratorMetadata": true, "target": "es2022", diff --git a/packages/shared/tsdown.config.ts b/packages/shared/tsdown.config.ts index 81b28e3d..272327a6 100644 --- a/packages/shared/tsdown.config.ts +++ b/packages/shared/tsdown.config.ts @@ -4,11 +4,6 @@ import { defineConfig } from 'tsdown' export default defineConfig([ { ...baseConfig, - exports: { - customExports (exports) { - return Object.assign({}, exports, { './tsconfig.json': './tsconfig.json' }) - }, - }, format: ['esm', 'cjs'], entry: ['src/index.ts'], sourcemap: true, From b9b728ab8517a0fb7a4e6bee020d876477d156cc Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Thu, 8 Jan 2026 11:03:27 +0100 Subject: [PATCH 20/28] feat: update package version to 0.28.3 and remove sourcemap option from tsdown config --- packages/console/tsdown.config.ts | 1 - packages/shared/package.json | 2 +- packages/shared/tsdown.config.ts | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/console/tsdown.config.ts b/packages/console/tsdown.config.ts index 798794d6..4fc3f9b4 100644 --- a/packages/console/tsdown.config.ts +++ b/packages/console/tsdown.config.ts @@ -16,7 +16,6 @@ export default defineConfig([ ...baseConfig, format: ['esm', 'cjs'], entry: ['src/index.ts'], - sourcemap: true, target: 'node22', platform: 'node', }, diff --git a/packages/shared/package.json b/packages/shared/package.json index b8040216..3a6d2014 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/shared", - "version": "0.28.2", + "version": "0.28.3", "description": "Shared Utilities.", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/shared/tsdown.config.ts b/packages/shared/tsdown.config.ts index 272327a6..fa28b47e 100644 --- a/packages/shared/tsdown.config.ts +++ b/packages/shared/tsdown.config.ts @@ -6,7 +6,6 @@ export default defineConfig([ ...baseConfig, format: ['esm', 'cjs'], entry: ['src/index.ts'], - sourcemap: true, target: 'node22', platform: 'node', }, From 7343026f43e7f6c0fb50ec452b3eb01947565bb9 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Thu, 8 Jan 2026 11:53:04 +0100 Subject: [PATCH 21/28] update: fix ts config conflics --- examples/basic-app/.h3ravel/tsconfig.json | 33 +++------- packages/shared/package.json | 11 ++-- packages/shared/src/Utils/scripts.ts | 2 +- ...{tsconfig.dist.json => tsconfig.base.json} | 0 packages/shared/tsdown.config.ts | 6 ++ pnpm-lock.yaml | 60 +++++++++++++++---- pnpm-workspace.yaml | 2 +- 7 files changed, 69 insertions(+), 45 deletions(-) rename packages/shared/{tsconfig.dist.json => tsconfig.base.json} (100%) diff --git a/examples/basic-app/.h3ravel/tsconfig.json b/examples/basic-app/.h3ravel/tsconfig.json index 8cde4893..ee4d4860 100644 --- a/examples/basic-app/.h3ravel/tsconfig.json +++ b/examples/basic-app/.h3ravel/tsconfig.json @@ -1,27 +1,15 @@ { - "extends": "@h3ravel/shared/tsconfig.json", + "extends": "@h3ravel/shared/tsconfig.base.json", "compilerOptions": { "baseUrl": ".", "outDir": "dist", "paths": { - "src/*": [ - "./../src/*" - ], - "App/*": [ - "./../src/app/*" - ], - "root/*": [ - "./../*" - ], - "routes/*": [ - "./../src/routes/*" - ], - "config/*": [ - "./../src/config/*" - ], - "resources/*": [ - "./../src/resources/*" - ] + "src/*": ["./../src/*"], + "App/*": ["./../src/app/*"], + "root/*": ["./../*"], + "routes/*": ["./../src/routes/*"], + "config/*": ["./../src/config/*"], + "resources/*": ["./../src/resources/*"] }, "target": "es2022", "module": "es2022", @@ -35,10 +23,7 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true }, - "include": [ - "./**/*.d.ts", - "./../**/*" - ], + "include": ["./**/*.d.ts", "./../**/*"], "exclude": [ ".", "./../**/console/bin", @@ -56,4 +41,4 @@ "./../jest.config.ts", "./../arquebus.config.js" ] -} \ No newline at end of file +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 3a6d2014..948bdb7c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/shared", - "version": "0.28.3", + "version": "0.28.4", "description": "Shared Utilities.", "type": "module", "main": "./dist/index.cjs", @@ -11,7 +11,8 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./tsconfig.base.json": "./tsconfig.base.json" }, "typesVersions": { "*": { @@ -22,7 +23,7 @@ }, "files": [ "dist", - "tsconfig.json" + "tsconfig.base.json" ], "publishConfig": { "access": "public", @@ -58,9 +59,7 @@ "lint": "eslint . --ext .ts", "test": "jest --passWithNoTests", "release:patch": "pnpm build && pnpm version patch && git add . && git commit -m \"version: bump shared package and publish\" && pnpm publish --tag latest", - "version-patch": "pnpm version patch", - "prepack": "cp tsconfig.json tsconfig.temp.json && cp tsconfig.dist.json tsconfig.json", - "postpack": "cp tsconfig.temp.json tsconfig.json && rm tsconfig.temp.json" + "version-patch": "pnpm version patch" }, "dependencies": { "@inquirer/prompts": "^7.9.0", diff --git a/packages/shared/src/Utils/scripts.ts b/packages/shared/src/Utils/scripts.ts index d6391de1..7a870d09 100644 --- a/packages/shared/src/Utils/scripts.ts +++ b/packages/shared/src/Utils/scripts.ts @@ -1,5 +1,5 @@ export const mainTsconfig = { - extends: '@h3ravel/shared/tsconfig.json', + extends: '@h3ravel/shared/tsconfig.base.json', compilerOptions: { baseUrl: '.', outDir: 'dist', diff --git a/packages/shared/tsconfig.dist.json b/packages/shared/tsconfig.base.json similarity index 100% rename from packages/shared/tsconfig.dist.json rename to packages/shared/tsconfig.base.json diff --git a/packages/shared/tsdown.config.ts b/packages/shared/tsdown.config.ts index fa28b47e..edde6cb1 100644 --- a/packages/shared/tsdown.config.ts +++ b/packages/shared/tsdown.config.ts @@ -4,8 +4,14 @@ import { defineConfig } from 'tsdown' export default defineConfig([ { ...baseConfig, + exports: { + customExports (exports) { + return Object.assign({}, exports, { './tsconfig.base.json': './tsconfig.base.json' }) + }, + }, format: ['esm', 'cjs'], entry: ['src/index.ts'], + sourcemap: false, target: 'node22', platform: 'node', }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f940d87f..294b8257 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,8 +143,8 @@ catalogs: version: 5.1.4 prod: '@h3ravel/arquebus': - specifier: ^0.7.3 - version: 0.7.3 + specifier: ^0.7.4 + version: 0.7.4 '@h3ravel/collect.js': specifier: ^5.3.3 version: 5.3.3 @@ -272,7 +272,7 @@ importers: dependencies: '@h3ravel/arquebus': specifier: catalog:prod - version: 0.7.3(@types/node@24.9.2)(sqlite3@5.1.7) + version: 0.7.4(@types/node@24.9.2)(sqlite3@5.1.7) '@h3ravel/cache': specifier: workspace:^ version: link:../../packages/cache @@ -461,12 +461,9 @@ importers: specifier: catalog:prod version: 2.0.1-rc.5 devDependencies: - '@h3ravel/arquebus': - specifier: catalog:prod - version: 0.7.3(@types/node@24.10.0)(sqlite3@5.1.7) '@h3ravel/musket': specifier: catalog:prod - version: 0.6.8(@h3ravel/support@0.15.6)(@types/node@24.10.0) + version: 0.6.8(@h3ravel/support@0.16.1)(@types/node@24.10.0) edge.js: specifier: 'catalog:' version: 6.3.0 @@ -533,7 +530,7 @@ importers: dependencies: '@h3ravel/arquebus': specifier: catalog:prod - version: 0.7.3(@types/node@24.10.0)(sqlite3@5.1.7) + version: 0.7.4(@types/node@24.10.0)(sqlite3@5.1.7) '@h3ravel/core': specifier: workspace:^ version: link:../core @@ -873,7 +870,7 @@ importers: version: link:../http '@h3ravel/musket': specifier: catalog:prod - version: 0.6.8(@h3ravel/support@0.15.6)(@types/node@24.10.0) + version: 0.6.8(@h3ravel/support@0.16.1)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:* version: link:../shared @@ -1630,14 +1627,17 @@ packages: '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} - '@h3ravel/arquebus@0.7.3': - resolution: {integrity: sha512-C45R/q9YowDU+HzUG08XREx1Wr1M8ayRrH5N78rQJxH1Oz/N3QeLb6zrc5iFMWy4zNscFe44JZa19T9/ByAL2Q==} + '@h3ravel/arquebus@0.7.4': + resolution: {integrity: sha512-/INp32wRy02H+8JQWUohnqJmXANTQGy2hGDdonemZ/X8I4uj3jGb03+dbt/qiBWSNCk6ypLoMJ/feZPf+oSw+w==} engines: {node: '>=14', pnpm: '>=4'} hasBin: true '@h3ravel/collect.js@5.3.3': resolution: {integrity: sha512-mD0BP1KdBVvnh1CYAu9J3eCePHu4Qf6P0hgkg5G5SUCI6TvdxlnDQYvvOomYkW4ojcEnHuKA/1pKa6V7oyDTrg==} + '@h3ravel/contracts@0.28.1': + resolution: {integrity: sha512-Ub2+5rvabNjMvcxDkVWfBBucXLpVDqCX8/rl3QQwbDpcNilZfcnzw1aHfSuk5XpNUBw4zmxYJGlQkuNS3St+TQ==} + '@h3ravel/musket@0.6.8': resolution: {integrity: sha512-OCFIi9lxVvnc1fU0QRWYNvbbwWvhRlDEVD19akXBmRxHaOGlhwJc3oF7Fl5F84gSzfBqgQtANbL33Tsqu81SHg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1650,6 +1650,9 @@ packages: '@h3ravel/support@0.15.6': resolution: {integrity: sha512-fAZvxgXotHczhznZhg83FrQfucfJ8XQNNO1xtVQQ8Z7mOCTA+MLIfni0oSaHaqpPf1xpgfpVaXlEmdFu1xcGoQ==} + '@h3ravel/support@0.16.1': + resolution: {integrity: sha512-zXKP3wRhYuvexSOwWOHqehF5ykvQTGf9KzQuiY6sbHgL3PQ0FRC9vTgUB3z1lFfmu2c39XOSpoGQp4c/CBQZ+g==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -7045,7 +7048,7 @@ snapshots: '@gar/promisify@1.1.3': optional: true - '@h3ravel/arquebus@0.7.3(@types/node@24.10.0)(sqlite3@5.1.7)': + '@h3ravel/arquebus@0.7.4(@types/node@24.10.0)(sqlite3@5.1.7)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.10.0) '@h3ravel/support': 0.15.6 @@ -7076,7 +7079,7 @@ snapshots: - sqlite3 - supports-color - '@h3ravel/arquebus@0.7.3(@types/node@24.9.2)(sqlite3@5.1.7)': + '@h3ravel/arquebus@0.7.4(@types/node@24.9.2)(sqlite3@5.1.7)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.9.2) '@h3ravel/support': 0.15.6 @@ -7109,6 +7112,12 @@ snapshots: '@h3ravel/collect.js@5.3.3': {} + '@h3ravel/contracts@0.28.1': + dependencies: + h3: 2.0.1-rc.5 + transitivePeerDependencies: + - crossws + '@h3ravel/musket@0.6.8(@h3ravel/support@0.15.6)(@types/node@24.10.0)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.10.0) @@ -7126,6 +7135,23 @@ snapshots: - '@types/node' - crossws + '@h3ravel/musket@0.6.8(@h3ravel/support@0.16.1)(@types/node@24.10.0)': + dependencies: + '@h3ravel/shared': 0.27.7(@types/node@24.10.0) + '@h3ravel/support': 0.16.1 + chalk: 5.6.2 + commander: 14.0.2 + dayjs: 1.11.19 + execa: 9.6.0 + glob: 11.0.3 + preferred-pm: 4.1.1 + radashi: 12.7.0 + resolve-from: 5.0.0 + tsx: 4.20.6 + transitivePeerDependencies: + - '@types/node' + - crossws + '@h3ravel/musket@0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.10.0) @@ -7191,6 +7217,14 @@ snapshots: dayjs: 1.11.19 luxon: 3.7.2 + '@h3ravel/support@0.16.1': + dependencies: + '@h3ravel/contracts': 0.28.1 + dayjs: 1.11.19 + luxon: 3.7.2 + transitivePeerDependencies: + - crossws + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d67c7837..c32771bf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -71,7 +71,7 @@ catalog: catalogs: prod: - '@h3ravel/arquebus': ^0.7.3 + '@h3ravel/arquebus': ^0.7.4 '@h3ravel/collect.js': ^5.3.3 '@h3ravel/musket': ^0.6.8 h3: 2.0.1-rc.5 From c89d4a70d7d3e93d78f2ad4a9c446f61a86e26bc Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Thu, 15 Jan 2026 19:24:30 +0100 Subject: [PATCH 22/28] feat: Implement hashing interfaces and session driver - Added IArgonHasher, IBcryptHasher, IBaseHashManager, and IHashManager interfaces for hashing functionalities. - Created HashManagerContract to define hash algorithm configurations. - Introduced ISessionDriver interface for consistent session management across different storage mechanisms. - Developed IRouteUrlGenerator and IUrlGenerator interfaces for URL generation. - Implemented global helper functions for application instance, request, response, session, and URL generation. - Added HashFacade, RequestFacade, and ResponseFacade for easier access to hashing and HTTP functionalities. - Introduced InteractsWithTime trait for handling time-related functionalities. --- .barrelize | 11 +- examples/basic-app/.h3ravel/tsconfig.json | 31 ++- examples/basic-app/package.json | 8 + .../app/Http/Controllers/ProjectController.ts | 25 +- .../app/Http/Controllers/UserController.ts | 10 +- examples/basic-app/src/app/Models/user.ts | 14 +- examples/basic-app/src/bootstrap/app.ts | 8 +- examples/basic-app/src/config/app.ts | 4 + .../basic-app/src/resources/views/index.edge | 6 +- .../src/resources/views/test.form.edge | 14 +- examples/basic-app/src/routes/web.ts | 10 +- packages/config/src/ConfigRepository.ts | 3 +- .../src/Providers/ConfigServiceProvider.ts | 22 +- packages/console/src/IO/zero.ts | 14 +- packages/contracts/src/Core/IApplication.ts | 53 ++++- packages/contracts/src/Core/IContainer.ts | 20 ++ .../contracts/src/Hashing/IAbstractHasher.ts | 11 + .../contracts/src/Hashing/IArgon2idHasher.ts | 45 ++++ .../contracts/src/Hashing/IArgonHasher.ts | 45 ++++ .../contracts/src/Hashing/IBaseHashManager.ts | 38 +++ .../contracts/src/Hashing/IBcryptHasher.ts | 69 ++++++ .../contracts/src/Hashing/IHashManager.ts | 92 +++++++ .../src/Hashing/IHashManagerContract.ts} | 8 +- packages/contracts/src/Http/IHttpRequest.ts | 5 +- packages/contracts/src/Http/IRequest.ts | 2 +- packages/contracts/src/Http/IResponse.ts | 5 + .../src/Routing/ICallableDispatcher.ts | 11 + packages/contracts/src/Session/FlashBag.ts | 24 +- .../src/Session/ISessionDriver.ts} | 75 +++--- .../contracts/src/Session/ISessionManager.ts | 60 ++--- .../contracts/src/Session/SessionContract.ts | 174 +------------- .../contracts/src/Url/IRouteUrlGenerator.ts | 45 ++++ packages/contracts/src/Url/IUrlGenerator.ts | 225 ++++++++++++++++++ packages/contracts/src/Url/Utils.ts | 2 +- .../src/Utilities/BindingsContract.ts | 17 +- .../contracts/src/Utilities/PathLoader.ts | 7 + packages/contracts/src/Utilities/Utilities.ts | 2 +- packages/contracts/src/index.ts | 10 + packages/core/src/Application.ts | 157 ++++++++++-- packages/core/src/Container.ts | 148 +++++++++++- .../Contracts/ServiceProviderConstructor.ts | 2 +- packages/core/src/H3ravel.ts | 45 +--- packages/core/src/ProviderRegistry.ts | 1 - .../core/src/Providers/CoreServiceProvider.ts | 5 - packages/core/src/app.globals.d.ts | 84 ------- .../core/tests/single-entry-point.test.ts | 7 +- packages/database/src/Configuration.ts | 2 +- packages/foundation/env.d.ts | 2 + .../src/Bootstrapers/RegisterHelpers.ts | 12 + .../src/Configuration/AppBuilder.ts | 6 +- .../src/Configuration/Middleware.ts | 3 + .../foundation/src/Console/ConsoleKernel.ts | 2 + .../Exceptions/RecordsNotFoundException.ts | 4 +- .../foundation/src/Exceptions/Base/Handler.ts | 2 - packages/foundation/src/Helpers.ts | 153 ++++++++++++ packages/foundation/src/Http/Kernel.ts | 36 +-- packages/foundation/src/app.globals.d.ts | 171 +++++++++++++ packages/foundation/src/index.ts | 2 + packages/hashing/package.json | 2 + packages/hashing/src/Contracts/.gitkeep | 0 .../hashing/src/Drivers/AbstractHasher.ts | 9 +- .../hashing/src/Drivers/Argon2idHasher.ts | 15 +- packages/hashing/src/Drivers/ArgonHasher.ts | 15 +- packages/hashing/src/Drivers/BcryptHasher.ts | 17 +- packages/hashing/src/HashManager.ts | 13 +- packages/hashing/src/Helpers.ts | 18 +- .../src/Providers/HashingServiceProvider.ts | 7 +- packages/hashing/src/Utils/Manager.ts | 8 +- packages/hashing/src/Utils/ParseInfo.ts | 6 +- packages/hashing/src/app.globals.d.ts | 7 - packages/hashing/src/index.ts | 1 - packages/http/src/HttpContext.ts | 10 +- packages/http/src/Middleware/LogRequests.ts | 10 +- .../http/src/Providers/HttpServiceProvider.ts | 2 - packages/http/src/Request.ts | 23 +- packages/http/src/Response.ts | 24 +- packages/http/src/Utilities/HttpRequest.ts | 3 +- packages/http/src/app.globals.d.ts | 39 --- packages/http/src/env.d.ts | 1 + packages/router/package.json | 5 +- packages/router/src/CallableDispatcher.ts | 5 +- .../router/src/Commands/RouteListCommand.ts | 5 +- packages/router/src/ControllerDispatcher.ts | 5 +- packages/router/src/ImplicitRouteBinding.ts | 6 +- packages/router/src/MiddlewareResolver.ts | 12 +- packages/router/src/Pipeline.ts | 14 +- .../src/Providers/RoutingServiceProvider.ts | 72 ++++++ packages/router/src/Route.ts | 16 +- packages/router/src/RouteGroup.ts | 2 +- packages/router/src/RouteRegisterer.ts | 2 +- packages/router/src/RouteUrlGenerator.ts | 105 ++++---- packages/router/src/Router.ts | 7 +- .../src/Traits/RouteDependencyResolver.ts | 5 +- packages/router/src/UrlGenerator.ts | 61 +++-- packages/router/src/index.ts | 1 + packages/router/tests/router.test.ts | 12 +- .../src/Providers/SessionServiceProvider.ts | 14 +- packages/session/src/SessionManager.ts | 60 ++++- packages/session/src/SessionStore.ts | 8 +- packages/session/src/adapters.ts | 10 +- .../session/src/drivers/DatabaseDriver.ts | 17 +- packages/session/src/drivers/Driver.ts | 9 +- packages/session/src/drivers/FileDriver.ts | 4 +- packages/session/src/drivers/MemoryDriver.ts | 8 +- packages/session/src/drivers/RedisDriver.ts | 12 +- packages/session/src/index.ts | 1 - packages/session/tests/file.spec.ts | 4 +- packages/session/tests/memory.spec.ts | 4 +- packages/shared/src/Utils/PathLoader.ts | 8 +- packages/support/package.json | 4 + packages/support/src/Facades/HashFacade.ts | 10 + packages/support/src/Facades/RequestFacade.ts | 10 + .../support/src/Facades/ResponseFacade.ts | 10 + packages/support/src/Facades/index.ts | 3 + packages/support/src/Helpers/Obj.ts | 100 +++++++- packages/support/src/Helpers/Time.ts | 85 ++++++- .../support/src/Traits/InteractsWithTime.ts | 67 ++++++ packages/support/src/Traits/index.ts | 1 + packages/support/tsdown.config.ts | 1 + packages/url/src/app.globals.d.ts | 5 - packages/url/tests/Url.spec.ts | 4 +- .../validation/src/ValidationException.ts | 4 +- packages/validation/src/env.d.ts | 1 + packages/view/package.json | 5 +- .../view/src/Providers/ViewServiceProvider.ts | 9 +- pnpm-lock.yaml | 103 +++++--- pnpm-workspace.yaml | 4 +- tsconfig.base.json | 1 + 128 files changed, 2342 insertions(+), 878 deletions(-) create mode 100644 packages/contracts/src/Hashing/IAbstractHasher.ts create mode 100644 packages/contracts/src/Hashing/IArgon2idHasher.ts create mode 100644 packages/contracts/src/Hashing/IArgonHasher.ts create mode 100644 packages/contracts/src/Hashing/IBaseHashManager.ts create mode 100644 packages/contracts/src/Hashing/IBcryptHasher.ts create mode 100644 packages/contracts/src/Hashing/IHashManager.ts rename packages/{hashing/src/Contracts/ManagerContract.ts => contracts/src/Hashing/IHashManagerContract.ts} (89%) rename packages/{session/src/Contracts/SessionContract.ts => contracts/src/Session/ISessionDriver.ts} (57%) create mode 100644 packages/contracts/src/Url/IRouteUrlGenerator.ts create mode 100644 packages/contracts/src/Url/IUrlGenerator.ts delete mode 100644 packages/core/src/app.globals.d.ts create mode 100644 packages/foundation/env.d.ts create mode 100644 packages/foundation/src/Bootstrapers/RegisterHelpers.ts create mode 100644 packages/foundation/src/Helpers.ts create mode 100644 packages/foundation/src/app.globals.d.ts delete mode 100644 packages/hashing/src/Contracts/.gitkeep delete mode 100644 packages/hashing/src/app.globals.d.ts delete mode 100644 packages/http/src/app.globals.d.ts create mode 100644 packages/http/src/env.d.ts create mode 100644 packages/router/src/Providers/RoutingServiceProvider.ts create mode 100644 packages/support/src/Facades/HashFacade.ts create mode 100644 packages/support/src/Facades/RequestFacade.ts create mode 100644 packages/support/src/Facades/ResponseFacade.ts create mode 100644 packages/support/src/Traits/InteractsWithTime.ts create mode 100644 packages/support/src/Traits/index.ts create mode 100644 packages/validation/src/env.d.ts diff --git a/.barrelize b/.barrelize index e8751a8c..8fe3df78 100644 --- a/.barrelize +++ b/.barrelize @@ -162,7 +162,15 @@ }, { "name": "index.ts", - "root": "packages/support/src/facades", + "root": "packages/support/src/Facades", + "exclude": [ + "**/*.test.ts", + "**/*.d.ts" + ] + }, + { + "name": "index.ts", + "root": "packages/support/src/Traits", "exclude": [ "**/*.test.ts", "**/*.d.ts" @@ -174,6 +182,7 @@ "exclude": [ "**/*.test.ts", "**/*.d.ts", + "**/Traits/*.*", "**/Facades/*.*" ], "exports": { diff --git a/examples/basic-app/.h3ravel/tsconfig.json b/examples/basic-app/.h3ravel/tsconfig.json index ee4d4860..1fefc38d 100644 --- a/examples/basic-app/.h3ravel/tsconfig.json +++ b/examples/basic-app/.h3ravel/tsconfig.json @@ -4,12 +4,24 @@ "baseUrl": ".", "outDir": "dist", "paths": { - "src/*": ["./../src/*"], - "App/*": ["./../src/app/*"], - "root/*": ["./../*"], - "routes/*": ["./../src/routes/*"], - "config/*": ["./../src/config/*"], - "resources/*": ["./../src/resources/*"] + "src/*": [ + "./../src/*" + ], + "App/*": [ + "./../src/app/*" + ], + "root/*": [ + "./../*" + ], + "routes/*": [ + "./../src/routes/*" + ], + "config/*": [ + "./../src/config/*" + ], + "resources/*": [ + "./../src/resources/*" + ] }, "target": "es2022", "module": "es2022", @@ -23,7 +35,10 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true }, - "include": ["./**/*.d.ts", "./../**/*"], + "include": [ + "./**/*.d.ts", + "./../**/*" + ], "exclude": [ ".", "./../**/console/bin", @@ -41,4 +56,4 @@ "./../jest.config.ts", "./../arquebus.config.js" ] -} +} \ No newline at end of file diff --git a/examples/basic-app/package.json b/examples/basic-app/package.json index 685bbbad..f5a5a49e 100644 --- a/examples/basic-app/package.json +++ b/examples/basic-app/package.json @@ -4,6 +4,14 @@ "description": "Example H3ravel App.", "type": "module", "private": true, + "autoload": { + "namespaces": { + "App/": "app/", + "Database/Factories/": "database/factories/", + "Database/Seeders/": "database/seeders/" + }, + "files": [] + }, "scripts": { "build": "NODE_ENV=production tsdown --config-loader unconfig -c tsdown.default.config.ts", "serve": "tsx watch ./src/server.ts", diff --git a/examples/basic-app/src/app/Http/Controllers/ProjectController.ts b/examples/basic-app/src/app/Http/Controllers/ProjectController.ts index b13b0796..caae839a 100644 --- a/examples/basic-app/src/app/Http/Controllers/ProjectController.ts +++ b/examples/basic-app/src/app/Http/Controllers/ProjectController.ts @@ -5,8 +5,9 @@ import { Project } from 'src/app/Models/project' import { User } from 'App/Models/user' export class ProjectController extends Controller { + @Injectable() index (user: User) { - return user.toJSON() + return user.getRelated('projects') } @Injectable() @@ -15,30 +16,30 @@ export class ProjectController extends Controller { name: ['required', 'string'], }) - console.log(validate) + console.log(validate, user) return response .setStatusCode(202) - .json({ message: `User ${request.input('name')} created` }) + .json({ message: `Project ${request.input('name')} created` }) } @Injectable() - async show (response: Response, user: User, project: Project) { - console.log(project.user_id, 'response, user') - // console.log(response, user, project.getRelation('user'), 'response, user') - // return response - // .setCache({ max_age: 50011, private: false }) - // .setStatusCode(202) - // .setContent(JSON.stringify({ id: user.id, name: user.name, created_at: user.created_at })) + async show (user: User, project: Project) { + return response() + .setCache({ max_age: 50011, private: false }) + .setStatusCode(202) + .json({ user, project }) } + @Injectable() async update ({ request, response }: HttpContext, user: User, project: Project) { return response .setStatusCode(201) - .json({ message: `User ${request.input('name')} updated`, user, project }) + .json({ message: `Project ${request.input('name')} updated`, user, project }) } + @Injectable() destroy ({ request }: HttpContext, user: User, project: Project) { - return { message: `User ${request.input('id')} deleted`, user, project } + return { message: `Project ${request.input('id')} deleted`, user, project } } } diff --git a/examples/basic-app/src/app/Http/Controllers/UserController.ts b/examples/basic-app/src/app/Http/Controllers/UserController.ts index 23af0b7a..cb2411ec 100644 --- a/examples/basic-app/src/app/Http/Controllers/UserController.ts +++ b/examples/basic-app/src/app/Http/Controllers/UserController.ts @@ -23,11 +23,11 @@ export class UserController extends Controller { @Injectable() async show (response: Response, user: User) { - console.log(response, user, 'response, user') - // return response - // .setCache({ max_age: 50011, private: false }) - // .setStatusCode(202) - // .setContent(JSON.stringify({ id: user.id, name: user.name, created_at: user.created_at })) + + return response + .setCache({ max_age: 50011, private: false }) + .setStatusCode(202) + .setContent(user) } async update ({ request, response }: HttpContext) { diff --git a/examples/basic-app/src/app/Models/user.ts b/examples/basic-app/src/app/Models/user.ts index 991b9996..e81fafcd 100644 --- a/examples/basic-app/src/app/Models/user.ts +++ b/examples/basic-app/src/app/Models/user.ts @@ -1,3 +1,15 @@ import { Model } from '@h3ravel/database' +import { Project } from './project' +import { Relationship } from '@h3ravel/arquebus' -export class User extends Model { protected table: string | null = 'users' } +export class User extends Model { + protected table: string | null = 'users' + protected hidden: string[] = [ + 'password' + ] + + @Relationship + _projects () { + return this.hasMany(Project, 'user_id') + } +} diff --git a/examples/basic-app/src/bootstrap/app.ts b/examples/basic-app/src/bootstrap/app.ts index 515ee063..9fb912c7 100644 --- a/examples/basic-app/src/bootstrap/app.ts +++ b/examples/basic-app/src/bootstrap/app.ts @@ -6,7 +6,7 @@ import providers from 'src/bootstrap/providers' export default class { async bootstrap () { - const app = await h3ravel(providers, process.cwd(), { autoload: true, initialize: false }, async () => undefined) + const app = await h3ravel(providers, process.cwd(), { autoload: true, initialize: false }) this.configure(app) return await app.fire() } @@ -16,9 +16,9 @@ export default class { .withRouting({ web: path.join(process.cwd(), 'src/routes/web.ts'), api: path.join(process.cwd(), 'src/routes/api.ts'), - commands: path.join(process.cwd(), 'src/routes/console.ts'), - channels: path.join(process.cwd(), 'src/routes/channels.ts'), - health: '/up', + // commands: path.join(process.cwd(), 'src/routes/console.ts'), + // channels: path.join(process.cwd(), 'src/routes/channels.ts'), + // health: '/up', }) .withExceptions((exceptions) => { return exceptions diff --git a/examples/basic-app/src/config/app.ts b/examples/basic-app/src/config/app.ts index 0399b849..bddfdd9d 100644 --- a/examples/basic-app/src/config/app.ts +++ b/examples/basic-app/src/config/app.ts @@ -51,6 +51,10 @@ export default () => { url: env('APP_URL', 'http://localhost'), + frontend_url: env('FRONTEND_URL', 'http://localhost:3000'), + + asset_url: env('ASSET_URL'), + /* |-------------------------------------------------------------------------- | Application Timezone diff --git a/examples/basic-app/src/resources/views/index.edge b/examples/basic-app/src/resources/views/index.edge index e143c19e..ea431eda 100644 --- a/examples/basic-app/src/resources/views/index.edge +++ b/examples/basic-app/src/resources/views/index.edge @@ -247,7 +247,7 @@

H3ravel

@@ -373,11 +373,11 @@ Sponsor - H3ravel v{{ app.getVersion('app') }} (TypeScript v{{ app.getVersion('ts') }}) + H3ravel v{{ app().getVersion('app') }} (TypeScript v{{ app().getVersion('ts') }})
- + \ No newline at end of file diff --git a/examples/basic-app/src/resources/views/test.form.edge b/examples/basic-app/src/resources/views/test.form.edge index 3c5b79b9..aa47e3fe 100644 --- a/examples/basic-app/src/resources/views/test.form.edge +++ b/examples/basic-app/src/resources/views/test.form.edge @@ -5,8 +5,18 @@ - - {{await request().old('name')}} +
+ Name: +
+
+ Age: +
+
+ +
+ Failing Validation: +
+ {{JSON.stringify(await request().old( ))}} \ No newline at end of file diff --git a/examples/basic-app/src/routes/web.ts b/examples/basic-app/src/routes/web.ts index ff4c5206..cd5b27de 100644 --- a/examples/basic-app/src/routes/web.ts +++ b/examples/basic-app/src/routes/web.ts @@ -4,7 +4,8 @@ import { MailController } from 'App/Http/Controllers/MailController' import { Route } from '@h3ravel/support/facades' import { UrlExampleController } from 'App/Http/Controllers/UrlExampleController' -// Route.get('/', [HomeController, 'index']) +Route.get('/', [HomeController, 'index']) +Route.get('.well-known/{k1?}/{k2?}', (_, ee, ii) => { console.log(ee, ii) }) Route.get('/mail', [MailController, 'send']) // URL examples Route.get('/url-examples/{id?}', [UrlExampleController, 'index']).name('url.examples') @@ -32,7 +33,7 @@ Route.get('/form', async function () { return await view('test.form') }) -Route.put('/validation', async ({ request, response }: HttpContext) => { +Route.match(['PUT', 'POST'], '/validation', async ({ request, response }: HttpContext) => { const data = await request.validate({ name: ['required', 'string'], age: ['required', 'integer'], @@ -44,7 +45,4 @@ Route.put('/validation', async ({ request, response }: HttpContext) => { message: `User ${data.name} created`, data, }) -}) - - -// console.log(Route.getRoutes())//.getRoutesByMethod()['GET']) \ No newline at end of file +}) \ No newline at end of file diff --git a/packages/config/src/ConfigRepository.ts b/packages/config/src/ConfigRepository.ts index e4f6aff6..a396c4c6 100644 --- a/packages/config/src/ConfigRepository.ts +++ b/packages/config/src/ConfigRepository.ts @@ -35,8 +35,7 @@ export class ConfigRepository { if (!this.loaded) { const configPath = this.app.getPath('config') - - globalThis.env = this.app.make('env') + globalThis.env ??= this.app.make('env') Registerer.register(this.app as never) const files = (await readdir(configPath)).filter((e) => { diff --git a/packages/config/src/Providers/ConfigServiceProvider.ts b/packages/config/src/Providers/ConfigServiceProvider.ts index b0496c35..caecc238 100644 --- a/packages/config/src/Providers/ConfigServiceProvider.ts +++ b/packages/config/src/Providers/ConfigServiceProvider.ts @@ -1,8 +1,7 @@ -/// +/// import { ConfigRepository, EnvLoader } from '..' -import { Bindings } from '@h3ravel/contracts' import { ConfigPublishCommand } from '../Commands/ConfigPublishCommand' import { ServiceProvider } from '@h3ravel/support' @@ -24,7 +23,7 @@ export class ConfigServiceProvider extends ServiceProvider { */ this.app.singleton('env', () => { const env = new EnvLoader(this.app).get - globalThis.env = env + globalThis.env ??= env return env }) @@ -38,22 +37,7 @@ export class ConfigServiceProvider extends ServiceProvider { * Create singleton to load configurations */ this.app.singleton('config', () => { - const config = { - get: (key, def) => repo.get(key as any, def), - set: repo.set - } as Bindings['config'] - - globalThis.config = ((key: string | Record, def: any) => { - if (!key || typeof key === 'string') { - return config.get(key, def) - } - - Object.entries(key).forEach(([key, value]) => { - config.set(key, value) - }) - }) as never - - return config + return repo }) this.app.make('http.app').use(e => { diff --git a/packages/console/src/IO/zero.ts b/packages/console/src/IO/zero.ts index 37891950..0c296f09 100644 --- a/packages/console/src/IO/zero.ts +++ b/packages/console/src/IO/zero.ts @@ -1,5 +1,5 @@ import { FileSystem, mainTsconfig } from '@h3ravel/shared' -import { mkdir, readdir, writeFile } from 'node:fs/promises' +import { mkdir, readdir, rm, writeFile } from 'node:fs/promises' import path, { join } from 'node:path' import { execa } from 'execa' @@ -15,10 +15,14 @@ export default class { const pm = (await preferredPM(process.cwd()))?.name ?? 'npm' const outDir = join(process.env.DIST_DIR ?? DIST_DIR) - if (await FileSystem.fileExists(outDir) && (await readdir(outDir)).length > 0) return - if (!await FileSystem.fileExists(path.join(outDir, 'tsconfig.json'))) { - await mkdir(path.join(outDir.replace('/serve', '')), { recursive: true }) - await writeFile(path.join(outDir.replace('/serve', ''), 'tsconfig.json'), JSON.stringify(mainTsconfig, null, 2)) + try { + if (await FileSystem.fileExists(outDir) && (await readdir(outDir)).length > 0) await rm(outDir, { recursive: true, force: true }) + if (!await FileSystem.fileExists(path.join(outDir, 'tsconfig.json'))) { + await mkdir(path.join(outDir.replace('/serve', '')), { recursive: true }) + await writeFile(path.join(outDir.replace('/serve', ''), 'tsconfig.json'), JSON.stringify(mainTsconfig, null, 2)) + } + } catch (error: any) { + console.log(error.message) } const ENV_VARS = { diff --git a/packages/contracts/src/Core/IApplication.ts b/packages/contracts/src/Core/IApplication.ts index 4823b7ef..11485714 100644 --- a/packages/contracts/src/Core/IApplication.ts +++ b/packages/contracts/src/Core/IApplication.ts @@ -1,6 +1,7 @@ -import type { ConcreteConstructor, IPathName } from '../Utilities/Utilities' +import type { ConcreteConstructor, GenericObject, IPathName } from '../Utilities/Utilities' import type { H3, H3Event } from 'h3' +import { EntryConfig } from '@h3ravel/core' import { IAppBuilder } from '../Configuration/IAppBuilder' import { IBootstraper } from '../Foundation/IBootstraper' import { IContainer } from './IContainer' @@ -11,8 +12,8 @@ import type { PathLoader } from '../Utilities/PathLoader' export abstract class IApplication extends IContainer { abstract paths: PathLoader - context?: (event: H3Event) => Promise - h3Event?: H3Event + abstract context?: (event: H3Event) => Promise + abstract h3Event?: H3Event /** * List of registered console commands */ @@ -97,12 +98,36 @@ export abstract class IApplication extends IContainer { */ abstract booted (callback: (app: this) => void): void + /** + * Throw an HttpException with the given data. + * + * @param code + * @param message + * @param headers + * + * @throws {HttpException} + * @throws {NotFoundHttpException} + */ + abstract abort (code: number, message: string, headers: GenericObject): void + + /** + * Register a terminating callback with the application. + * + * @param callback + */ + abstract terminating (callback: (app: this) => void): this + + /** + * Terminate the application. + */ + abstract terminate (): void + /** * Handle the incoming HTTP request and send the response to the browser. * * @param request */ - abstract handleRequest (event: H3Event): Promise + abstract handleRequest (config?: EntryConfig): Promise /** * Get the URI resolver callback. @@ -167,6 +192,14 @@ export abstract class IApplication extends IContainer { */ abstract hasBeenBootstrapped (): boolean + /** + * Build the http context + * + * @param event + * @param config + */ + abstract buildContext (event: H3Event, config?: EntryConfig, fresh?: boolean): Promise + /** * Save the curretn H3 instance for possible future use. * @@ -187,6 +220,18 @@ export abstract class IApplication extends IContainer { */ abstract getHttpContext (): IHttpContext | undefined + /** + * @param key + */ + abstract getHttpContext (key: K): IHttpContext[K] + + /** + * Get the application namespace. + * + * @throws {RuntimeException} + */ + abstract getNamespace (): string + /** * Get the base path of the app * diff --git a/packages/contracts/src/Core/IContainer.ts b/packages/contracts/src/Core/IContainer.ts index 93b30bec..5bfc0c74 100644 --- a/packages/contracts/src/Core/IContainer.ts +++ b/packages/contracts/src/Core/IContainer.ts @@ -132,6 +132,15 @@ export abstract class IContainer { abstract alias (key: [string | ClassConstructor, any][]): this abstract alias (key: string | ClassConstructor, target: any): this + /** + * Bind a new callback to an abstract's rebind event. + * + * @param abstract + * @param callback + */ + abstract rebinding (key: T | (new (...args: any[]) => Bindings[T]), callback: (app: this, inst: Bindings[T]) => Bindings[T] | void): void + abstract rebinding (key: T | (abstract new (...args: any[]) => Bindings[T]), callback: (app: this, inst: Bindings[T]) => Bindings[T] | void): void + /** * Determine if the given abstract type has been bound. * @@ -159,6 +168,17 @@ export abstract class IContainer { */ abstract resolved (abstract: IBinding | string): boolean + /** + * "Extend" an abstract type in the container. + * + * @param abstract + * @param closure + * + * @throws {InvalidArgumentException} + */ + abstract extend (key: T | (new (...args: any[]) => Bindings[T]), closure: (inst: Bindings[T], app: this) => Bindings[T]): void + abstract extend (key: T | (abstract new (...args: any[]) => Bindings[T]), closure: (inst: Bindings[T], app: this) => Bindings[T]): void + /** * Register an existing instance as shared in the container. * diff --git a/packages/contracts/src/Hashing/IAbstractHasher.ts b/packages/contracts/src/Hashing/IAbstractHasher.ts new file mode 100644 index 00000000..e2950245 --- /dev/null +++ b/packages/contracts/src/Hashing/IAbstractHasher.ts @@ -0,0 +1,11 @@ +import { HashInfo } from './IHashManagerContract' + +export abstract class IAbstractHasher { + /** + * Get information about the given hashed value. + * + * @param hashedValue + * @returns + */ + abstract info (hashedValue: string): HashInfo +} diff --git a/packages/contracts/src/Hashing/IArgon2idHasher.ts b/packages/contracts/src/Hashing/IArgon2idHasher.ts new file mode 100644 index 00000000..5cd35dc4 --- /dev/null +++ b/packages/contracts/src/Hashing/IArgon2idHasher.ts @@ -0,0 +1,45 @@ +import { HashConfiguration, HashInfo } from './IHashManagerContract' + +import { IAbstractHasher } from './IAbstractHasher' + +export abstract class IArgon2idHasher extends IAbstractHasher { + /** + * Hash the given value using Argon2id. + */ + abstract make (value: string, options?: HashConfiguration['argon']): Promise; + + /** + * Check the given plain value against a hash. + */ + abstract check (value: string, hashedValue?: string | null, _options?: HashConfiguration['argon']): Promise; + + /** + * Get information about the given hashed value. + */ + abstract info (hashedValue: string): HashInfo; + + /** + * Check if the given hash needs to be rehashed based on current options. + */ + abstract needsRehash (hashedValue: string, options?: HashConfiguration['argon']): boolean; + + /** + * Verify that the hash configuration does not exceed the configured limits. + */ + abstract verifyConfiguration (hashedValue: string): boolean; + + /** + * Verify the hashed value's options. + */ + protected abstract isUsingValidOptions (hashedValue: string): boolean; + + /** + * Verify the hashed value's algorithm. + */ + protected abstract isUsingCorrectAlgorithm (hashedValue: string): boolean; + + /** + * Extract Argon parameters from the hash. + */ + protected abstract parseInfo (hashedValue: string): Record; +} \ No newline at end of file diff --git a/packages/contracts/src/Hashing/IArgonHasher.ts b/packages/contracts/src/Hashing/IArgonHasher.ts new file mode 100644 index 00000000..7d787ae4 --- /dev/null +++ b/packages/contracts/src/Hashing/IArgonHasher.ts @@ -0,0 +1,45 @@ +import { HashConfiguration, HashInfo } from './IHashManagerContract' + +import { IAbstractHasher } from './IAbstractHasher' + +export abstract class IArgonHasher extends IAbstractHasher { + /** + * Hash the given value using Argon2i. + */ + abstract make (value: string, options?: HashConfiguration['argon']): Promise; + + /** + * Check the given plain value against a hash. + */ + abstract check (value: string, hashedValue?: string | null, _options?: HashConfiguration['argon']): Promise; + + /** + * Get information about the given hashed value. + */ + abstract info (hashedValue: string): HashInfo; + + /** + * Check if the given hash needs to be rehashed based on current options. + */ + abstract needsRehash (hashedValue: string, options?: HashConfiguration['argon']): boolean; + + /** + * Verify that the hash configuration does not exceed the configured limits. + */ + abstract verifyConfiguration (hashedValue: string): boolean; + + /** + * Verify the hashed value's options. + */ + protected abstract isUsingValidOptions (hashedValue: string): boolean; + + /** + * Verify the hashed value's algorithm. + */ + protected abstract isUsingCorrectAlgorithm (hashedValue: string): boolean; + + /** + * Extract Argon parameters from the hash. + */ + protected abstract parseInfo (hashedValue: string): Record; +} diff --git a/packages/contracts/src/Hashing/IBaseHashManager.ts b/packages/contracts/src/Hashing/IBaseHashManager.ts new file mode 100644 index 00000000..167a52b7 --- /dev/null +++ b/packages/contracts/src/Hashing/IBaseHashManager.ts @@ -0,0 +1,38 @@ +import { HashAlgorithm } from './IHashManagerContract' +import { IArgon2idHasher } from './IArgon2idHasher' +import { IArgonHasher } from './IArgonHasher' +import { IBcryptHasher } from './IBcryptHasher' + +export abstract class IBaseHashManager { + abstract driver (): IBcryptHasher | IArgonHasher | IArgon2idHasher; + + abstract createBcryptDriver?(): IBcryptHasher; + + abstract createArgonDriver?(): IArgonHasher; + + abstract createArgon2idDriver?(): IArgon2idHasher; + + /** + * Get the default driver name. + * + * @return string + */ + abstract getDefaultDriver (): HashAlgorithm; + + protected abstract createDriver (driver: HashAlgorithm): IArgonHasher | IArgon2idHasher | IBcryptHasher; + + /** + * Determine if a given string is already hashed. + * + * @param value + * @returns + */ + abstract isHashed (value: string): boolean; + + /** + * Autoload config and initialize library + * + * @returns + */ + abstract init (basePath?: string): Promise; +} \ No newline at end of file diff --git a/packages/contracts/src/Hashing/IBcryptHasher.ts b/packages/contracts/src/Hashing/IBcryptHasher.ts new file mode 100644 index 00000000..5db91e76 --- /dev/null +++ b/packages/contracts/src/Hashing/IBcryptHasher.ts @@ -0,0 +1,69 @@ +import { HashConfiguration, HashInfo } from './IHashManagerContract' + +import { IAbstractHasher } from './IAbstractHasher' + +export abstract class IBcryptHasher extends IAbstractHasher { + /** + * Hash the given value. + * + * @param value + * @param options + */ + abstract make (value: string, options?: HashConfiguration['bcrypt']): Promise; + + /** + * Check the given plain value against a hash. + * + * @param value + * @param hashedValue + * @param options + */ + abstract check (value: string, hashedValue?: string | null, _options?: HashConfiguration['bcrypt']): Promise; + + /** + * Get information about the given hashed value. + * + * @param hashedValue + */ + abstract info (hashedValue: string): HashInfo; + + /** + * Check if the given hash has been hashed using the given options. + * + * @param hashedValue + * @param options + */ + abstract needsRehash (hashedValue: string, options?: HashConfiguration['bcrypt']): boolean; + + /** + * Verify the hashed value's options. + * + * @param hashedValue + * @return + */ + protected abstract isUsingValidOptions (hashedValue: string): boolean; + + /** + * Verifies that the configuration is less than or equal to what is configured. + * + * @private + */ + abstract verifyConfiguration (value: string): boolean; + + /** + * Verify the hashed value's algorithm. + * + * @param hashedValue + * + * @returns + */ + protected abstract isUsingCorrectAlgorithm (hashedValue: string): boolean; + + /** + * Extract the cost value from the options object. + * + * @param options + * @return int + */ + protected abstract cost (options?: HashConfiguration['bcrypt']): number; +} \ No newline at end of file diff --git a/packages/contracts/src/Hashing/IHashManager.ts b/packages/contracts/src/Hashing/IHashManager.ts new file mode 100644 index 00000000..42bea437 --- /dev/null +++ b/packages/contracts/src/Hashing/IHashManager.ts @@ -0,0 +1,92 @@ +import { HashAlgorithm, HashInfo, HashOptions } from './IHashManagerContract' + +import { IArgon2idHasher } from './IArgon2idHasher' +import { IArgonHasher } from './IArgonHasher' +import { IBaseHashManager } from './IBaseHashManager' +import { IBcryptHasher } from './IBcryptHasher' + +export abstract class IHashManager extends IBaseHashManager { + /** + * Create an instance of the Bcrypt hash Driver. + * + * @return BcryptHasher + */ + abstract createBcryptDriver (): IBcryptHasher; + + /** + * Create an instance of the Argon hash Driver. + * + * @return ArgonHasher + */ + abstract createArgonDriver (): IArgonHasher; + + /** + * Create an instance of the Argon2id hash Driver. + * + * @return Argon2idHasher + */ + abstract createArgon2idDriver (): IArgon2idHasher; + + /** + * Hash the given value. + * + * @param value + * @param options + * + * @returns + */ + abstract make (value: string, options?: HashOptions): Promise; + + /** + * Get information about the given hashed value. + * + * @param hashedValue + * @returns + */ + abstract info (hashedValue: string): HashInfo; + + /** + * Check the given plain value against a hash. + * + * @param value + * @param hashedValue + * @param options + * @returns + */ + abstract check (value: string, hashedValue?: string, options?: HashOptions): Promise; + + /** + * Check if the given hash has been hashed using the given options. + * + * @param hashedValue + * @param options + * @returns + */ + abstract needsRehash (hashedValue: string, options?: HashOptions): boolean; + + /** + * Determine if a given string is already hashed. + * + * @param string value + * @returns + */ + abstract isHashed (value: string): boolean; + + /** + * Verifies that the configuration is less than or equal to what is configured. + * + * @param value + * + * @internal + */ + abstract verifyConfiguration (value: string): boolean; + + /** + * Get a driver instance. + * + * @param driver + * + * @throws {InvalidArgumentException} + */ + abstract driver (driver?: HashAlgorithm): IArgonHasher | IArgon2idHasher | IBcryptHasher; +} \ No newline at end of file diff --git a/packages/hashing/src/Contracts/ManagerContract.ts b/packages/contracts/src/Hashing/IHashManagerContract.ts similarity index 89% rename from packages/hashing/src/Contracts/ManagerContract.ts rename to packages/contracts/src/Hashing/IHashManagerContract.ts index a0390513..a4ff84e6 100644 --- a/packages/hashing/src/Contracts/ManagerContract.ts +++ b/packages/contracts/src/Hashing/IHashManagerContract.ts @@ -1,6 +1,6 @@ export type HashAlgorithm = 'bcrypt' | 'argon' | 'argon2id' //| 'argon2i' | 'argon2' | 'unknown' -export interface Configuration { +export interface HashConfiguration { [key: string]: any; /** * Default Hash Driver @@ -39,7 +39,7 @@ export interface Configuration { }, } -export type Options = Partial +export type HashOptions = Partial export interface BcryptOptions { cost: number @@ -51,11 +51,11 @@ export interface Argon2Options { threads: number } -export interface UnknownOptions { +export interface UnknownHashOptions { [key: string]: any } -export interface Info { +export interface HashInfo { algo: number; algoName: HashAlgorithm; diff --git a/packages/contracts/src/Http/IHttpRequest.ts b/packages/contracts/src/Http/IHttpRequest.ts index 5fa7ea22..e351a850 100644 --- a/packages/contracts/src/Http/IHttpRequest.ts +++ b/packages/contracts/src/Http/IHttpRequest.ts @@ -8,6 +8,7 @@ import { IServerBag } from './IServerBag' import { IUrl } from '../Url/IUrl' import { InputBag } from './IInputBag' import { RequestMethod } from '../Utilities/Utilities' +import { RouteParams } from '../Url/Utils' export abstract class IHttpRequest { /** @@ -30,7 +31,7 @@ export abstract class IHttpRequest { /** * Query string parameters (GET). */ - abstract _query: InputBag + abstract _query: RouteParams /** * Server and execution environment parameters */ @@ -63,7 +64,7 @@ export abstract class IHttpRequest { * @param server The SERVER parameters * @param content The raw body data */ - abstract initialize (): Promise; + abstract initialize (): void; /** * Gets a list of content types acceptable by the client browser in preferable order. * @returns {string[]} diff --git a/packages/contracts/src/Http/IRequest.ts b/packages/contracts/src/Http/IRequest.ts index 53122ae7..26b0e7c1 100644 --- a/packages/contracts/src/Http/IRequest.ts +++ b/packages/contracts/src/Http/IRequest.ts @@ -78,7 +78,7 @@ export abstract class IRequest< * @param server The SERVER parameters * @param content The raw body data */ - abstract initialize (): Promise; + abstract initialize (): void; /** * Retrieve all data from the instance (query + body). */ diff --git a/packages/contracts/src/Http/IResponse.ts b/packages/contracts/src/Http/IResponse.ts index 2348c36d..64c5b920 100644 --- a/packages/contracts/src/Http/IResponse.ts +++ b/packages/contracts/src/Http/IResponse.ts @@ -84,6 +84,11 @@ export abstract class IResponse extends IHttpResponse { */ abstract getEvent (): H3Event; abstract getEvent> (key: K): DotNestedValue; + + /** + * Reset the response class to it's defautl + */ + abstract reset (): void } export abstract class IResponsable extends HTTPResponse { diff --git a/packages/contracts/src/Routing/ICallableDispatcher.ts b/packages/contracts/src/Routing/ICallableDispatcher.ts index cbb0a074..78fde1a8 100644 --- a/packages/contracts/src/Routing/ICallableDispatcher.ts +++ b/packages/contracts/src/Routing/ICallableDispatcher.ts @@ -1,2 +1,13 @@ +import { CallableConstructor } from '../Utilities/Utilities' +import { IRoute } from './IRoute' + export abstract class ICallableDispatcher { + /** + * Dispatch a request to a given callback. + * + * @param route + * @param handler + * @param method + */ + abstract dispatch (route: IRoute, handler: CallableConstructor): Promise } \ No newline at end of file diff --git a/packages/contracts/src/Session/FlashBag.ts b/packages/contracts/src/Session/FlashBag.ts index 7575b374..f0282d76 100644 --- a/packages/contracts/src/Session/FlashBag.ts +++ b/packages/contracts/src/Session/FlashBag.ts @@ -1,28 +1,28 @@ -export declare class FlashBag { +export abstract class FlashBag { /** * Flash a value for the next request * * @param key Key to store in flash * @param value Value to be flashed */ - flash (key: string, value: any): void; + abstract flash (key: string, value: any): void; /** * Store a temporary value for the current request only * * @param key Key to store * @param value Value to store */ - now (key: string, value: any): void; + abstract now (key: string, value: any): void; /** * Reflash all current flash data for another request cycle */ - reflash (): void; + abstract reflash (): void; /** * Keep only specific flash keys for the next request * * @param keys Keys to keep */ - keep (keys: string[]): void; + abstract keep (keys: string[]): void; /** * Age flash data at the end of the request * @@ -30,7 +30,7 @@ export declare class FlashBag { * - Moves new flash data to old * - Clears new flash data */ - ageFlashData (): void; + abstract ageFlashData (): void; /** * Get a flash value * @@ -38,34 +38,34 @@ export declare class FlashBag { * @param defaultValue Default value if key doesn't exist * @returns Flash value or default */ - get (key: string, defaultValue?: any): any; + abstract get (key: string, defaultValue?: any): any; /** * Check if a flash key exists * * @param key Key to check * @returns Boolean indicating existence */ - has (key: string): boolean; + abstract has (key: string): boolean; /** * Get all flash data * * @returns Combined flash data */ - all (): Record; + abstract all (): Record; /** * Get all flash data keys * * @returns Combined flash data */ - keys (): string[]; + abstract keys (): string[]; /** * Get the raww flash data * * @returns raw flash data */ - raw (): Record; + abstract raw (): Record; /** * Clear all flash data */ - clear (): void; + abstract clear (): void; } \ No newline at end of file diff --git a/packages/session/src/Contracts/SessionContract.ts b/packages/contracts/src/Session/ISessionDriver.ts similarity index 57% rename from packages/session/src/Contracts/SessionContract.ts rename to packages/contracts/src/Session/ISessionDriver.ts index 07eef2fc..ccc20a52 100644 --- a/packages/session/src/Contracts/SessionContract.ts +++ b/packages/contracts/src/Session/ISessionDriver.ts @@ -1,4 +1,4 @@ -import { FlashBag } from '../FlashBag' +import { FlashBag } from './FlashBag' /** * SessionDriver Interface @@ -6,8 +6,8 @@ import { FlashBag } from '../FlashBag' * All session drivers must implement these methods to ensure * consistency across different storage mechanisms (memory, files, database, redis). */ -export interface SessionDriver { - flashBag: FlashBag +export abstract class ISessionDriver { + abstract flashBag: FlashBag /** * Retrieve a value from the session by key. @@ -15,7 +15,7 @@ export interface SessionDriver { * @param key * @param defaultValue */ - get (key: string, defaultValue?: any): T | Promise + abstract get (key: string, defaultValue?: any): T | Promise /** * Store multiple values in the session. @@ -23,14 +23,14 @@ export interface SessionDriver { * @param key * @param defaultValue */ - set (value: Record): void | Promise + abstract set (value: Record): void | Promise /** * Retrieve all data from the session including flash * * @returns */ - getAll> (): Promise | T + abstract getAll> (): Promise | T /** * Store a value in the session. @@ -38,55 +38,55 @@ export interface SessionDriver { * @param key * @param value */ - put (key: string, value: any): void | Promise + abstract put (key: string, value: any): void | Promise /** * Append a value to an array key * * @param key * @param value - */ - push (key: string, value: any): Promise | void + */ + abstract push (key: string, value: any): Promise | void /** * Remove a key from the session. * * @param key */ - forget (key: string): Promise | void + abstract forget (key: string): Promise | void /** * Determine if a key is present in the session. * * @param key - */ - has (key: string): Promise | boolean + */ + abstract has (key: string): Promise | boolean /** * Determine if a key exists in the session (even if null). * * @param key */ - exists (key: string): Promise | boolean + abstract exists (key: string): Promise | boolean /** * Get all data from the session. */ - all> (): Promise | T + abstract all> (): Promise | T /** * Get only a subset of session keys. * * @param keys */ - only> (keys: string[]): Promise | T + abstract only> (keys: string[]): Promise | T /** * Get all session data except the specified keys. * * @param keys */ - except> (keys: string[]): Promise | T + abstract except> (keys: string[]): Promise | T /** * Get and remove an item from the session. @@ -94,7 +94,7 @@ export interface SessionDriver { * @param key * @param defaultValue */ - pull (key: string, defaultValue?: any): Promise | T + abstract pull (key: string, defaultValue?: any): Promise | T /** * Increment a numeric session value. @@ -102,7 +102,7 @@ export interface SessionDriver { * @param key * @param amount */ - increment (key: string, amount?: number): Promise | number + abstract increment (key: string, amount?: number): Promise | number /** * Decrement a numeric session value. @@ -110,7 +110,7 @@ export interface SessionDriver { * @param key * @param amount */ - decrement (key: string, amount?: number): Promise | number + abstract decrement (key: string, amount?: number): Promise | number /** * Flash a key/value pair for the next request only. @@ -118,19 +118,19 @@ export interface SessionDriver { * @param key * @param value */ - flash (key: string, value: any): Promise | void + abstract flash (key: string, value: any): Promise | void /** * Reflash all current flash data for another request cycle. */ - reflash (): Promise | void + abstract reflash (): Promise | void /** * Keep only specific flash data for another request. * * @param keys */ - keep (keys: string[]): Promise | void + abstract keep (keys: string[]): Promise | void /** * Store data for the current request only (not persisted). @@ -138,49 +138,32 @@ export interface SessionDriver { * @param key * @param value */ - now (key: string, value: any): Promise | void + abstract now (key: string, value: any): Promise | void /** * Regenerate the session ID and optionally persist the data. */ - regenerate (): Promise | void + abstract regenerate (): Promise | void /** * Invalidate the session completely and regenerate ID. */ - invalidate (): Promise | void + abstract invalidate (): Promise | void /** * Determine if an item is not present in the session. * * @param key */ - missing (key: string): Promise | boolean + abstract missing (key: string): Promise | boolean /** * Flush all session data */ - flush (): Promise | void + abstract flush (): Promise | void /** * Age flash data at the end of the request lifecycle. */ - ageFlashData (): Promise | void -} - -export interface DriverOption { - cwd?: string - dir?: string - table?: string - prefix?: string - client?: any - sessionId?: string - sessionDir?: string -} - -/** - * A builder function that returns a SessionDriver for a given sessionId. - * - * The builder receives the sessionId and a driver-specific options bag. - */ -export type DriverBuilder = (sessionId: string, options?: DriverOption) => SessionDriver \ No newline at end of file + abstract ageFlashData (): Promise | void +} \ No newline at end of file diff --git a/packages/contracts/src/Session/ISessionManager.ts b/packages/contracts/src/Session/ISessionManager.ts index b13404ea..b3c0494e 100644 --- a/packages/contracts/src/Session/ISessionManager.ts +++ b/packages/contracts/src/Session/ISessionManager.ts @@ -1,5 +1,7 @@ -import type { DriverOption } from './SessionContract' +import { FlashBag } from './FlashBag' +import { IApplication } from '../Core/IApplication' import type { IHttpContext } from '../Http/IHttpContext' +import { ISessionDriver } from './ISessionDriver' /** * SessionManager @@ -7,85 +9,85 @@ import type { IHttpContext } from '../Http/IHttpContext' * Handles session initialization, ID generation, and encryption. * Each request gets a unique session namespace tied to its ID. */ -export declare class ISessionManager { +export abstract class ISessionManager { + abstract flashBag: FlashBag + /** - * @param ctx - incoming request http context - * @param driverName - registered driver key ('file' | 'database' | 'memory' | 'redis') - * @param driverOptions - optional bag for driver-specific options + * Access the current session ID. */ - constructor(ctx: IHttpContext, driverName: 'file' | 'memory' | 'database' | 'redis', driverOptions: DriverOption) + abstract id (): string; /** - * Access the current session ID. + * Get the current session driver */ - id (): string; + abstract getDriver (): ISessionDriver /** * Retrieve a value from the session * * @param key * @returns */ - get (key: string, defaultValue?: any): Promise | any; + abstract get (key: string, defaultValue?: any): Promise | any; /** * Store a value in the session * * @param key * @param value */ - set (value: Record): Promise | void; + abstract set (value: Record): Promise | void; /** * Store multiple key/value pairs * * @param values */ - put (key: string, value: any): void | Promise; + abstract put (key: string, value: any): void | Promise; /** * Append a value to an array key * * @param key * @param value */ - push (key: string, value: any): void | Promise; + abstract push (key: string, value: any): void | Promise; /** * Remove a key from the session * * @param key */ - forget (key: string): void | Promise; + abstract forget (key: string): void | Promise; /** * Retrieve all session data * * @returns */ - all (): Record | Promise>; + abstract all (): Record | Promise>; /** * Determine if a key exists (even if null). * * @param key * @returns */ - exists (key: string): Promise | boolean; + abstract exists (key: string): Promise | boolean; /** * Determine if a key has a non-null value. * * @param key * @returns */ - has (key: string): Promise | boolean; + abstract has (key: string): Promise | boolean; /** * Get only specific keys. * * @param keys * @returns */ - only (keys: string[]): Record | Promise>; + abstract only (keys: string[]): Record | Promise>; /** * Return all keys except the specified ones. * * @param keys * @returns */ - except (keys: string[]): Record | Promise>; + abstract except (keys: string[]): Record | Promise>; /** * Return and delete a key from the session. * @@ -93,7 +95,7 @@ export declare class ISessionManager { * @param defaultValue * @returns */ - pull (key: string, defaultValue?: any): any; + abstract pull (key: string, defaultValue?: any): any; /** * Increment a numeric value by amount (default 1). * @@ -101,7 +103,7 @@ export declare class ISessionManager { * @param amount * @returns */ - increment (key: string, amount?: number): Promise | number; + abstract increment (key: string, amount?: number): Promise | number; /** * Decrement a numeric value by amount (default 1). * @@ -109,53 +111,53 @@ export declare class ISessionManager { * @param amount * @returns */ - decrement (key: string, amount?: number): number | Promise; + abstract decrement (key: string, amount?: number): number | Promise; /** * Flash a value for next request only. * * @param key * @param value */ - flash (key: string, value: any): void | Promise; + abstract flash (key: string, value: any): void | Promise; /** * Reflash all flash data for one more cycle. * * @returns */ - reflash (): void | Promise; + abstract reflash (): void | Promise; /** * Keep only selected flash data. * * @param keys * @returns */ - keep (keys: string[]): void | Promise; + abstract keep (keys: string[]): void | Promise; /** * Store data only for current request cycle (not persisted). * * @param key * @param value */ - now (key: string, value: any): void | Promise; + abstract now (key: string, value: any): void | Promise; /** * Regenerate session ID and persist data under new ID. */ - regenerate (): void | Promise; + abstract regenerate (): void | Promise; /** * Determine if an item is not present in the session. * * @param key * @returns */ - missing (key: string): Promise | boolean; + abstract missing (key: string): Promise | boolean; /** * Flush all session data */ - flush (): void | Promise; + abstract flush (): void | Promise; /** * Age flash data at the end of the request lifecycle. * * @returns */ - ageFlashData (): void | Promise; + abstract ageFlashData (): void | Promise; } \ No newline at end of file diff --git a/packages/contracts/src/Session/SessionContract.ts b/packages/contracts/src/Session/SessionContract.ts index c2ed8b8b..13427e69 100644 --- a/packages/contracts/src/Session/SessionContract.ts +++ b/packages/contracts/src/Session/SessionContract.ts @@ -1,174 +1,6 @@ -import { FlashBag } from './FlashBag' +import { ISessionDriver } from './ISessionDriver' -/** - * SessionDriver Interface - * - * All session drivers must implement these methods to ensure - * consistency across different storage mechanisms (memory, files, database, redis). - */ -export interface SessionDriver { - flashBag: FlashBag - - /** - * Retrieve a value from the session by key. - * - * @param key - * @param defaultValue - */ - get (key: string, defaultValue?: any): T | Promise - - /** - * Store multiple values in the session. - * - * @param key - * @param defaultValue - */ - set (value: Record): void | Promise - - /** - * Retrieve all data from the session including flash - * - * @returns - */ - getAll> (): Promise | T - - /** - * Store a value in the session. - * - * @param key - * @param value - */ - put (key: string, value: any): void | Promise - - /** - * Append a value to an array key - * - * @param key - * @param value - */ - push (key: string, value: any): Promise | void - - /** - * Remove a key from the session. - * - * @param key - */ - forget (key: string): Promise | void - - /** - * Determine if a key is present in the session. - * - * @param key - */ - has (key: string): Promise | boolean - - /** - * Determine if a key exists in the session (even if null). - * - * @param key - */ - exists (key: string): Promise | boolean - - /** - * Get all data from the session. - */ - all> (): Promise | T - - /** - * Get only a subset of session keys. - * - * @param keys - */ - only> (keys: string[]): Promise | T - - /** - * Get all session data except the specified keys. - * - * @param keys - */ - except> (keys: string[]): Promise | T - - /** - * Get and remove an item from the session. - * - * @param key - * @param defaultValue - */ - pull (key: string, defaultValue?: any): Promise | T - - /** - * Increment a numeric session value. - * - * @param key - * @param amount - */ - increment (key: string, amount?: number): Promise | number - - /** - * Decrement a numeric session value. - * - * @param key - * @param amount - */ - decrement (key: string, amount?: number): Promise | number - - /** - * Flash a key/value pair for the next request only. - * - * @param key - * @param value - */ - flash (key: string, value: any): Promise | void - - /** - * Reflash all current flash data for another request cycle. - */ - reflash (): Promise | void - - /** - * Keep only specific flash data for another request. - * - * @param keys - */ - keep (keys: string[]): Promise | void - - /** - * Store data for the current request only (not persisted). - * - * @param key - * @param value - */ - now (key: string, value: any): Promise | void - - /** - * Regenerate the session ID and optionally persist the data. - */ - regenerate (): Promise | void - - /** - * Invalidate the session completely and regenerate ID. - */ - invalidate (): Promise | void - - /** - * Determine if an item is not present in the session. - * - * @param key - */ - missing (key: string): Promise | boolean - - /** - * Flush all session data - */ - flush (): Promise | void - - /** - * Age flash data at the end of the request lifecycle. - */ - ageFlashData (): Promise | void -} - -export interface DriverOption { +export interface SessionDriverOption { cwd?: string dir?: string table?: string @@ -183,4 +15,4 @@ export interface DriverOption { * * The builder receives the sessionId and a driver-specific options bag. */ -export type DriverBuilder = (sessionId: string, options?: DriverOption) => SessionDriver \ No newline at end of file +export type SessionDriverBuilder = (sessionId: string, options?: SessionDriverOption) => ISessionDriver \ No newline at end of file diff --git a/packages/contracts/src/Url/IRouteUrlGenerator.ts b/packages/contracts/src/Url/IRouteUrlGenerator.ts new file mode 100644 index 00000000..89b75a87 --- /dev/null +++ b/packages/contracts/src/Url/IRouteUrlGenerator.ts @@ -0,0 +1,45 @@ +import { IRoute } from '../Routing/IRoute' +import { RouteParams } from './Utils' + +export abstract class IRouteUrlGenerator { + /** + * The named parameter defaults. + */ + abstract defaultParameters: RouteParams; + + /** + * Characters that should not be URL encoded. + */ + abstract dontEncode: { + '%2F': string; + '%40': string; + '%3A': string; + '%3B': string; + '%2C': string; + '%3D': string; + '%2B': string; + '%21': string; + '%2A': string; + '%7C': string; + '%3F': string; + '%26': string; + '%23': string; + '%25': string; + }; + + /** + * Generate a URL for the given route. + * + * @param route + * @param parameters + * @param absolute + */ + abstract to (route: IRoute, parameters?: RouteParams, absolute?: boolean): string; + + /** + * Set the default named parameters used by the URL generator. + * + * @param $defaults + */ + abstract defaults (defaults: RouteParams): void; +} \ No newline at end of file diff --git a/packages/contracts/src/Url/IUrlGenerator.ts b/packages/contracts/src/Url/IUrlGenerator.ts new file mode 100644 index 00000000..5ab87cc0 --- /dev/null +++ b/packages/contracts/src/Url/IUrlGenerator.ts @@ -0,0 +1,225 @@ +import { CallableConstructor, GenericObject } from '../Utilities/Utilities' + +import { IRequest } from '../Http/IRequest' +import { IRoute } from '../Routing/IRoute' +import { IRouteCollection } from '../Routing/IRouteCollection' +import { RouteParams } from './Utils' +import { UrlRoutable } from '../Routing/Traits/UrlRoutable' + +export abstract class IUrlGenerator { + /** + * The named parameter defaults. + */ + abstract defaultParameters: GenericObject + + /** + * Get the full URL for the current request, + * including the query string. + * + * Example: + * https://example.com/users?page=2 + */ + abstract full (): string; + + /** + * Get the URL for the current request path + * without modifying the query string. + */ + abstract current (): string; + + /** + * Get the URL for the previous request. + * + * Resolution order: + * 1. HTTP Referer header + * 2. Session-stored previous URL + * 3. Fallback (if provided) + * 4. Root "/" + * + * @param fallback Optional fallback path or URL + */ + abstract previous (fallback?: string | false): string; + + /** + * Generate an absolute URL to the given path. + * + * - Accepts relative paths or full URLs + * - Automatically prefixes scheme + host + * - Encodes extra path parameters safely + * + * @param path Relative or absolute path + * @param extra Additional path segments + * @param secure Force HTTPS or HTTP + */ + abstract to (path: string, extra?: (string | number)[], secure?: boolean | null): string; + + /** + * Generate a secure (HTTPS) absolute URL. + * + * @param path + * @param parameters + * @returns + */ + abstract secure (path: string, parameters?: any[]): string; + + /** + * Generate a URL to a public asset. + * + * - Skips URL generation if path is already absolute + * - Removes index.php from root if present + * + * @param path Asset path + * @param secure Force HTTPS + */ + abstract asset (path: string, secure?: boolean | null): string; + + /** + * Generate a secure (HTTPS) asset URL. + * + * @param path + * @returns + */ + abstract secureAsset (path: string): string; + + /** + * Resolve the URL scheme to use. + * + * Priority: + * 1. Explicit `secure` flag + * 2. Forced scheme + * 3. Request scheme (cached) + * + * @param secure + */ + abstract formatScheme (secure?: boolean | null): string; + + /** + * Format the base root URL. + * + * - Applies forced root if present + * - Replaces scheme while preserving host + * - Result is cached per request + * + * @param scheme URL scheme + * @param root Optional custom root + */ + abstract formatRoot (scheme: string, root?: string): string; + + abstract signedRoute (name: string, parameters?: Record, expiration?: number, absolute?: boolean): string; + + abstract hasValidSignature (request: IRequest): boolean; + + abstract route (name: string, parameters?: GenericObject, absolute?: boolean): string; + + /** + * Get the URL for a given route instance. + * + * @param route + * @param parameters + * @param absolute + */ + abstract toRoute (route: IRoute, parameters?: GenericObject, absolute?: boolean): string; + + /** + * Combine root and path into a final URL. + * + * Allows optional host and path formatters + * to modify the output dynamically. + * + * @param root + * @param path + * @param route + * @returns + */ + abstract format (root: string, path: string, route?: IRoute): string; + + /** + * Format the array of URL parameters. + * + * @param parameters + */ + abstract formatParameters (parameters: GenericObject | RouteParams): GenericObject; + + /** + * Determine whether a string is a valid URL. + * + * Supports: + * - Absolute URLs + * - Protocol-relative URLs + * - Anchors and special schemes + * + * @param path + * @returns + */ + abstract isValidUrl (path: string): boolean; + + /** + * Force HTTPS for all generated URLs. + * + * @param force + */ + abstract forceHttps (force?: boolean): void; + + /** + * Set the origin (scheme + host) for generated URLs. + * + * @param root + */ + abstract useOrigin (root?: string): void; + + abstract useAssetOrigin (root?: string): void; + + abstract setKeyResolver (resolver: () => string | string[]): void; + + abstract resolveMissingNamedRoutesUsing (resolver: CallableConstructor): void; + + abstract formatHostUsing (callback: CallableConstructor): this; + + abstract formatPathUsing (callback: CallableConstructor): this; + + /** + * Get the request instance. + */ + abstract getRequest (): IRequest + + /** + * Set the current request instance. + * + * @param request + */ + abstract setRequest (request: IRequest): void; + + /** + * Set the route collection. + * + * @param routes + */ + abstract setRoutes (routes: IRouteCollection): this; + + /** + * Get the route collection. + */ + abstract getRoutes (): IRouteCollection + + /** + * Set the session resolver for the generator. + * + * @param sessionResolver + */ + abstract setSessionResolver (sessionResolver: CallableConstructor): this; + + /** + * Clone a new instance of the URL generator with a different encryption key resolver. + * + * @param keyResolver + */ + abstract withKeyResolver (keyResolver: () => string | string[]): void; + + /** + * Set the default named parameters used by the URL generator. + * + * @param array $defaults + * @return void + */ + abstract defaults (defaults: GenericObject): void; +} \ No newline at end of file diff --git a/packages/contracts/src/Url/Utils.ts b/packages/contracts/src/Url/Utils.ts index c380ee36..8f0a8460 100644 --- a/packages/contracts/src/Url/Utils.ts +++ b/packages/contracts/src/Url/Utils.ts @@ -1 +1 @@ -export type RouteParams = Record \ No newline at end of file +export type RouteParams = Record | N[] | N \ No newline at end of file diff --git a/packages/contracts/src/Utilities/BindingsContract.ts b/packages/contracts/src/Utilities/BindingsContract.ts index 94a73899..d0951a0f 100644 --- a/packages/contracts/src/Utilities/BindingsContract.ts +++ b/packages/contracts/src/Utilities/BindingsContract.ts @@ -3,9 +3,14 @@ import { IResponsable, IResponse } from '../Http/IResponse' import type { Edge } from 'edge.js' import { IDispatcher } from '../Events/IDispatcher' +import { IHashManager } from '../Hashing/IHashManager' import { IHttpContext } from '../Http/IHttpContext' import { IRequest } from '../Http/IRequest' +import { IRouteCollection } from '../Routing/IRouteCollection' import { IRouter } from '../Routing/IRouter' +import { ISessionDriver } from '../Session/ISessionDriver' +import { ISessionManager } from '../Session/ISessionManager' +import { IUrlGenerator } from '../Url/IUrlGenerator' import { PathLoader } from './PathLoader' type RemoveIndexSignature = { @@ -23,25 +28,31 @@ export type Bindings = { db: any env (): NodeJS.ProcessEnv env (key: T, def?: any): any + url: IUrlGenerator view (viewPath: string, params?: Record): Promise edge: Edge; asset (key: string, def?: string): string + hash: IHashManager router: IRouter events: IDispatcher + routes: IRouteCollection config: { get> (): X - get, T extends Extract> (key: T, def?: any): X[T] + get, T extends Extract> (key?: T, def?: any): X[T] set (key: T, value: any): void load?(): any } + session: ISessionManager; 'app.events': IDispatcher + 'hash.driver': ReturnType 'http.app': H3 - 'path.base': string - 'load.paths': PathLoader 'http.serve': typeof serve 'http.context': IHttpContext 'http.request': IRequest 'http.response': IResponse + 'load.paths': PathLoader + 'path.base': string + 'session.store': ISessionDriver } export type UseKey = Record> = keyof RemoveIndexSignature diff --git a/packages/contracts/src/Utilities/PathLoader.ts b/packages/contracts/src/Utilities/PathLoader.ts index e6915521..83900c40 100644 --- a/packages/contracts/src/Utilities/PathLoader.ts +++ b/packages/contracts/src/Utilities/PathLoader.ts @@ -19,4 +19,11 @@ export declare class PathLoader { * @param base - The base path to include to the path */ setPath (name: IPathName, path: string, base?: string): void + + /** + * + * @param path + * @param skipExt + */ + distPath (path: string, skipExt?: boolean): string } diff --git a/packages/contracts/src/Utilities/Utilities.ts b/packages/contracts/src/Utilities/Utilities.ts index 2a50ef42..e7f3b142 100644 --- a/packages/contracts/src/Utilities/Utilities.ts +++ b/packages/contracts/src/Utilities/Utilities.ts @@ -4,7 +4,7 @@ import { MiddlewareList } from '../Foundation/MiddlewareContract' import type { IHttpContext } from '../Http/IHttpContext' -export type IPathName = 'views' | 'routes' | 'assets' | 'base' | 'public' | 'storage' | 'config' | 'database' | 'commands' +export type IPathName = 'app' | 'src' | 'views' | 'routes' | 'assets' | 'base' | 'public' | 'storage' | 'config' | 'database' | 'commands' export type RouterEnd = 'get' | 'delete' | 'put' | 'post' | 'patch' | 'apiResource' | 'group' | 'route' | 'any'; export type RouteMethod = 'GET' | 'HEAD' | 'PUT' | 'PATCH' | 'POST' | 'DELETE' | 'OPTIONS'; export type RequestMethod = 'HEAD' | 'GET' | 'PUT' | 'DELETE' | 'TRACE' | 'OPTIONS' | 'PURGE' | 'POST' | 'CONNECT' | 'PATCH'; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f3e1905d..72ad355b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -12,6 +12,13 @@ export * from './Foundation/IBootstraper' export * from './Foundation/IKernel' export * from './Foundation/MiddlewareContract' export * from './Foundation/RateLimiterAdapter' +export * from './Hashing/IAbstractHasher' +export * from './Hashing/IArgon2idHasher' +export * from './Hashing/IArgonHasher' +export * from './Hashing/IBaseHashManager' +export * from './Hashing/IBcryptHasher' +export * from './Hashing/IHashManager' +export * from './Hashing/IHashManagerContract' export * from './Http/HttpContract' export * from './Http/IFileBag' export * from './Http/IHeaderBag' @@ -41,10 +48,13 @@ export * from './Routing/IRouter' export * from './Routing/IRouteRegistrar' export * from './Routing/Traits/UrlRoutable' export * from './Session/FlashBag' +export * from './Session/ISessionDriver' export * from './Session/ISessionManager' export * from './Session/SessionContract' export * from './Url/IRequestAwareUrl' +export * from './Url/IRouteUrlGenerator' export * from './Url/IUrl' +export * from './Url/IUrlGenerator' export * from './Url/IUrlHelpers' export * from './Url/Utils' export * from './Utilities/BindingsContract' diff --git a/packages/core/src/Application.ts b/packages/core/src/Application.ts index 8d467db8..c9f68935 100644 --- a/packages/core/src/Application.ts +++ b/packages/core/src/Application.ts @@ -3,10 +3,11 @@ import 'reflect-metadata' import { FileSystem, Logger, PathLoader } from '@h3ravel/shared' import { H3, serve, type H3Event } from 'h3' -import { CKernel, ConcreteConstructor, IBootstraper, IKernel, IUrl, type IApplication, type IHttpContext, type IPathName, type IServiceProvider } from '@h3ravel/contracts' -import { InvalidArgumentException, Str } from '@h3ravel/support' +import { IResponse, IUrl, type IApplication, type IHttpContext, type IPathName, type IServiceProvider } from '@h3ravel/contracts' +import { CKernel, ConcreteConstructor, GenericObject, IBootstraper, IKernel, IResponsable } from '@h3ravel/contracts' +import { data_get, InvalidArgumentException, RuntimeException, Str } from '@h3ravel/support' -import { AppBuilder, ConfigException } from '@h3ravel/foundation' +import { AppBuilder, ConfigException, HttpException, NotFoundHttpException, ResponseCodes } from '@h3ravel/foundation' import { Container } from './Container' import { ContainerResolver } from './Manager/ContainerResolver' import { ProviderRegistry } from './ProviderRegistry' @@ -18,6 +19,8 @@ import path from 'node:path' import { readFile } from 'node:fs/promises' import semver from 'semver' import { CoreServiceProvider } from './Providers/CoreServiceProvider' +import { EntryConfig } from './Contracts/H3ravelContract' +import { createRequire } from 'node:module' export class Application extends Container implements IApplication { /** @@ -30,6 +33,7 @@ export class Application extends Container implements IApplication { private tries: number = 0 private basePath: string private versions: { [key: string]: string, app: string, ts: string } = { app: '0.0.0', ts: '0.0.0' } + private namespace?: string private static versions: { [key: string]: string, app: string, ts: string } = { app: '0.0.0', ts: '0.0.0' } private h3App?: H3 @@ -58,6 +62,11 @@ export class Application extends Container implements IApplication { */ protected bootingCallbacks: Array<(app: this) => void> = [] + /** + * The array of terminating callbacks. + */ + protected terminatingCallbacks: Array<(app: this) => void> = [] + /** * Indicates if the application has been bootstrapped before. */ @@ -88,6 +97,7 @@ export class Application extends Container implements IApplication { * Register core bindings into the container */ protected registerBaseBindings () { + Application.setInstance(this) this.bind(Application, () => this) this.bind('path.base', () => this.basePath) this.bind('load.paths', () => this.paths) @@ -347,6 +357,48 @@ export class Application extends Container implements IApplication { } } + /** + * Throw an HttpException with the given data. + * + * @param code + * @param message + * @param headers + * + * @throws {HttpException} + * @throws {NotFoundHttpException} + */ + abort (code: ResponseCodes, message = '', headers: GenericObject = {}): void { + if (code == 404) { + throw new NotFoundHttpException(message, undefined, 0, headers) + } + + throw new HttpException(code, message, undefined, headers) + } + + /** + * Register a terminating callback with the application. + * + * @param callback + */ + terminating (callback: (app: this) => void): this { + this.terminatingCallbacks.push(callback) + + return this + } + + /** + * Terminate the application. + */ + terminate (): void { + let index = 0 + + while (index < this.terminatingCallbacks.length) { + this.call(this.terminatingCallbacks[index]) + + index++ + } + } + /** * Call the booting callbacks for the application. * @@ -365,38 +417,68 @@ export class Application extends Container implements IApplication { /** * Handle the incoming HTTP request and send the response to the browser. * - * @param request + * @param config Configuration option to pass to the initializer */ - async handleRequest (): Promise { + async handleRequest (config?: EntryConfig): Promise { this.h3App?.all('/**', async (event) => { - const context = await this.context!(event) + // Define app context factory + this.context = (event) => this.buildContext(event, config) - const kernel = this.make(IKernel) + this.h3Event = event - if (!this.bound('http.context')) - this.bind('http.context', () => context) + const context = await this.context!(event) - if (!this.bound('http.request')) - this.bind('http.request', () => context.request) + const kernel = this.make(IKernel) - if (!this.bound('http.response')) - this.bind('http.response', () => context.response) + this.bind('http.context', () => context) + this.bind('http.request', () => context.request) + this.bind('http.response', () => context.response) const response = await kernel.handle(context.request) - if (response) - this.bind('http.response', () => response) + if (response) this.bind('http.response', () => response) kernel.terminate(context.request, response!) + let finalResponse: IResponse | IResponsable | undefined + if (response && ['Response', 'JsonResponse'].includes(response.constructor.name)) { - return response.prepare(context.request).send() + finalResponse = response.prepare(context.request).send() } else { - return response + finalResponse = response } + + return finalResponse }) } + /** + * Build the http context + * + * @param event + * @param config + * @returns + */ + async buildContext (event: H3Event, config?: EntryConfig, fresh = false): Promise { + const { HttpContext, Request, Response } = await import('@h3ravel/http') + + event = config?.h3Event ?? event + + // If we’ve already attached the context to this event, reuse it + if (!fresh && (event as any)._h3ravelContext) + return (event as any)._h3ravelContext + + Request.enableHttpMethodParameterOverride() + const ctx = HttpContext.init({ + app: this, + request: await Request.create(event, this), + response: new Response(this, event), + }, event); + + (event as any)._h3ravelContext = ctx + return ctx + } + /** * Handle the incoming Artisan command. */ @@ -582,8 +664,45 @@ export class Application extends Container implements IApplication { /** * Get the HttpContext. */ - getHttpContext (): IHttpContext | undefined { - return this.httpContext + getHttpContext (): IHttpContext | undefined + /** + * @param key + */ + getHttpContext (key: K): IHttpContext[K] + getHttpContext (key?: keyof IHttpContext): any { + return key ? this.httpContext?.[key] : this.httpContext + } + + /** + * Get the application namespace. + * + * @throws {RuntimeException} + */ + getNamespace (): string { + if (this.namespace != null) { + return this.namespace + } + + const require = createRequire(import.meta.url) + + const pkg = require(path.join(process.cwd(), 'package.json')) + for (const [namespace, pathChoice] of Object.entries(data_get(pkg, 'autoload.namespaces'))) { + + if (this.getPath('app', '/') === this.getPath('src', pathChoice as never)) { + return this.namespace = namespace + } + } + + throw new RuntimeException('Unable to detect application namespace.') + } + + /** + * Get the path of the app dir + * + * @returns + */ + path (): string { + return this.getPath('app') } /** diff --git a/packages/core/src/Container.ts b/packages/core/src/Container.ts index 0c83cc03..e616d765 100644 --- a/packages/core/src/Container.ts +++ b/packages/core/src/Container.ts @@ -3,11 +3,17 @@ import { CallableConstructor, IMiddleware, ConcreteConstructor, type IBinding } import { ExtractClassMethods, IContainer, type UseKey, ClassConstructor, type Bindings } from '@h3ravel/contracts' import { MiddlewareHandler } from '@h3ravel/foundation' import { ContainerResolver } from './Manager/ContainerResolver' +import { Application } from '.' export class Container extends IContainer { public bindings = new Map unknown>() public singletons = new Map() public middlewareHandler?: MiddlewareHandler + /** + * The current globally available container (if any). + */ + protected static instance?: Application + /** * All of the before resolving callbacks by class type. */ @@ -19,7 +25,7 @@ export class Container extends IContainer { /** * All of the registered rebound callbacks. */ - protected reboundCallbacks: Record any)[]> = {} + protected reboundCallbacks = new Map any)[]>() /** * The container's shared instances. */ @@ -41,6 +47,11 @@ export class Container extends IContainer { */ protected middlewares = new Map() + /** + * The extension closures for services. + */ + protected extenders = new Map() + /** * Check if the target has any decorators * @@ -190,6 +201,39 @@ export class Container extends IContainer { return Reflect.apply(fn as never, instance, args) } + /** + * Read reflected param types, resolve dependencies from the container and return the result + * + * @param instance + * @param method + */ + resolveParams, M extends ExtractClassMethods> ( + instance: X, + method: M, + ): any[] { + /** + * Get param types for the instance method + */ + const paramTypes: any[] = Reflect.getMetadata('design:paramtypes', instance as never, method as string) || [] + + /** + * Resolve and return the bound dependencies + */ + return paramTypes.filter(e => !!e).map(abstract => { + // if ( + // !abstract || abstract.name === 'Function' || + // abstract.constructor.name === 'AsyncFunction' || + // abstract.constructor.name === 'Function' || typeof abstract === 'object' + // ) return abstract + + if (typeof abstract === 'function' && abstract.toString().startsWith('function Function')) { + return abstract + } + + return this.make(abstract) + }) + } + /** * Resolve the gevein service from the container * @@ -238,6 +282,15 @@ export class Container extends IContainer { ) } + /** + * If we defined any extenders for this type, we'll need to spin through them + * and apply them to the object being built. This allows for the extension + * of services, such as changing configuration or decorating the object. + */ + for (const extender of this.getExtenders(abstract)) { + resolved = extender(resolved, this) + } + if (raiseEvents) this.runAfterResolvingCallbacks(abstract, resolved) @@ -323,6 +376,10 @@ export class Container extends IContainer { dependencies = paramTypes.map((dep) => this.make(dep)) } + if (dependencies.length === 0) { + dependencies = [this] + } + return new ClassType(...dependencies) } @@ -350,6 +407,24 @@ export class Container extends IContainer { return this.aliases.get(abstract) ?? abstract } + /** + * Get the extender callbacks for a given type. + * + * @param abstract + */ + protected getExtenders (abstract: string | IBinding) { + return this.extenders.get(this.getAlias(abstract)) ?? [] + } + + /** + * Remove all of the extender callbacks for a given type. + * + * @param abstract + */ + forgetExtenders (abstract: string | IBinding) { + this.extenders.delete(this.getAlias(abstract)) + } + /** * Set the alias for an abstract. * @@ -368,6 +443,27 @@ export class Container extends IContainer { return this } + /** + * Bind a new callback to an abstract's rebind event. + * + * @param abstract + * @param callback + */ + rebinding (key: T | (new (...args: any[]) => Bindings[T]), callback: (app: this, inst: Bindings[T]) => Bindings[T] | void): void + rebinding (key: T | (abstract new (...args: any[]) => Bindings[T]), callback: (app: this, inst: Bindings[T]) => Bindings[T] | void): void + rebinding ( + abstract: T, + callback: any + ) { + abstract = this.getAlias(abstract) + + this.reboundCallbacks.set(abstract, this.reboundCallbacks.get(abstract)?.concat(callback) ?? [callback]) + + if (this.bound(abstract)) { + return this.make(abstract) + } + } + /** * Determine if the given abstract type has been bound. * @@ -407,6 +503,35 @@ export class Container extends IContainer { return this.resolvedInstances.has(abstract) || this.instances.has(abstract) } + /** + * "Extend" an abstract type in the container. + * + * @param abstract + * @param closure + * + * @throws {InvalidArgumentException} + */ + extend (key: T | (new (...args: any[]) => Bindings[T]), closure: (inst: Bindings[T], app: this) => Bindings[T]): void + extend (key: T | (abstract new (...args: any[]) => Bindings[T]), closure: (inst: Bindings[T], app: this) => Bindings[T]): void + extend ( + abstract: T | (new (...args: any[]) => Bindings[T]), + closure: any + ): void { + abstract = this.getAlias(abstract) + + if (this.instances.has(abstract)) { + this.instances.set(abstract, closure(this.instances.get(abstract), this)) + + this.rebound(abstract) + } else { + this.extenders.set(abstract, this.extenders.get(abstract)?.concat(closure) ?? [closure]) + + if (this.resolved(abstract)) { + this.rebound(abstract) + } + } + } + /** * Register an existing instance as shared in the container. * @@ -445,7 +570,8 @@ export class Container extends IContainer { if (ContainerResolver.isClass(callback)) { return this.make(callback) } - return callback() + + return callback(this) } /** @@ -473,7 +599,7 @@ export class Container extends IContainer { * @param abstract */ protected getReboundCallbacks (abstract: any) { - return this.reboundCallbacks[abstract] ?? [] + return this.reboundCallbacks.get(abstract) ?? [] } /** @@ -496,4 +622,20 @@ export class Container extends IContainer { } } } + + /** + * Get the globally available instance of the container. + */ + static getInstance (): Application { + return this.instance ??= new Application(process.cwd(), 'h3ravel') + } + + /** + * Set the shared instance of the container. + * + * @param container + */ + static setInstance (container?: Application): Application | undefined { + return Container.instance = container + } } diff --git a/packages/core/src/Contracts/ServiceProviderConstructor.ts b/packages/core/src/Contracts/ServiceProviderConstructor.ts index 59c1b6c7..869b4f19 100644 --- a/packages/core/src/Contracts/ServiceProviderConstructor.ts +++ b/packages/core/src/Contracts/ServiceProviderConstructor.ts @@ -1,4 +1,4 @@ -/// +/// import type { Application, ServiceProvider } from '..' diff --git a/packages/core/src/H3ravel.ts b/packages/core/src/H3ravel.ts index 2647ec47..27575e4d 100644 --- a/packages/core/src/H3ravel.ts +++ b/packages/core/src/H3ravel.ts @@ -1,9 +1,9 @@ import { Application, OServiceProvider } from '.' -import { IApplication, IHttpContext } from '@h3ravel/contracts' import { EntryConfig } from './Contracts/H3ravelContract' import { Facades } from '@h3ravel/support/facades' import { H3 } from 'h3' +import { IApplication } from '@h3ravel/contracts' /** * Simple global entry point for H3ravel applications @@ -25,14 +25,7 @@ export const h3ravel = async ( * Configuration option to pass to the initializer */ config: EntryConfig = { initialize: false, autoload: false, filteredProviders: [] }, - /** - * final middleware function to call once the server is fired up - */ - middleware: (ctx: IHttpContext) => Promise = async () => undefined, ): Promise => { - - const { FlashDataMiddleware, HttpContext, LogRequests, Request, Response } = await import('@h3ravel/http') - // Initialize the H3 app instance let h3App: H3 | undefined @@ -55,44 +48,12 @@ export const h3ravel = async ( h3App = app.make('http.app') app.setH3App(h3App) - // Define app context factory - app.context = async (event) => { - event = config.h3Event ?? event - - // If we’ve already attached the context to this event, reuse it - if ((event as any)._h3ravelContext) - return (event as any)._h3ravelContext - - Request.enableHttpMethodParameterOverride() - const ctx = HttpContext.init({ - app, - request: await Request.create(event, app), - response: new Response(app, event), - }, event); - - (event as any)._h3ravelContext = ctx - return ctx - } - app.singleton(IApplication, () => app) - app.singleton('app.globalMiddleware', () => [ - LogRequests, - FlashDataMiddleware - ].map(e => app.make(e))) - - // Initialize the Application Kernel - // const kernel = new Kernel(app) - // // Register kernel with H3 - // h3App.use(async (event) => { - // const resp = await kernel.handle(event, middleware) - // console.log(resp) - // return resp - // }) if (!Facades.getApplication()) { Facades.setApplication(app) } - await app.handleRequest() - void middleware + + await app.handleRequest(config) } catch { if (!h3App && config.h3) { h3App = config.h3 diff --git a/packages/core/src/ProviderRegistry.ts b/packages/core/src/ProviderRegistry.ts index f5a83cbd..3788d83e 100644 --- a/packages/core/src/ProviderRegistry.ts +++ b/packages/core/src/ProviderRegistry.ts @@ -227,7 +227,6 @@ export class ProviderRegistry { if (autoRegister) { for (const manifestPath of manifests) { const pkg = this.getManifest(path.resolve(manifestPath)) - if (pkg.h3ravel?.providers) { providers.push(...await Promise.all( pkg.h3ravel.providers.map( diff --git a/packages/core/src/Providers/CoreServiceProvider.ts b/packages/core/src/Providers/CoreServiceProvider.ts index 5d072aed..9cd5c3c7 100644 --- a/packages/core/src/Providers/CoreServiceProvider.ts +++ b/packages/core/src/Providers/CoreServiceProvider.ts @@ -26,10 +26,5 @@ export class CoreServiceProvider extends ServiceProvider { } boot (): void | Promise { - try { - Object.assign(globalThis, { - asset: this.app.make('asset'), - }) - } catch {/** */ } } } diff --git a/packages/core/src/app.globals.d.ts b/packages/core/src/app.globals.d.ts deleted file mode 100644 index c1a85d65..00000000 --- a/packages/core/src/app.globals.d.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { IResponsable } from '@h3ravel/contracts' - -export { } - -declare global { - /** - * Dump something and kill the process for quick debugging. Based on Laravel's dd() - * - * @param args - */ - function dd (...args: any[]): never - /** - * Dump something but keep the process for quick debugging. Based on Laravel's dump() - * - * @param args - */ - function dump (...args: any[]): void - - /** - * Global env variable - * - * @param path - */ - function env (): NodeJS.ProcessEnv; - function env (key: T, def?: any): any; - - /** - * Load config option - */ - function config> (): X; - function config, T extends Extract> (key: T, def?: any): X[T]; - function config> (key: T): void; - - /** - * Render a view - * - * @param viewPath - * @param params - */ - function view (viewPath: string, params?: Record | undefined): Promise - - /** - * Get static asset - * - * @param asset Name of the asset to serve - * @param def Default asset to serve if asset does not exist - */ - function asset (asset: string, def: string): string - - /** - * Get app path - * - * @param path - */ - function app_path (path?: string): string - - /** - * Get base path - * - * @param path - */ - function base_path (path?: string): string - - /** - * Get public path - * - * @param path - */ - function public_path (path?: string): string - - /** - * Get storage path - * - * @param path - */ - function storage_path (path?: string): string - - /** - * Get the database path - * - * @param path - */ - function database_path (path?: string): string -} diff --git a/packages/core/tests/single-entry-point.test.ts b/packages/core/tests/single-entry-point.test.ts index c4ebd410..7d280bbc 100644 --- a/packages/core/tests/single-entry-point.test.ts +++ b/packages/core/tests/single-entry-point.test.ts @@ -1,6 +1,7 @@ -import { Application, ConfigException } from '@h3ravel/core' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { Application } from '@h3ravel/core' +import { ConfigException } from '@h3ravel/foundation' import { h3ravel } from '@h3ravel/core' let app: Application @@ -33,7 +34,7 @@ describe('Single Entry Point with @h3ravel/http installed', async () => { }) it('can load routes before server is fired', () => { - app.make('router').get('path', () => ({ success: true }), 'path') + app.make('router').get('path', () => ({ success: true })).name('path') expect(app.bindings.get('app.routes')?.()).toMatchObject([{ name: 'path' }]) expect(app.bindings.get('app.routes')?.()).toMatchObject([{ path: 'path' }]) expect(app.bindings.get('app.routes')?.()).toMatchObject([{ method: 'get' }]) diff --git a/packages/database/src/Configuration.ts b/packages/database/src/Configuration.ts index 32872253..f120eb42 100644 --- a/packages/database/src/Configuration.ts +++ b/packages/database/src/Configuration.ts @@ -1,4 +1,4 @@ -/// +/// import { Knex } from 'knex' type TFunction = (...args: any[]) => any diff --git a/packages/foundation/env.d.ts b/packages/foundation/env.d.ts new file mode 100644 index 00000000..50174eeb --- /dev/null +++ b/packages/foundation/env.d.ts @@ -0,0 +1,2 @@ +/// +/// \ No newline at end of file diff --git a/packages/foundation/src/Bootstrapers/RegisterHelpers.ts b/packages/foundation/src/Bootstrapers/RegisterHelpers.ts new file mode 100644 index 00000000..5762842e --- /dev/null +++ b/packages/foundation/src/Bootstrapers/RegisterHelpers.ts @@ -0,0 +1,12 @@ +import { IApplication, IBootstraper } from '@h3ravel/contracts' + +import { Helpers } from '../Helpers' + +export class RegisterHelpers extends IBootstraper { + /** + * Bootstrap application helpers. + */ + bootstrap (app: IApplication) { + Helpers.load(app) + } +} \ No newline at end of file diff --git a/packages/foundation/src/Configuration/AppBuilder.ts b/packages/foundation/src/Configuration/AppBuilder.ts index f88ef65b..d5f1d476 100644 --- a/packages/foundation/src/Configuration/AppBuilder.ts +++ b/packages/foundation/src/Configuration/AppBuilder.ts @@ -2,7 +2,7 @@ import { CKernel, CallableConstructor, IApplication, IExceptionHandler, IKernel, import { ConsoleKernel, ExceptionHandler, Exceptions, Kernel, Middleware } from '..' import { Route } from '@h3ravel/support/facades' -import { Collection, isClass, RouteServiceProvider } from '@h3ravel/support' +import { Collection, isClass, RouteServiceProvider, AssetsServiceProvider } from '@h3ravel/support' import { existsSync, statSync } from 'node:fs' import { Command } from '@h3ravel/musket' @@ -65,7 +65,7 @@ export class AppBuilder { this.app.afterResolving(IKernel, (kernel) => { const middleware = new Middleware(this.app) // TODO: Implement the route() method and use here - .redirectGuestsTo(() => 'route(\'login\')') + .redirectGuestsTo(() => route('login')) if (callback && typeof callback === 'function') { callback(middleware) @@ -127,7 +127,7 @@ export class AppBuilder { RouteServiceProvider.loadRoutesUsing(using) this.app.booting((app) => { - app.registerProviders([RouteServiceProvider]) + app.registerProviders([RouteServiceProvider, AssetsServiceProvider]) }) if (typeof commands === 'string' && existsSync(commands) !== false) { diff --git a/packages/foundation/src/Configuration/Middleware.ts b/packages/foundation/src/Configuration/Middleware.ts index 0d72b342..7dbecf4d 100644 --- a/packages/foundation/src/Configuration/Middleware.ts +++ b/packages/foundation/src/Configuration/Middleware.ts @@ -323,11 +323,14 @@ export class Middleware { const middleware: Record = { 'web': [ + 'LogRequests', 'SubstituteBindings', + 'FlashDataMiddleware', this.authenticatedSessions ? 'auth.session' : null, ].filter(e => e !== null), 'api': [ + 'LogRequests', 'SubstituteBindings', this.apiLimiter ? 'throttle:' + this.apiLimiter : null, ].filter(e => e !== null), diff --git a/packages/foundation/src/Console/ConsoleKernel.ts b/packages/foundation/src/Console/ConsoleKernel.ts index fac68e98..13019e0b 100644 --- a/packages/foundation/src/Console/ConsoleKernel.ts +++ b/packages/foundation/src/Console/ConsoleKernel.ts @@ -10,6 +10,7 @@ import { Injectable } from '..' import { KeyGenerateCommand } from './Commands/KeyGenerateCommand' import { MakeCommand } from './Commands/MakeCommand' import { PostinstallCommand } from './Commands/PostinstallCommand' +import { RegisterHelpers } from '../Bootstrapers/RegisterHelpers' import { Terminating } from '../Core/Events/Terminating' import { altLogo } from './logo' import { createRequire } from 'module' @@ -27,6 +28,7 @@ export class ConsoleKernel extends CKernel { * The bootstrap classes for the application. */ #bootstrappers: ConcreteConstructor[] = [ + RegisterHelpers, RegisterFacades, BootProviders ] diff --git a/packages/foundation/src/Database/Exceptions/RecordsNotFoundException.ts b/packages/foundation/src/Database/Exceptions/RecordsNotFoundException.ts index 42d7d730..754108bd 100644 --- a/packages/foundation/src/Database/Exceptions/RecordsNotFoundException.ts +++ b/packages/foundation/src/Database/Exceptions/RecordsNotFoundException.ts @@ -1,2 +1,4 @@ -export class RecordsNotFoundException extends Error { +import { NotFoundHttpException } from '../../Exceptions/NotFoundHttpException' + +export class RecordsNotFoundException extends NotFoundHttpException { } diff --git a/packages/foundation/src/Exceptions/Base/Handler.ts b/packages/foundation/src/Exceptions/Base/Handler.ts index 830aa9d6..c1a59347 100644 --- a/packages/foundation/src/Exceptions/Base/Handler.ts +++ b/packages/foundation/src/Exceptions/Base/Handler.ts @@ -1,5 +1,3 @@ -/// - import type { ExceptionConditionCallback, ExceptionConstructor, IHttpContext, IRequest, IResponse, RateLimiterAdapter, LimitSpec } from '@h3ravel/contracts' import { IExceptionHandler, type RenderExceptionCallback, type ReportExceptionCallback, type ThrottleExceptionCallback } from '@h3ravel/contracts' diff --git a/packages/foundation/src/Helpers.ts b/packages/foundation/src/Helpers.ts new file mode 100644 index 00000000..542cc579 --- /dev/null +++ b/packages/foundation/src/Helpers.ts @@ -0,0 +1,153 @@ +import { IApplication, IUrlGenerator } from '@h3ravel/contracts' + +export class Helpers { + static app: IApplication + + static load (app: IApplication) { + this.app = app + this.loadHelpers() + } + + /** + * Get the available app instance. + * + * @param key + */ + private static appInstance () { + return (key?: any) => { + if (key) { + return this.app.make(key) + } + + return this.app + } + } + + /** + * Get an instance of the Request class + * + * @returns — a global instance of the Request class. + */ + private static request () { + return () => this.app.make('http.request') + } + + /** + * Get an instance of the Response class + * + * @returns — a global instance of the Response class. + */ + private static response () { + return () => this.app.make('http.response') + } + + /** + * Get an instance of the current session manager + * @param key + * @param defaultValue + * + * @returns — a global instance of the current session manager. + */ + private static session () { + const req = this.request() + + return (...args: any[]) => Reflect.apply(req, req, []).session(...args) + } + + /** + * Get the flashed input from previous request. + * + * @param args + */ + private static old () { + const req = this.request() + + return (...args: any[]) => Reflect.apply(req, req, []).old(args?.[0], args?.[1]) + } + + /** + * Hash the given value against the bcrypt algorithm. + * + * @param value + * @param options + */ + private static bcrypt () { + return (value: string, options: any) => this.app.make('hash').make(value, options) + } + + /** + * Global env variable + * + * @param path + */ + private static env () { + return (...args: any[]) => Reflect.apply(this.app.make('env'), undefined, args) + } + + private static config () { + return ((key?: string | Record, defaultValue?: any) => { + if (!key || typeof key === 'string') { + return this.app.make('config').get(key, defaultValue) + } + + Object.entries(key).forEach(([key, value]) => { + this.app.make('config').set(key, value) + }) + }) + } + + /** + * Generate the URL to a named route. + * + * @param name + * @param parameters + * @param absolute + */ + private static route () { + return (name: string, parameters?: (string | number)[], absolute = true) => { + return this.app.make('url').route(name, parameters, absolute) + } + } + + /** + * Get the evaluated view contents for the given view. + */ + private static view () { + return (...args: any[]) => Reflect.apply(this.app.make('view'), undefined, args) + } + + /** + * Get static asset + */ + private static asset () { + return (...args: any[]) => Reflect.apply(this.app.make('asset'), undefined, args) + } + + private static url () { + return (path?: string, parameters: (string | number)[] = [], secure?: boolean): IUrlGenerator | string => { + if (!path) { + return this.app.make(IUrlGenerator) + } + + return this.app.make(IUrlGenerator).to(path, parameters, secure) + } + } + + /** + * Load all global helpers + */ + private static loadHelpers () { + globalThis.request ??= this.request() + globalThis.response ??= this.response() + globalThis.session ??= this.session() + globalThis.old ??= this.old() + globalThis.bcrypt ??= this.bcrypt() + globalThis.env ??= this.env() + globalThis.config ??= this.config() + globalThis.view = this.view() + globalThis.url = this.url() + globalThis.app ??= this.appInstance() + globalThis.route = this.route() + globalThis.asset = this.asset() + } +} \ No newline at end of file diff --git a/packages/foundation/src/Http/Kernel.ts b/packages/foundation/src/Http/Kernel.ts index f01ff03c..d2072c93 100644 --- a/packages/foundation/src/Http/Kernel.ts +++ b/packages/foundation/src/Http/Kernel.ts @@ -1,18 +1,23 @@ import { Arr, DateTime, InvalidArgumentException } from '@h3ravel/support' -import { ConcreteConstructor, IApplication, IBootstraper, IExceptionHandler, IKernel, IMiddleware, IRequest, IResponse, IRouter, MiddlewareIdentifier, MiddlewareList } from '@h3ravel/contracts' +import { ConcreteConstructor, IApplication, IBootstraper, IExceptionHandler, IKernel, IMiddleware } from '@h3ravel/contracts' +import { IRequest, IResponse, IRouter, MiddlewareIdentifier, MiddlewareList } from '@h3ravel/contracts' +import { mix, use } from '@h3ravel/shared' import { Facades } from '@h3ravel/support/facades' import { Injectable } from '..' +import { InteractsWithTime } from '@h3ravel/support/traits' import { RegisterFacades } from '../Bootstrapers/RegisterFacades' +import { RegisterHelpers } from '../Bootstrapers/RegisterHelpers' import { RequestHandled } from './Events/RequestHandled' import { Terminating } from '../Core/Events/Terminating' @Injectable() -export class Kernel extends IKernel { +export class Kernel extends mix(IKernel, use(InteractsWithTime)) { /** * The bootstrap classes for the application. */ #bootstrappers: ConcreteConstructor[] = [ + RegisterHelpers, RegisterFacades ] @@ -145,7 +150,7 @@ export class Kernel extends IKernel { this.terminateMiddleware(request, response) - // this.app.terminate(); + this.app.terminate() if (!this.#requestStartedAt) return @@ -170,6 +175,7 @@ export class Kernel extends IKernel { } } + response.reset() this.#requestStartedAt = undefined } @@ -207,15 +213,14 @@ export class Kernel extends IKernel { */ public whenRequestLifecycleIsLongerThan (threshold: number | DateTime, handler: (...args: any[]) => any) { //TODO: Pay attention to these + threshold = threshold instanceof DateTime + ? this.secondsUntil(threshold) * 1000 + : threshold - // threshold = threshold instanceof DateTime - // ? this.secondsUntil(threshold) * 1000 - // : threshold - - // this.requestLifecycleDurationHandlers = { - // 'threshold': threshold, - // 'handler': handler, - // } + this.requestLifecycleDurationHandlers.push({ + threshold, + handler, + }) } /** @@ -230,10 +235,10 @@ export class Kernel extends IKernel { */ protected gatherRouteMiddleware (request: IRequest) { // TODO: Pay attention to this - // const route = request.route() - // if (route) { - // return this.router.gatherRouteMiddleware(route) - // } + const route = request.route() + if (route) { + return this.router.gatherRouteMiddleware(route) + } return [] } @@ -432,6 +437,7 @@ export class Kernel extends IKernel { this.router.middlewareGroup(key, middleware) } + // TODO: Pay Attention to these for (const [key, middleware] of Object.entries(this.middlewareAliases)) { // this.router.aliasMiddleware(key, middleware) // console.log(key, middleware, 'key, middleware') diff --git a/packages/foundation/src/app.globals.d.ts b/packages/foundation/src/app.globals.d.ts new file mode 100644 index 00000000..78ece55a --- /dev/null +++ b/packages/foundation/src/app.globals.d.ts @@ -0,0 +1,171 @@ +import { Bindings, GenericObject, IApplication, IRequest, IResponsable, IResponse, ISessionManager, IUrlGenerator, UseKey } from '@h3ravel/contracts' + +export { } + +declare global { + /** + * Get the available Application instance. + */ + function app (): IApplication + /** + * Get the available Application instance. + * + * @param key + */ + function app (key: T): Bindings[T]; + /** + * Get the available Application instance. + * + * @param key + */ + function app any> (key: C): InstanceType; + /** + * Get the available Application instance. + * + * @param key + */ + function app any> (key: F): ReturnType; + + /** + * Dump something and kill the process for quick debugging. Based on Laravel's dd() + * + * @param args + */ + function dd (...args: any[]): never + + /** + * Dump something but keep the process for quick debugging. Based on Laravel's dump() + * + * @param args + */ + function dump (...args: any[]): void + + /** + * Global env variable + * + * @param path + */ + function env (): NodeJS.ProcessEnv; + function env (key: T, def?: any): any; + + /** + * Load config option + */ + function config> (): X; + function config, T extends Extract> (key: T, def?: any): X[T]; + function config> (key: T): void; + + /** + * Generate a URL for the current application instance. + * + * @param path + * @param parameters + * @param secure + */ + function url (path?: string, parameters: (string | number)[] = [], secure?: boolean): IUrlGenerator | string + + /** + * Get the URL to a named route. + * + * @param name + * @param parameters + * @param absolute + * @returns + */ + function route (name: string, parameters: GenericObject = {}, absolute?: boolean): string + + /** + * Get the evaluated view contents for the given view. + * + * @param viewPath + * @param params + */ + function view (viewPath: string, params?: Record | undefined): Promise + + /** + * Get static asset + * + * @param asset Name of the asset to serve + * @param def Default asset to serve if asset does not exist + */ + function asset (asset: string, def: string): string + + /** + * Get an instance of the Request class + * + * @returns a global instance of the Request class. + */ + function request (): IRequest + + /** + * Get an instance of the Response class + * + * @returns a global instance of the Response class. + */ + function response (): IResponse + + /** + * Get the flashed input from previous request + * + * @param key + * @param defaultValue + * @returns + */ + function old (): Promise> + function old (key: string, defaultValue?: any): Promise + + /** + * Get an instance of the current session manager + * + * @param key + * @param defaultValue + * @returns a global instance of the current session manager. + */ + function session | undefined = undefined> (key?: K, defaultValue?: any): K extends undefined + ? ISessionManager + : K extends string + ? any : void | Promise + + /** + * Get app path + * + * @param path + */ + function app_path (path?: string): string + + /** + * Get base path + * + * @param path + */ + function base_path (path?: string): string + + /** + * Get public path + * + * @param path + */ + function public_path (path?: string): string + + /** + * Get storage path + * + * @param path + */ + function storage_path (path?: string): string + + /** + * Get the database path + * + * @param path + */ + function database_path (path?: string): string + + /** + * Hash the given value against the bcrypt algorithm. + * + * @param value + * @param options + */ + function bcrypt (value: string, options?: HashOptions): Promise +} diff --git a/packages/foundation/src/index.ts b/packages/foundation/src/index.ts index c05e0064..7ea1c709 100644 --- a/packages/foundation/src/index.ts +++ b/packages/foundation/src/index.ts @@ -1,6 +1,8 @@ +export * from './Helpers' export * from './Adapters/InMemoryRateLimiter' export * from './Bootstrapers/BootProviders' export * from './Bootstrapers/RegisterFacades' +export * from './Bootstrapers/RegisterHelpers' export * from './Configuration/AppBuilder' export * from './Configuration/Middleware' export * from './Console/ConsoleKernel' diff --git a/packages/hashing/package.json b/packages/hashing/package.json index 2c15d0e7..8021a726 100644 --- a/packages/hashing/package.json +++ b/packages/hashing/package.json @@ -66,6 +66,7 @@ "peerDependencies": { "@h3ravel/core": "workspace:^", "@h3ravel/foundation": "workspace:^", + "@h3ravel/shared": "workspace:^", "@h3ravel/support": "workspace:^" }, "peerDependenciesMeta": { @@ -75,6 +76,7 @@ }, "dependencies": { "argon2": "catalog:", + "@h3ravel/contracts": "workspace:^", "@h3ravel/foundation": "workspace:^" } } \ No newline at end of file diff --git a/packages/hashing/src/Contracts/.gitkeep b/packages/hashing/src/Contracts/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/hashing/src/Drivers/AbstractHasher.ts b/packages/hashing/src/Drivers/AbstractHasher.ts index e9588934..5c8b445a 100644 --- a/packages/hashing/src/Drivers/AbstractHasher.ts +++ b/packages/hashing/src/Drivers/AbstractHasher.ts @@ -1,15 +1,16 @@ -import { Info } from '../Contracts/ManagerContract' +import { HashInfo, IAbstractHasher } from '@h3ravel/contracts' + import { ParseInfo } from '../Utils/ParseInfo' -export abstract class AbstractHasher { +export class AbstractHasher extends IAbstractHasher { /** * Get information about the given hashed value. * * @param hashedValue * @returns */ - public info (hashedValue: string): Info { - let algoName = 'unknown' as Info['algoName'] + public info (hashedValue: string): HashInfo { + let algoName = 'unknown' as HashInfo['algoName'] if (hashedValue.startsWith('$2')) algoName = 'bcrypt' if (hashedValue.startsWith('$argon2id$')) algoName = 'argon2id' diff --git a/packages/hashing/src/Drivers/Argon2idHasher.ts b/packages/hashing/src/Drivers/Argon2idHasher.ts index 8d69540d..3ec82397 100644 --- a/packages/hashing/src/Drivers/Argon2idHasher.ts +++ b/packages/hashing/src/Drivers/Argon2idHasher.ts @@ -1,16 +1,17 @@ -import { Configuration, Info } from '../Contracts/ManagerContract' +import { HashConfiguration, HashInfo, IArgon2idHasher } from '@h3ravel/contracts' import { AbstractHasher } from './AbstractHasher' import { RuntimeException } from '@h3ravel/support' import argon from 'argon2' +import { mix } from '@h3ravel/shared' -export class Argon2idHasher extends AbstractHasher { +export class Argon2idHasher extends mix(AbstractHasher, IArgon2idHasher) { private memory: number = 65536 private verifyAlgorithm: boolean = true private threads: number = 1 private time: number = 4 - constructor(options = {} as Configuration['argon']) { + constructor(options = {} as HashConfiguration['argon']) { super() this.memory = options.memory ?? this.memory this.verifyAlgorithm = options.verify ?? process.env.HASH_VERIFY ?? this.verifyAlgorithm @@ -21,7 +22,7 @@ export class Argon2idHasher extends AbstractHasher { /** * Hash the given value using Argon2id. */ - public async make (value: string, options = {} as Configuration['argon']): Promise { + public async make (value: string, options = {} as HashConfiguration['argon']): Promise { try { return await argon.hash(value, { type: argon.argon2id, @@ -40,7 +41,7 @@ export class Argon2idHasher extends AbstractHasher { public async check ( value: string, hashedValue?: string | null, - _options = {} as Configuration['argon'] + _options = {} as HashConfiguration['argon'] ): Promise { if (!hashedValue || hashedValue.length === 0) { return false @@ -60,14 +61,14 @@ export class Argon2idHasher extends AbstractHasher { /** * Get information about the given hashed value. */ - public info (hashedValue: string): Info { + public info (hashedValue: string): HashInfo { return super.info(hashedValue) } /** * Check if the given hash needs to be rehashed based on current options. */ - public needsRehash (hashedValue: string, options = {} as Configuration['argon']): boolean { + public needsRehash (hashedValue: string, options = {} as HashConfiguration['argon']): boolean { const parsed = this.parseInfo(hashedValue) if (!parsed) return true diff --git a/packages/hashing/src/Drivers/ArgonHasher.ts b/packages/hashing/src/Drivers/ArgonHasher.ts index 5cbe2491..d5038f43 100644 --- a/packages/hashing/src/Drivers/ArgonHasher.ts +++ b/packages/hashing/src/Drivers/ArgonHasher.ts @@ -1,16 +1,17 @@ -import { Configuration, Info } from '../Contracts/ManagerContract' +import { HashConfiguration, HashInfo, IArgonHasher } from '@h3ravel/contracts' import { AbstractHasher } from './AbstractHasher' import { RuntimeException } from '@h3ravel/support' import argon from 'argon2' +import { mix } from '@h3ravel/shared' -export class ArgonHasher extends AbstractHasher { +export class ArgonHasher extends mix(AbstractHasher, IArgonHasher) { private memory: number = 65536 private verifyAlgorithm: boolean = true private threads: number = 1 private time: number = 4 - constructor(options = {} as Configuration['argon']) { + constructor(options = {} as HashConfiguration['argon']) { super() this.memory = options.memory ?? this.memory this.verifyAlgorithm = options.verify ?? process.env.HASH_VERIFY ?? this.verifyAlgorithm @@ -21,7 +22,7 @@ export class ArgonHasher extends AbstractHasher { /** * Hash the given value using Argon2i. */ - public async make (value: string, options = {} as Configuration['argon']): Promise { + public async make (value: string, options = {} as HashConfiguration['argon']): Promise { try { return await argon.hash(value, { type: argon.argon2i, @@ -40,7 +41,7 @@ export class ArgonHasher extends AbstractHasher { public async check ( value: string, hashedValue?: string | null, - _options = {} as Configuration['argon'] + _options = {} as HashConfiguration['argon'] ): Promise { if (!hashedValue || hashedValue.length === 0) { return false @@ -60,14 +61,14 @@ export class ArgonHasher extends AbstractHasher { /** * Get information about the given hashed value. */ - public info (hashedValue: string): Info { + public info (hashedValue: string): HashInfo { return super.info(hashedValue) } /** * Check if the given hash needs to be rehashed based on current options. */ - public needsRehash (hashedValue: string, options = {} as Configuration['argon']): boolean { + public needsRehash (hashedValue: string, options = {} as HashConfiguration['argon']): boolean { const parsed = this.parseInfo(hashedValue) if (!parsed) return true diff --git a/packages/hashing/src/Drivers/BcryptHasher.ts b/packages/hashing/src/Drivers/BcryptHasher.ts index 0c16770d..b30295fa 100644 --- a/packages/hashing/src/Drivers/BcryptHasher.ts +++ b/packages/hashing/src/Drivers/BcryptHasher.ts @@ -1,15 +1,16 @@ -import { Configuration, Info } from '../Contracts/ManagerContract' +import { HashConfiguration, HashInfo, IBcryptHasher } from '@h3ravel/contracts' import { InvalidArgumentException, RuntimeException } from '@h3ravel/support' import { AbstractHasher } from './AbstractHasher' import bcrypt from 'bcryptjs' +import { mix } from '@h3ravel/shared' -export class BcryptHasher extends AbstractHasher { +export class BcryptHasher extends mix(AbstractHasher, IBcryptHasher) { private rounds: number = 12 private verifyAlgorithm: boolean = true private limit: number | null = null - constructor(options = {} as Configuration['bcrypt']) { + constructor(options = {} as HashConfiguration['bcrypt']) { super() this.rounds = options.rounds ?? this.rounds this.verifyAlgorithm = options.verify ?? process.env.HASH_VERIFY ?? this.verifyAlgorithm @@ -24,7 +25,7 @@ export class BcryptHasher extends AbstractHasher { * * @return {String} */ - public async make (value: string, options = {} as Configuration['bcrypt']): Promise { + public async make (value: string, options = {} as HashConfiguration['bcrypt']): Promise { if (this.limit && value.length > this.limit) { throw new InvalidArgumentException(`Value is too long to hash. Value must be less than ${this.limit} bytes`) } @@ -45,7 +46,7 @@ export class BcryptHasher extends AbstractHasher { * @param options * @returns */ - public async check (value: string, hashedValue?: string | null, _options = {} as Configuration['bcrypt']) { + public async check (value: string, hashedValue?: string | null, _options = {} as HashConfiguration['bcrypt']) { if (!hashedValue || hashedValue.length === 0) { return false } @@ -64,7 +65,7 @@ export class BcryptHasher extends AbstractHasher { * * @return {Object} */ - public info (hashedValue: string): Info { + public info (hashedValue: string): HashInfo { return super.info(hashedValue) } @@ -76,7 +77,7 @@ export class BcryptHasher extends AbstractHasher { * * @return {Boolean} */ - public needsRehash (hashedValue: string, options = {} as Configuration['bcrypt']): boolean { + public needsRehash (hashedValue: string, options = {} as HashConfiguration['bcrypt']): boolean { const match = hashedValue.match(/^\$2[aby]?\$(\d+)\$/) if (!match) return true @@ -130,7 +131,7 @@ export class BcryptHasher extends AbstractHasher { * @param options * @return int */ - protected cost (options = {} as Configuration['bcrypt']) { + protected cost (options = {} as HashConfiguration['bcrypt']) { return options.rounds ?? this.rounds } } diff --git a/packages/hashing/src/HashManager.ts b/packages/hashing/src/HashManager.ts index e1ad0031..7c948475 100644 --- a/packages/hashing/src/HashManager.ts +++ b/packages/hashing/src/HashManager.ts @@ -1,12 +1,13 @@ -import { Configuration, HashAlgorithm, Options } from './Contracts/ManagerContract' +import { HashAlgorithm, HashConfiguration, HashOptions, IHashManager } from '@h3ravel/contracts' import { Argon2idHasher } from './Drivers/Argon2idHasher' import { ArgonHasher } from './Drivers/ArgonHasher' import { BcryptHasher } from './Drivers/BcryptHasher' import { InvalidArgumentException } from '@h3ravel/support' import { Manager } from './Utils/Manager' +import { mix } from '@h3ravel/shared' -export class HashManager extends Manager { +export class HashManager extends mix(Manager, IHashManager) { private drivers: { [name: string]: BcryptHasher | ArgonHasher | Argon2idHasher } = {} /** @@ -44,7 +45,7 @@ export class HashManager extends Manager { * * @returns */ - public make (value: string, options: Options = {}) { + public make (value: string, options: HashOptions = {}) { return this.driver().make(value, options as never) } @@ -66,7 +67,7 @@ export class HashManager extends Manager { * @param options * @returns */ - public check (value: string, hashedValue?: string, options: Options = {}) { + public check (value: string, hashedValue?: string, options: HashOptions = {}) { return this.driver().check(value, hashedValue, options as never) } @@ -77,7 +78,7 @@ export class HashManager extends Manager { * @param options * @returns */ - public needsRehash (hashedValue: string, options: Options = {}) { + public needsRehash (hashedValue: string, options: HashOptions = {}) { return this.driver().needsRehash(hashedValue, options as never) } @@ -132,6 +133,6 @@ export class HashManager extends Manager { } } -export const defineConfig = (config: Configuration) => { +export const defineConfig = (config: HashConfiguration) => { return config } diff --git a/packages/hashing/src/Helpers.ts b/packages/hashing/src/Helpers.ts index 27aef7ba..8d8ac73a 100644 --- a/packages/hashing/src/Helpers.ts +++ b/packages/hashing/src/Helpers.ts @@ -1,4 +1,6 @@ -import { Options } from './Contracts/ManagerContract' +import { HashOptions, IHashManager } from '@h3ravel/contracts' + +import { Hash as HashFacade } from '@h3ravel/support/facades' import { RuntimeException } from '@h3ravel/support' export class Hash { @@ -10,7 +12,7 @@ export class Hash { * * @returns */ - public static make (value: string, options: Options = {}) { + public static make (value: string, options: HashOptions = {}) { return this.driver().make(value, options) } @@ -32,7 +34,7 @@ export class Hash { * @param options * @returns */ - public static check (value: string, hashedValue?: string, options: Options = {}) { + public static check (value: string, hashedValue?: string, options: HashOptions = {}) { return this.driver().check(value, hashedValue, options) } @@ -43,7 +45,7 @@ export class Hash { * @param options * @returns */ - public static needsRehash (hashedValue: string, options: Options = {}) { + public static needsRehash (hashedValue: string, options: HashOptions = {}) { return this.driver().needsRehash(hashedValue, options) } @@ -76,12 +78,12 @@ export class Hash { * * @returns * - * @throws InvalidArgumentException + * @throws {RuntimeException} */ - public static driver () { - if (typeof globalThis.Hash === 'undefined') { + public static driver (): IHashManager { + if (typeof Hash === 'undefined') { throw new RuntimeException('The Hash helper is only available on H3ravel, use the HashManager class instead.') } - return globalThis.Hash + return HashFacade } } diff --git a/packages/hashing/src/Providers/HashingServiceProvider.ts b/packages/hashing/src/Providers/HashingServiceProvider.ts index acfc5124..46ce6c18 100644 --- a/packages/hashing/src/Providers/HashingServiceProvider.ts +++ b/packages/hashing/src/Providers/HashingServiceProvider.ts @@ -1,18 +1,15 @@ import { HashManager } from '../HashManager' +import { ServiceProvider } from '@h3ravel/support' /** * Register HashManager. */ -export class HashingServiceProvider { +export class HashingServiceProvider extends ServiceProvider { public static priority = 991 - constructor(private app: any) { } - register () { const manager = new HashManager(this.app.make('config').get('hashing')) - globalThis.Hash = manager - this.app.singleton('hash', () => { return manager }) diff --git a/packages/hashing/src/Utils/Manager.ts b/packages/hashing/src/Utils/Manager.ts index e2cf358b..584c3c20 100644 --- a/packages/hashing/src/Utils/Manager.ts +++ b/packages/hashing/src/Utils/Manager.ts @@ -1,6 +1,6 @@ +import { HashAlgorithm, HashConfiguration, IBaseHashManager } from '@h3ravel/contracts' import { type SnakeToTitleCase, Str, InvalidArgumentException } from '@h3ravel/support' -import type { Configuration, HashAlgorithm } from '../Contracts/ManagerContract' import { BcryptHasher } from '../Drivers/BcryptHasher' import { ArgonHasher } from '../Drivers/ArgonHasher' import { Argon2idHasher } from '../Drivers/Argon2idHasher' @@ -10,8 +10,10 @@ import { ConfigException } from '@h3ravel/foundation' type CreateMethodName = `create${SnakeToTitleCase}Driver` -export abstract class Manager { - constructor(public config = {} as Configuration) { } +export abstract class Manager extends IBaseHashManager { + constructor(public config = {} as HashConfiguration) { + super() + } public abstract driver (): BcryptHasher | ArgonHasher | Argon2idHasher public createBcryptDriver?(): BcryptHasher diff --git a/packages/hashing/src/Utils/ParseInfo.ts b/packages/hashing/src/Utils/ParseInfo.ts index f5dd1baa..f8d17753 100644 --- a/packages/hashing/src/Utils/ParseInfo.ts +++ b/packages/hashing/src/Utils/ParseInfo.ts @@ -1,4 +1,4 @@ -import { HashAlgorithm, Info } from '../Contracts/ManagerContract' +import { HashAlgorithm, HashInfo } from '@h3ravel/contracts' export class ParseInfo { @@ -11,7 +11,7 @@ export class ParseInfo { } public static argon2 (hashed: string) { - const info: Info['options'] = {} + const info: HashInfo['options'] = {} // Example: $argon2id$v=19$m=65536,t=4,p=1$... const parts = hashed.split('$') const params = parts[3] // "m=65536,t=4,p=1" @@ -32,7 +32,7 @@ export class ParseInfo { return info } - public static bcrypt (hashed: string): Info['options'] { + public static bcrypt (hashed: string): HashInfo['options'] { const match = hashed.match(/^\$2[aby]?\$(\d+)\$/) return { cost: match ? parseInt(match[1], 10) : undefined, diff --git a/packages/hashing/src/app.globals.d.ts b/packages/hashing/src/app.globals.d.ts deleted file mode 100644 index 6cbed6e0..00000000 --- a/packages/hashing/src/app.globals.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { HashManager } from '.' - -declare global { - var Hash: HashManager -} - -export { } diff --git a/packages/hashing/src/index.ts b/packages/hashing/src/index.ts index df0b43e8..6e002a56 100644 --- a/packages/hashing/src/index.ts +++ b/packages/hashing/src/index.ts @@ -1,4 +1,3 @@ -export * from './Contracts/ManagerContract' export * from './Drivers/AbstractHasher' export * from './Drivers/Argon2idHasher' export * from './Drivers/ArgonHasher' diff --git a/packages/http/src/HttpContext.ts b/packages/http/src/HttpContext.ts index 816a4f96..040cdfb7 100644 --- a/packages/http/src/HttpContext.ts +++ b/packages/http/src/HttpContext.ts @@ -1,12 +1,14 @@ import { IApplication, IHttpContext, IRequest, IResponse } from '@h3ravel/contracts' +import { FlashDataMiddleware } from './Middleware/FlashDataMiddleware' import type { H3Event } from 'h3' +import { LogRequests } from './Middleware/LogRequests' /** * Represents the HTTP context for a single request lifecycle. * Encapsulates the application instance, request, and response objects. */ -export class HttpContext implements IHttpContext { +export class HttpContext extends IHttpContext { private static contexts = new WeakMap() public event!: H3Event @@ -14,7 +16,11 @@ export class HttpContext implements IHttpContext { public app: IApplication, public request: IRequest, public response: IResponse - ) { } + ) { + super() + this.app.bindMiddleware('LogRequests', LogRequests) + this.app.bindMiddleware('FlashDataMiddleware', FlashDataMiddleware) + } /** * Factory method to create a new HttpContext instance from a context object. diff --git a/packages/http/src/Middleware/LogRequests.ts b/packages/http/src/Middleware/LogRequests.ts index 65bb6c92..483421f1 100644 --- a/packages/http/src/Middleware/LogRequests.ts +++ b/packages/http/src/Middleware/LogRequests.ts @@ -1,21 +1,21 @@ -import { IRequest, IResponse } from '@h3ravel/contracts' - +import { IRequest } from '@h3ravel/contracts' import { Injectable } from '@h3ravel/foundation' import { Logger } from '@h3ravel/shared' import { Middleware } from '../Middleware' +import { Response } from '@h3ravel/support/facades' export class LogRequests extends Middleware { @Injectable() - async handle (request: IRequest, response: IResponse, next: (request: IRequest) => Promise): Promise { + async handle (request: IRequest, next: (request: IRequest) => Promise) { const _next = await next(request) - const code = Number(response.getStatusCode()) + const code = Number(Response.getStatusCode()) const method = request.method().toLowerCase() let color = 'bgRed' if (code < 200) color = 'bgWhite' else if (code >= 200 && code <= 300) color = 'bgBlue' - else if (code >= 300 && code <= 400) color = 'bgOrange' + else if (code >= 300 && code <= 400) color = 'bgYellow' let mColor = 'bgYellow' if (method == 'get') mColor = 'bgBlue' diff --git a/packages/http/src/Providers/HttpServiceProvider.ts b/packages/http/src/Providers/HttpServiceProvider.ts index 96701c05..e33fb917 100644 --- a/packages/http/src/Providers/HttpServiceProvider.ts +++ b/packages/http/src/Providers/HttpServiceProvider.ts @@ -1,5 +1,3 @@ -/// - import { HttpContext, Request, Response } from '..' import { IApplication, IHttpContext, IRequest, IResponse } from '@h3ravel/contracts' diff --git a/packages/http/src/Request.ts b/packages/http/src/Request.ts index 5c10de6b..aa7780b7 100644 --- a/packages/http/src/Request.ts +++ b/packages/http/src/Request.ts @@ -64,10 +64,7 @@ export class Request< ) { const instance = new Request(event, app) await instance.setBody() - await instance.initialize() - globalThis.old = (...args: any[]) => instance.old(args?.[0], args?.[1]) as never - globalThis.request = () => instance - globalThis.session = (...args: any[]) => instance.session(...args) + instance.initialize() return instance } @@ -88,10 +85,6 @@ export class Request< instance.content = event.req.body instance.body = instance.content instance.buildRequirements() - instance.sessionManagerClass = {} as never - globalThis.old = (...args: any[]) => instance.old(args?.[0], args?.[1]) as never - globalThis.request = () => instance - globalThis.session = (...args: any[]) => instance.session(...args) return instance } @@ -427,18 +420,7 @@ export class Request< ? ISessionManager : K extends string ? any : void | Promise { - this.sessionManager ??= new this.sessionManagerClass( - this.context, - config('session.driver', 'file'), - { - cwd: config('session.files'), - sessionDir: '/', - dir: '/', - table: config('session.table'), - prefix: config('database.connections.redis.options.prefix'), - client: config(`database.connections.${config('session.driver', 'file')}.client`), - } - ) + this.sessionManager ??= this.app.make('session') if (typeof key === 'string') { return this.sessionManager.get(key, defaultValue) @@ -661,7 +643,6 @@ export class Request< */ setRouteResolver (callback: () => IRoute) { this.routeResolver = callback - return this } diff --git a/packages/http/src/Response.ts b/packages/http/src/Response.ts index 94a307c3..725f4bf9 100644 --- a/packages/http/src/Response.ts +++ b/packages/http/src/Response.ts @@ -10,6 +10,14 @@ import { ResponseCodes } from '@h3ravel/foundation' export class Response extends HttpResponse implements IResponse { static codes = ResponseCodes + private initializationData = {} as { + app: IApplication + content?: string + event: string | H3Event + status: ResponseCodes + headers: Record + } + /** * The current Http Context */ @@ -27,7 +35,7 @@ export class Response extends HttpResponse implements IResponse { constructor(public app: IApplication, event?: H3Event | string, status: ResponseCodes = 200, headers: Record = {}) { const hasHeaders = Object.entries(headers).length > 0 const content = !(event instanceof H3Event) ? event : '' - event = event instanceof H3Event ? event : app.make('http.context')?.event + event = event instanceof H3Event ? event : app.getHttpContext('event') super(event) @@ -39,7 +47,7 @@ export class Response extends HttpResponse implements IResponse { this.withHeaders(headers) } - globalThis.response = () => this + this.initializationData = { app, event, status, headers, content } } /** @@ -201,4 +209,16 @@ export class Response extends HttpResponse implements IResponse { getEvent> (key?: K): any { return safeDot(this.event, key) } + + /** + * Reset the response class to it's defautl + */ + reset () { + // const { status, headers, content } = this.initializationData + // return this.setStatusCode(200) + // .setContent('') + // .withHeaders({}) + // .expire() + return this + } } diff --git a/packages/http/src/Utilities/HttpRequest.ts b/packages/http/src/Utilities/HttpRequest.ts index 5051c088..aa839669 100644 --- a/packages/http/src/Utilities/HttpRequest.ts +++ b/packages/http/src/Utilities/HttpRequest.ts @@ -182,9 +182,8 @@ export class HttpRequest { * @param server The SERVER parameters * @param content The raw body data */ - public async initialize (): Promise { + public initialize (): void { this.buildRequirements() - this.sessionManagerClass = (await import(('@h3ravel/session'))).SessionManager } protected buildRequirements () { diff --git a/packages/http/src/app.globals.d.ts b/packages/http/src/app.globals.d.ts deleted file mode 100644 index 61781a96..00000000 --- a/packages/http/src/app.globals.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { ISessionManager } from '@h3ravel/contracts' -import type { Request, Response } from '.' - -export { } - -declare global { - /** - * Get the flashed input from previous request - * - * @param key - * @param defaultValue - * @returns - */ - function old (): Promise> - function old (key: string, defaultValue?: any): Promise - /** - * Get an instance of the Request class - * - * @returns a global instance of the Request class. - */ - function request (): Request - /** - * Get an instance of the Response class - * - * @returns a global instance of the Response class. - */ - function response (): Response - /** - * Get an instance of the current session manager - * - * @param key - * @param defaultValue - * @returns a global instance of the current session manager. - */ - function session | undefined = undefined> (key?: K, defaultValue?: any): K extends undefined - ? ISessionManager - : K extends string - ? any : void | Promise -} diff --git a/packages/http/src/env.d.ts b/packages/http/src/env.d.ts new file mode 100644 index 00000000..ce87b764 --- /dev/null +++ b/packages/http/src/env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/packages/router/package.json b/packages/router/package.json index 9dafaab6..11be5d50 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -3,7 +3,9 @@ "version": "1.13.6", "description": "Route facade, decorators and controller system for H3ravel.", "h3ravel": { - "providers": [] + "providers": [ + "RoutingServiceProvider" + ] }, "type": "module", "main": "./dist/index.cjs", @@ -60,7 +62,6 @@ "dependencies": { "h3": "catalog:prod", "@h3ravel/contracts": "workspace:^", - "@h3ravel/core": "workspace:^", "@h3ravel/events": "workspace:^", "@h3ravel/musket": "catalog:prod", "@h3ravel/http": "workspace:^", diff --git a/packages/router/src/CallableDispatcher.ts b/packages/router/src/CallableDispatcher.ts index c8c492c6..8b1934b7 100644 --- a/packages/router/src/CallableDispatcher.ts +++ b/packages/router/src/CallableDispatcher.ts @@ -1,6 +1,5 @@ -import { CallableConstructor, ICallableDispatcher } from '@h3ravel/contracts' +import { CallableConstructor, IApplication, ICallableDispatcher } from '@h3ravel/contracts' -import { Application } from '@h3ravel/core' import { Route } from './Route' import { RouteDependencyResolver } from './Traits/RouteDependencyResolver' import { mix } from '@h3ravel/shared' @@ -11,7 +10,7 @@ export class CallableDispatcher extends mix(ICallableDispatcher, RouteDependency * * @param container The container instance. */ - public constructor(protected container: Application) { + public constructor(protected container: IApplication) { super(container) } diff --git a/packages/router/src/Commands/RouteListCommand.ts b/packages/router/src/Commands/RouteListCommand.ts index 6f0e15d5..1171f57d 100644 --- a/packages/router/src/Commands/RouteListCommand.ts +++ b/packages/router/src/Commands/RouteListCommand.ts @@ -1,10 +1,9 @@ -import { ClassicRouteDefinition, RouteMethod } from '@h3ravel/contracts' +import { ClassicRouteDefinition, IApplication, RouteMethod } from '@h3ravel/contracts' import { Logger, LoggerChalk } from '@h3ravel/shared' -import { Application } from '@h3ravel/core' import { Command } from '@h3ravel/musket' -export class RouteListCommand extends Command { +export class RouteListCommand extends Command { /** * The name and signature of the console command. diff --git a/packages/router/src/ControllerDispatcher.ts b/packages/router/src/ControllerDispatcher.ts index ca171b67..df0ae6ea 100644 --- a/packages/router/src/ControllerDispatcher.ts +++ b/packages/router/src/ControllerDispatcher.ts @@ -1,6 +1,5 @@ -import { IController, IControllerDispatcher, IMiddleware, ResourceMethod, RouteMethod } from '@h3ravel/contracts' +import { IApplication, IController, IControllerDispatcher, IMiddleware, ResourceMethod, RouteMethod } from '@h3ravel/contracts' -import { Application } from '@h3ravel/core' import { Collection } from '@h3ravel/support' import { FiltersControllerMiddleware } from './Traits/FiltersControllerMiddleware' import { Route } from './Route' @@ -16,7 +15,7 @@ export class ControllerDispatcher extends mix( * * @param container The container instance. */ - public constructor(protected container: Application) { + public constructor(protected container: IApplication) { super(container) } diff --git a/packages/router/src/ImplicitRouteBinding.ts b/packages/router/src/ImplicitRouteBinding.ts index 0d21aefa..a5cb8b5c 100644 --- a/packages/router/src/ImplicitRouteBinding.ts +++ b/packages/router/src/ImplicitRouteBinding.ts @@ -1,7 +1,5 @@ -import { GenericObject, IModel, UrlRoutable } from '@h3ravel/contracts' +import { GenericObject, IApplication, IModel, UrlRoutable } from '@h3ravel/contracts' -import { Application } from '@h3ravel/core' -import { Logger } from '@h3ravel/shared' import { ModelNotFoundException } from '@h3ravel/foundation' import { Route } from './Route' import { Str } from '@h3ravel/support' @@ -13,7 +11,7 @@ export class ImplicitRouteBinding { * @param container * @param route */ - public static async resolveForRoute (container: Application, route: Route): Promise { + public static async resolveForRoute (container: IApplication, route: Route): Promise { const parameters = route.getParameters() // Iterate only through parameters that are hinted as Models (UrlRoutable) diff --git a/packages/router/src/MiddlewareResolver.ts b/packages/router/src/MiddlewareResolver.ts index ccf680b7..c2f2d412 100644 --- a/packages/router/src/MiddlewareResolver.ts +++ b/packages/router/src/MiddlewareResolver.ts @@ -1,6 +1,4 @@ -import { IApplication, MiddlewareIdentifier } from '@h3ravel/contracts' - -import { MiddlewareList } from 'packages/contracts/dist' +import { IApplication, MiddlewareIdentifier, MiddlewareList } from '@h3ravel/contracts' type MiddlewareMap = Record type MiddlewareGroups = Record @@ -81,12 +79,14 @@ export class MiddlewareResolver { resolved = map[base] ?? base results.push(parameters ? `${String(resolved)}:${parameters}` : String(resolved)) + const bound = this.app.boundMiddlewares(resolved) + if (bound) results.push(bound) } else { - results.push(middleware) + const bound = this.app.boundMiddlewares(middleware) + if (bound) results.push(bound) } - } - return results + return results.filter(e => typeof e !== 'string') } } diff --git a/packages/router/src/Pipeline.ts b/packages/router/src/Pipeline.ts index 78f4ab03..7106fc93 100644 --- a/packages/router/src/Pipeline.ts +++ b/packages/router/src/Pipeline.ts @@ -1,9 +1,8 @@ -import { CallableConstructor, IRequest } from '@h3ravel/contracts' -import { Container, ContainerResolver } from '@h3ravel/core' +import { CallableConstructor, IContainer, IRequest } from '@h3ravel/contracts' +import { RuntimeException, isCallable } from '@h3ravel/support' import { Logger } from '@h3ravel/shared' import { Pipe } from './Contracts/Utilities' -import { RuntimeException } from '@h3ravel/support' export class Pipeline { /** @@ -19,7 +18,7 @@ export class Pipeline { /** * The container implementation. */ - protected container?: Container + protected container?: IContainer /** * The object being passed through the pipeline. @@ -36,7 +35,7 @@ export class Pipeline { */ protected method = 'handle' - constructor(app?: Container) { + constructor(app?: IContainer) { this.container = app } @@ -108,7 +107,7 @@ export class Pipeline { return async (passable: XP) => { try { // pipe is a callable middleware fn - if (typeof pipe === 'function' && ContainerResolver.isCallable(pipe)) { + if (typeof pipe === 'function' && isCallable(pipe)) { return await pipe(passable, stack) } @@ -136,7 +135,6 @@ export class Pipeline { } const handler: CallableConstructor = instance[this.method as never] ?? instance - // const result = 'await handler.apply(instance, parameters)' const result = Reflect.apply(handler, instance, parameters) return await this.handleCarry(result) @@ -198,7 +196,7 @@ export class Pipeline { * * @param container */ - setContainer (container: Container) { + setContainer (container: IContainer) { this.container = container return this diff --git a/packages/router/src/Providers/RoutingServiceProvider.ts b/packages/router/src/Providers/RoutingServiceProvider.ts new file mode 100644 index 00000000..af2eccb4 --- /dev/null +++ b/packages/router/src/Providers/RoutingServiceProvider.ts @@ -0,0 +1,72 @@ +import { ICallableDispatcher, IControllerDispatcher, IUrlGenerator } from '@h3ravel/contracts' + +import { CallableDispatcher } from '../CallableDispatcher' +import { ControllerDispatcher } from '../ControllerDispatcher' +import { ServiceProvider } from '@h3ravel/support' +import { UrlGenerator } from '../UrlGenerator' + +export class RoutingServiceProvider extends ServiceProvider { + public static order = 'before:ConfigServiceProvider' + + async register () { + this.bindUrlGenerator() + + this.app.singleton(ICallableDispatcher, (app) => { + return new CallableDispatcher(app) + }) + + this.app.singleton(IControllerDispatcher, (app) => { + return new ControllerDispatcher(app) + }) + } + + /** + * Bind the URL generator service. + * + * @return void + */ + protected bindUrlGenerator () { + this.app.alias(IUrlGenerator, 'url') + this.app.singleton('url', (app) => { + const routes = app.make('router').getRoutes() + + // The URL generator needs the route collection that exists on the router. + // Keep in mind this is an object, so we're passing by references here + // and all the registered routes will be available to the generator. + app.instance('routes', routes) + + return new UrlGenerator( + routes, + app.rebinding('http.request', (_app, request) => { + this.app.make('url').setRequest(request) + return request + })!, + app.make('config').get('app.asset_url') + ) + }) + + this.app.extend('url', (url, app) => { + // Next we will set a few service resolvers on the URL generator so it can + // get the information it needs to function. This just provides some of + // the convenience features to this URL generator like "signed" URLs. + url.setSessionResolver(() => { + return this.app.make('session') ?? null + }) + + url.setKeyResolver(() => { + const config = this.app.make('config') + + return [config.get('app.key'), ...(config.get('app.previous_keys') ?? [])] + }) + + // If the route collection is "rebound", for example, when the routes have been + // cached for the application, we will need to rebind the routes on the + // URL generator instance so it has the latest version of the routes. + app.rebinding('routes', (_app, routes) => { + this.app.make('url').setRoutes(routes) + }) + + return url + }) + } +} diff --git a/packages/router/src/Route.ts b/packages/router/src/Route.ts index db560bbf..596bce87 100644 --- a/packages/router/src/Route.ts +++ b/packages/router/src/Route.ts @@ -1,9 +1,7 @@ -import { ActionInput, CallableConstructor, ClassConstructor, GenericObject, IController, IControllerDispatcher } from '@h3ravel/contracts' -import { Application, Container } from '@h3ravel/core' +import { ActionInput, CallableConstructor, ClassConstructor, GenericObject, IApplication, ICallableDispatcher, IController, IControllerDispatcher } from '@h3ravel/contracts' import { Arr, Obj, Str, isClass } from '@h3ravel/support' import { IRoute, MiddlewareList, ResourceMethod, ResponsableType, RouteActions, RouteMethod } from '@h3ravel/contracts' -import { CallableDispatcher } from './CallableDispatcher' import { CompiledRoute } from './CompiledRoute' import { ControllerDispatcher } from './ControllerDispatcher' import { H3 } from 'h3' @@ -60,7 +58,7 @@ export class Route extends IRoute { /** * The container instance used by the route. */ - protected container!: Application + protected container!: IApplication /** * The fields that implicit binding should use for a given parameter. @@ -145,7 +143,7 @@ export class Route extends IRoute { * * @param container */ - setContainer (container: Application) { + setContainer (container: IApplication) { this.container = container return this @@ -443,7 +441,11 @@ export class Route extends IRoute { * Run the route action and return the response. */ async run (): Promise { - this.container ??= new Container() as never + if (!this.container) { + const { Container } = await import('@h3ravel/core') + + this.container = new Container() as never + } try { if (this.isControllerAction()) { @@ -837,7 +839,7 @@ export class Route extends IRoute { protected async runCallable () { const callable = this.action.uses - return new CallableDispatcher(this.container).dispatch(this, callable) + return this.container.make(ICallableDispatcher).dispatch(this, callable) } /** diff --git a/packages/router/src/RouteGroup.ts b/packages/router/src/RouteGroup.ts index 105ad558..c65c8485 100644 --- a/packages/router/src/RouteGroup.ts +++ b/packages/router/src/RouteGroup.ts @@ -1,6 +1,6 @@ import { Arr, Obj, Str } from '@h3ravel/support' -import { RouteActions } from '@h3ravel/shared' +import { RouteActions } from '@h3ravel/contracts' export class RouteGroup { /** diff --git a/packages/router/src/RouteRegisterer.ts b/packages/router/src/RouteRegisterer.ts index ffe72307..f6f324fa 100644 --- a/packages/router/src/RouteRegisterer.ts +++ b/packages/router/src/RouteRegisterer.ts @@ -4,7 +4,7 @@ import { UseMagic, trait, use } from '@h3ravel/shared' import { CreatesRegularExpressionRouteConstraints } from './Traits/CreatesRegularExpressionRouteConstraints' import { FRoute } from '@h3ravel/support/facades' -import { Injectable } from '@h3ravel/core' +import { Injectable } from '@h3ravel/foundation' import { Router } from './Router' const Inference = trait(e => class extends e { } as { diff --git a/packages/router/src/RouteUrlGenerator.ts b/packages/router/src/RouteUrlGenerator.ts index b09ea1e0..74430b18 100644 --- a/packages/router/src/RouteUrlGenerator.ts +++ b/packages/router/src/RouteUrlGenerator.ts @@ -1,11 +1,10 @@ -import { Arr, Collection, Obj, Str } from '@h3ravel/support' -import { GenericObject, IRequest, IRoute } from '@h3ravel/contracts' +import { Collection, Obj, Str } from '@h3ravel/support' +import { IRequest, IRoute, IRouteUrlGenerator, RouteParams } from '@h3ravel/contracts' -import { Route } from './Route' import { UrlGenerationException } from '@h3ravel/foundation' import type { UrlGenerator } from './UrlGenerator' -export class RouteUrlGenerator { +export class RouteUrlGenerator extends IRouteUrlGenerator { /** * The URL generator instance. */ @@ -19,7 +18,7 @@ export class RouteUrlGenerator { /** * The named parameter defaults. */ - public defaultParameters: GenericObject = {} + public defaultParameters: RouteParams = {} /** * Characters that should not be URL encoded. @@ -48,6 +47,7 @@ export class RouteUrlGenerator { * @param request */ constructor(url: UrlGenerator, request: IRequest) { + super() this.url = url this.request = request } @@ -59,16 +59,18 @@ export class RouteUrlGenerator { * @param parameters * @param absolute */ - to (route: Route, parameters: GenericObject = {}, absolute = false) { + to (route: IRoute, parameters: RouteParams = {}, absolute = false) { parameters = this.formatParameters(route, parameters) const domain = this.getRouteDomain(route, parameters) const root = this.replaceRootParameters(route, domain, parameters) const path = this.replaceRouteParameters(route.uri(), parameters) + let uri = this.addQueryString(this.url.format(root, path, route), parameters) - const missingMatches = [...uri.matchAll(/\{(.*?)\}/g)] + const missingMatches = [...uri.matchAll(/\{([\w]+)(?:[:][\w]+)?\??\}/g)] + if (missingMatches.length) { throw UrlGenerationException.forMissingParameters(route, missingMatches.map(m => m[1])) } @@ -94,7 +96,7 @@ export class RouteUrlGenerator { * @param route * @param parameters */ - protected getRouteDomain (route: Route, parameters: GenericObject) { + protected getRouteDomain (route: IRoute, parameters: RouteParams) { return route.getDomain() ? this.formatDomain(route, parameters) : undefined } @@ -104,7 +106,7 @@ export class RouteUrlGenerator { * @param route * @param parameters */ - protected formatDomain (route: Route, parameters: GenericObject) { + protected formatDomain (route: IRoute, parameters: RouteParams) { void parameters return this.addPortToDomain( this.getRouteScheme(route) + route.getDomain() @@ -116,7 +118,7 @@ export class RouteUrlGenerator { * * @param route */ - protected getRouteScheme (route: Route) { + protected getRouteScheme (route: IRoute) { if (route.httpOnly()) { return 'http://' } else if (route.httpsOnly()) { @@ -147,8 +149,9 @@ export class RouteUrlGenerator { * @param route * @param parameters */ - protected formatParameters (route: Route, parameters: Record) { - parameters = Arr.wrap(parameters) + protected formatParameters (route: IRoute, parameters: RouteParams) { + parameters = Obj.wrap(parameters) + this.defaultParameters = Obj.wrap(this.defaultParameters) const namedParameters: Record = {} const namedQueryParameters: Record = {} @@ -175,16 +178,16 @@ export class RouteUrlGenerator { } for (const [key, value] of Object.entries(parameters)) { - if (typeof key === 'string') { + if (!Str.isInteger(key)) { namedQueryParameters[key] = value delete parameters[key] } } - if (parameters.length === requiredRouteParametersWithoutDefaultsOrNamedParameters.length) { + if (Object.keys(parameters).length === requiredRouteParametersWithoutDefaultsOrNamedParameters.length) { for (const name of [...requiredRouteParametersWithoutDefaultsOrNamedParameters].reverse()) { - if (parameters.length === 0) break - namedParameters[name] = parameters.pop() + if (Obj.isEmpty(parameters)) break + namedParameters[name] = Obj.pop(parameters) } } @@ -193,13 +196,13 @@ export class RouteUrlGenerator { Object.entries(namedParameters).filter(([_, val]) => val === '') ) - if (requiredRouteParametersWithoutDefaultsOrNamedParameters.length && parameters.length !== Object.keys(emptyParameters).length) { + if (requiredRouteParametersWithoutDefaultsOrNamedParameters.length && Object.keys(parameters).length !== Object.keys(emptyParameters).length) { offset = Object.keys(namedParameters).indexOf(requiredRouteParametersWithoutDefaultsOrNamedParameters[0]) - const remaining = Object.keys(emptyParameters).length - offset - parameters.length + const remaining = Object.keys(emptyParameters).length - offset - Object.keys(parameters).length if (remaining < 0) offset += remaining if (offset < 0) offset = 0 - } else if (!requiredRouteParametersWithoutDefaultsOrNamedParameters.length && parameters.length !== 0) { - let remainingCount = parameters.length + } else if (!requiredRouteParametersWithoutDefaultsOrNamedParameters.length && !Obj.isEmpty(parameters)) { + let remainingCount = Object.keys(parameters).length const namedKeys = Object.keys(namedParameters) for (let i = namedKeys.length - 1; i >= 0; i--) { if (namedParameters[namedKeys[i]] === '') { @@ -211,24 +214,25 @@ export class RouteUrlGenerator { } const namedKeys = Object.keys(namedParameters) + for (let i = offset; i < namedKeys.length; i++) { const key = namedKeys[i] if (namedParameters[key] !== '') continue - if (parameters.length) namedParameters[key] = parameters.shift() + else if (!Obj.isEmpty(parameters)) namedParameters[key] = Obj.shift(parameters) } for (const [key, value] of Object.entries(namedParameters)) { const bindingField = route.bindingFieldFor(key) const defaultParameterKey = bindingField ? `key:${bindingField}` : key - if (value === '' && this.defaultParameters[defaultParameterKey] !== undefined) { + if (value === '' && Obj.isAssoc(this.defaultParameters) && this.defaultParameters[defaultParameterKey] !== undefined) { namedParameters[key] = this.defaultParameters[defaultParameterKey] } } parameters = { ...namedParameters, ...namedQueryParameters, ...parameters } - parameters = Collection.wrap(parameters) - .map((value, key) => value instanceof IRoute && route.bindingFieldFor(key) ? value[route.bindingFieldFor(key) as never] : value) + parameters = new Collection(parameters) + .map((value: any, key) => value instanceof IRoute && route.bindingFieldFor(key) ? value[route.bindingFieldFor(key) as never] : value) .all() return this.url.formatParameters(parameters) @@ -242,7 +246,7 @@ export class RouteUrlGenerator { * @param domain * @param parameters */ - protected replaceRootParameters (route: Route, domain: string | undefined, parameters: GenericObject) { + protected replaceRootParameters (route: IRoute, domain: string | undefined, parameters: RouteParams) { const scheme = this.getRouteScheme(route) return this.replaceRouteParameters( @@ -256,12 +260,12 @@ export class RouteUrlGenerator { * @param path * @param parameters */ - protected replaceRouteParameters (path: string, parameters: GenericObject) { + protected replaceRouteParameters (path: string, parameters: RouteParams) { path = this.replaceNamedParameters(path, parameters) - path = path.replace(/\{.*?\}/g, (match) => { + path = path.replace(/\{.*?\}/g, (match): any => { // Reset numeric keys - parameters = { ...parameters } + parameters = { ...parameters as Record } if (!(0 in parameters) && !match.endsWith('?}')) { return match @@ -269,7 +273,8 @@ export class RouteUrlGenerator { const val = parameters[0] delete parameters[0] - return val + + return val ?? '' }) return path.replace(/\{.*?\?\}/g, '').replace(/^\/+|\/+$/g, '') @@ -282,18 +287,31 @@ export class RouteUrlGenerator { * @param path * @param parameters */ - protected replaceNamedParameters (path: string, parameters: GenericObject) { - return path.replace(/\{(.*?)(\?)?\}/g, (_, key) => { + protected replaceNamedParameters (path: string, parameters: RouteParams) { + parameters = Obj.wrap(parameters) + this.defaultParameters = Obj.wrap(this.defaultParameters) + + return path.replace(/\{([^}?]+)(\?)?\}/g, (_, key, optional): any => { if (parameters[key] !== undefined && parameters[key] !== '') { const val = parameters[key] delete parameters[key] return val - } else if (this.defaultParameters[key] !== undefined) { - return this.defaultParameters[key] - } else if (parameters[key] !== undefined) { + } + + if (this.defaultParameters[key as never] !== undefined) { + return this.defaultParameters[key as never] + } + + if (parameters[key] !== undefined) { delete parameters[key] } + // preserve optional param if missing + if (optional) { + return `{${key}?}` + } + + // required param unresolved return `{${key}}` }) } @@ -305,7 +323,7 @@ export class RouteUrlGenerator { * @param uri * @param parameters */ - protected addQueryString (uri: string, parameters: GenericObject) { + protected addQueryString (uri: string, parameters: RouteParams) { // If the URI has a fragment we will move it to the end of this URI since it will // need to come after any query string that may be added to the URL else it is // not going to be available. We will remove it then append it back on here. @@ -329,11 +347,13 @@ export class RouteUrlGenerator { * @param parameters * @return string */ - protected getRouteQueryString (parameters: GenericObject) { + protected getRouteQueryString (parameters: RouteParams) { + parameters = Obj.wrap(parameters) + // First we will get all of the string parameters that are remaining after we // have replaced the route wildcards. We'll then build a query string from // these string parameters then use it as a starting point for the rest. - if (parameters.length === 0) { + if (Obj.isEmpty(parameters)) { return '' } @@ -343,7 +363,7 @@ export class RouteUrlGenerator { // Lastly, if there are still parameters remaining, we will fetch the numeric // parameters that are in the array and add them to the query string or we // will make the initial query string if it wasn't started with strings. - if (keyed.length < parameters.length) { + if (keyed.length < Object.keys(parameters).length) { query += '&' + this.getNumericParameters(parameters).join('&') } @@ -357,7 +377,7 @@ export class RouteUrlGenerator { * * @param parameters */ - protected getStringParameters (parameters: GenericObject) { + protected getStringParameters (parameters: RouteParams) { return Object.fromEntries( Object.entries(parameters).filter(([key]) => typeof key === 'string') ) @@ -369,7 +389,7 @@ export class RouteUrlGenerator { * * @param parameters */ - protected getNumericParameters (parameters: GenericObject) { + protected getNumericParameters (parameters: RouteParams) { return Object.fromEntries( Object.entries(parameters).filter(([key]) => !Number.isNaN(Number(key))) ) @@ -380,7 +400,10 @@ export class RouteUrlGenerator { * * @param $defaults */ - defaults (defaults: GenericObject) { + defaults (defaults: RouteParams) { + defaults = Obj.wrap(defaults) + this.defaultParameters = Obj.wrap(this.defaultParameters) + this.defaultParameters = { ...this.defaultParameters, ...defaults } } } \ No newline at end of file diff --git a/packages/router/src/Router.ts b/packages/router/src/Router.ts index 26480d03..8439417a 100644 --- a/packages/router/src/Router.ts +++ b/packages/router/src/Router.ts @@ -1,9 +1,8 @@ import 'reflect-metadata' import { Middleware, MiddlewareOptions, type H3 } from 'h3' -import { Application } from '@h3ravel/core' import { Request, Response, JsonResponse } from '@h3ravel/http' import { Arr, Collection, isClass, MacroableClass, Str, Stringable, tap } from '@h3ravel/support' -import { IDispatcher } from '@h3ravel/contracts' +import { IDispatcher, IApplication } from '@h3ravel/contracts' import { Magic, mix } from '@h3ravel/shared' import { IMiddleware, IRequest, IResponse, IRouter, RouteActions, ActionInput, MiddlewareList, ResponsableType } from '@h3ravel/contracts' import type { EventHandler, IController, GenericObject, ResourceOptions, ResourceMethod, CallableConstructor, MiddlewareIdentifier } from '@h3ravel/contracts' @@ -71,14 +70,14 @@ export class Router extends mix(IRouter, MacroableClass, Magic) { /** * The registered custom implicit binding callback. */ - protected implicitBindingCallback?: (container: Application, route: Route, defaultFn: CallableConstructor) => any + protected implicitBindingCallback?: (container: IApplication, route: Route, defaultFn: CallableConstructor) => any /** * All of the verbs supported by the router. */ static verbs: RouteMethod[] = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] - constructor(protected h3App: H3, private app: Application) { + constructor(protected h3App: H3, private app: IApplication) { super() this.events = app.has('app.events') ? app.make('app.events') : undefined this.routes = new RouteCollection() diff --git a/packages/router/src/Traits/RouteDependencyResolver.ts b/packages/router/src/Traits/RouteDependencyResolver.ts index bc86b497..687e421a 100644 --- a/packages/router/src/Traits/RouteDependencyResolver.ts +++ b/packages/router/src/Traits/RouteDependencyResolver.ts @@ -1,12 +1,11 @@ import 'reflect-metadata' -import { IController, ResourceMethod } from '@h3ravel/contracts' +import { IApplication, IController, ResourceMethod } from '@h3ravel/contracts' -import { Application } from '@h3ravel/core' import { RuntimeException } from '@h3ravel/support' export class RouteDependencyResolver { - constructor(protected container: Application) { } + constructor(protected container: IApplication) { } /** * Resolve the object method's type-hinted dependencies. diff --git a/packages/router/src/UrlGenerator.ts b/packages/router/src/UrlGenerator.ts index c30c2043..d5de657f 100644 --- a/packages/router/src/UrlGenerator.ts +++ b/packages/router/src/UrlGenerator.ts @@ -1,15 +1,13 @@ -import { CallableConstructor, GenericObject, IRequest, IRoute } from '@h3ravel/contracts' -import { optional, tap } from '@h3ravel/support' +import { CallableConstructor, GenericObject, IRequest, IRoute, IRouteCollection, IUrlGenerator, RouteParams, UrlRoutable } from '@h3ravel/contracts' +import { Obj, optional, tap } from '@h3ravel/support' -import { Route } from './Route' -import { RouteCollection } from './RouteCollection' import { RouteNotFoundException } from '@h3ravel/foundation' import { RouteUrlGenerator } from './RouteUrlGenerator' import crypto from 'crypto' -export class UrlGenerator { - protected routes: RouteCollection - protected request: IRequest +export class UrlGenerator extends IUrlGenerator { + private routes: IRouteCollection + private request: IRequest protected assetRoot?: string protected forcedRoot?: string @@ -46,7 +44,8 @@ export class UrlGenerator { */ #formatPathUsing?: CallableConstructor - constructor(routes: RouteCollection, request: IRequest, assetRoot?: string) { + constructor(routes: IRouteCollection, request: IRequest, assetRoot?: string) { + super() this.routes = routes this.request = request this.assetRoot = assetRoot @@ -107,7 +106,7 @@ export class UrlGenerator { * @param extra Additional path segments * @param secure Force HTTPS or HTTP */ - to (path: string, extra: any[] = [], secure: boolean | null = null): string { + to (path: string, extra: (string | number)[] = [], secure: boolean | null = null): string { if (this.isValidUrl(path)) { return path } @@ -196,6 +195,15 @@ export class UrlGenerator { return base.replace(/^https?:\/\//, scheme) } + /** + * Create a signed route URL for a named route. + * + * @param name + * @param parameters + * @param expiration + * @param absolute + * @returns + */ signedRoute ( name: string, parameters: Record = {}, @@ -222,6 +230,12 @@ export class UrlGenerator { return this.route(name, { ...parameters, signature }, absolute) } + /** + * Check if the given request has a valid signature for a relative URL. + * + * @param request + * @returns + */ hasValidSignature (request: IRequest): boolean { const signature = request.query('signature') if (!signature || !this.keyResolver) return false @@ -240,6 +254,14 @@ export class UrlGenerator { ) } + /** + * Get the URL to a named route. + * + * @param name + * @param parameters + * @param absolute + * @returns + */ route (name: string, parameters: GenericObject = {}, absolute = true): string { const route = this.routes.getByName(name) @@ -262,7 +284,7 @@ export class UrlGenerator { * @param parameters * @param absolute */ - toRoute (route: Route, parameters: GenericObject = {}, absolute: boolean = true) { + toRoute (route: IRoute, parameters: GenericObject = {}, absolute: boolean = true) { return this.routeUrl().to( route, parameters, @@ -281,7 +303,7 @@ export class UrlGenerator { * @param route * @returns */ - format (root: string, path: string, route?: Route): string { + format (root: string, path: string, route?: IRoute): string { let finalPath = '/' + path.replace(/^\/+/, '') if (this.#formatHostUsing) { @@ -300,9 +322,11 @@ export class UrlGenerator { * * @param parameters */ - formatParameters (parameters: GenericObject) { + formatParameters (parameters: GenericObject | RouteParams): GenericObject { + parameters = Obj.wrap(parameters as never) + for (const [key, parameter] of Object.entries(parameters)) { - if (typeof parameter.getRouteKey === 'function') { + if (Obj.isAssoc(parameter) && typeof parameter.getRouteKey === 'function') { parameters[key] = parameter.getRouteKey() } } @@ -401,7 +425,7 @@ export class UrlGenerator { /** * Get the request instance. */ - getRequest () { + getRequest (): IRequest { return this.request } @@ -430,12 +454,19 @@ export class UrlGenerator { * * @param routes */ - setRoutes (routes: RouteCollection) { + setRoutes (routes: IRouteCollection) { this.routes = routes return this } + /** + * Get the route collection. + */ + getRoutes (): IRouteCollection { + return this.routes + } + /** * Get the session implementation from the resolver. */ diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 7daf3b51..e3494c9d 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -21,6 +21,7 @@ export * from './MiddlewareResolver' export * from './PendingResourceRegistration' export * from './PendingSingletonResourceRegistration' export * from './Pipeline' +export * from './Providers/RoutingServiceProvider' export * from './ResourceRegistrar' export * from './Route' export * from './RouteAction' diff --git a/packages/router/tests/router.test.ts b/packages/router/tests/router.test.ts index 9ff8cd1a..a84e6c18 100644 --- a/packages/router/tests/router.test.ts +++ b/packages/router/tests/router.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, it } from 'vitest' -import { Application } from '@h3ravel/core' +import { IApplication } from '@h3ravel/contracts' import { h3ravel } from '@h3ravel/core' -let app: Application +let app: IApplication class Cont { index () { } @@ -29,10 +29,10 @@ describe('Router', async () => { it('can load routes before server is fired', async () => { const router = app.make('router') - router.match(['get'], 'path/{user}/{name}', [Cont, 'index']).name('path') - router.match(['get'], 'path3/{user:name}/{name}', [Cont, 'show']).name('path.3').prefix('---john') - router.match(['put'], 'path4/{user}/{name?}', () => { }).name('path.4') - router.match(['post'], 'path5/{user:name}/{name}', () => { }) + router.match(['GET'], 'path/{user}/{name}', [Cont, 'index']).name('path') + router.match(['GET'], 'path3/{user:name}/{name}', [Cont, 'show']).name('path.3').prefix('---john') + router.match(['PUT'], 'path4/{user}/{name?}', () => { }).name('path.4') + router.match(['POST'], 'path5/{user:name}/{name}', () => { }) router.getRoutes().refreshActionLookups() router.getRoutes().refreshNameLookups() diff --git a/packages/session/src/Providers/SessionServiceProvider.ts b/packages/session/src/Providers/SessionServiceProvider.ts index 5c88741c..69873e70 100644 --- a/packages/session/src/Providers/SessionServiceProvider.ts +++ b/packages/session/src/Providers/SessionServiceProvider.ts @@ -2,6 +2,7 @@ import { dbBuilder, fileBuilder, memoryBuilder, redisBuilder } from '../adapters import { MakeSessionTableCommand } from '../Commands/MakeSessionTableCommand' import { ServiceProvider } from '@h3ravel/support' +import { SessionManager } from '../SessionManager' import { SessionStore } from '../SessionStore' export class SessionServiceProvider extends ServiceProvider { @@ -17,6 +18,17 @@ export class SessionServiceProvider extends ServiceProvider { SessionStore.register('memory', memoryBuilder) SessionStore.register('redis', redisBuilder) - this.registeredCommands = [MakeSessionTableCommand] + this.app.singleton('session', (app) => { + return SessionManager.init(app) + }) + + this.app.singleton('session.store', (app) => { + // First, we will create the session manager which is responsible for the + // creation of the various session drivers when they are needed by the + // application instance, and will resolve them on a lazy load basis. + return app.make('session').getDriver() + }) + + this.registerCommands([MakeSessionTableCommand]) } } diff --git a/packages/session/src/SessionManager.ts b/packages/session/src/SessionManager.ts index 50947502..468f8816 100644 --- a/packages/session/src/SessionManager.ts +++ b/packages/session/src/SessionManager.ts @@ -1,5 +1,4 @@ -import { DriverOption, SessionDriver } from './Contracts/SessionContract' -import type { IHttpContext, IRequest, ISessionManager } from '@h3ravel/contracts' +import { IApplication, IHttpContext, IRequest, ISessionDriver, ISessionManager, SessionDriverOption } from '@h3ravel/contracts' import { createHash, createHmac, randomBytes } from 'crypto' import { getCookie, setCookie } from 'h3' @@ -12,8 +11,10 @@ import { SessionStore } from './SessionStore' * Handles session initialization, ID generation, and encryption. * Each request gets a unique session namespace tied to its ID. */ -export class SessionManager implements ISessionManager { - private driver: SessionDriver +export class SessionManager extends ISessionManager { + private app: IApplication + private ctx: IHttpContext + private driver: ISessionDriver private appKey: string private sessionId: string private request: IRequest @@ -24,17 +25,51 @@ export class SessionManager implements ISessionManager { * @param driverName - registered driver key ('file' | 'database' | 'memory' | 'redis') * @param driverOptions - optional bag for driver-specific options */ - constructor(private ctx: IHttpContext, driverName: 'file' | 'memory' | 'database' | 'redis' = 'file', driverOptions: DriverOption = {}) { + constructor(app?: IApplication, driverName?: 'file' | 'memory' | 'database' | 'redis', driverOptions?: SessionDriverOption) + constructor(app?: IHttpContext | IApplication, driverName?: 'file' | 'memory' | 'database' | 'redis', driverOptions?: SessionDriverOption) + constructor(app?: IHttpContext | IApplication, driverName: 'file' | 'memory' | 'database' | 'redis' = 'file', driverOptions: SessionDriverOption = {}) { + super() this.appKey = process.env.APP_KEY! - this.request = ctx.request + + if (app instanceof IHttpContext) { + this.request = app.request + this.ctx = app + this.app = app.app + } else { + this.app = app! + this.ctx = app!.make('http.context') + this.request = this.ctx.request + } this.sessionId = this.resolveSessionId() // Then instantiate the driver through the registry so different constructors are supported this.driver = SessionStore.make(driverName, driverOptions.sessionId ?? this.sessionId, driverOptions) + // @ts-expect-error caused by dist/src import missmatch this.flashBag = this.driver.flashBag } + /** + * Initialize the Session Manager + * + * @param ctx + * @returns + */ + static init (app: IApplication) { + return new SessionManager( + app, + config('session.driver', 'file'), + { + cwd: config('session.files'), + sessionDir: '/', + dir: '/', + table: config('session.table'), + prefix: config('database.connections.redis.options.prefix'), + client: config(`database.connections.${config('session.driver', 'file')}.client`), + } + ) + } + /** * Generate a secure session ID unique to the user device. */ @@ -53,13 +88,13 @@ export class SessionManager implements ISessionManager { * Resolve the session ID from cookie, header, or create a new one. */ private resolveSessionId (): string { - const cookieSession = getCookie(this.ctx.event, 'h3ravel_session') + const cookieSession = getCookie(this.ctx!.event, 'h3ravel_session') if (cookieSession) return cookieSession const newId = this.generateSessionId() - setCookie(this.ctx.event, 'h3ravel_session', newId, { + setCookie(this.ctx!.event, 'h3ravel_session', newId, { httpOnly: true, secure: true, sameSite: 'lax', @@ -71,10 +106,17 @@ export class SessionManager implements ISessionManager { /** * Access the current session ID. */ - public id (): string { + id (): string { return this.sessionId } + /** + * Get the current session driver + */ + getDriver (): ISessionDriver { + return this.driver + } + /** * Retrieve a value from the session * diff --git a/packages/session/src/SessionStore.ts b/packages/session/src/SessionStore.ts index 1986b4c5..eeef1445 100644 --- a/packages/session/src/SessionStore.ts +++ b/packages/session/src/SessionStore.ts @@ -1,4 +1,4 @@ -import { DriverBuilder, DriverOption } from './Contracts/SessionContract' +import { SessionDriverBuilder, SessionDriverOption } from '@h3ravel/contracts' /** * SessionStore (Driver registry) @@ -7,12 +7,12 @@ import { DriverBuilder, DriverOption } from './Contracts/SessionContract' * SessionStore.make('file', sessionId, options) */ export class SessionStore { - private static registry: Map = new Map() + private static registry: Map = new Map() /** * Register a driver builder under a key (e.g. 'file', 'database', 'memory'). */ - public static register (name: 'file' | 'memory' | 'database' | 'redis', builder: DriverBuilder) { + public static register (name: 'file' | 'memory' | 'database' | 'redis', builder: SessionDriverBuilder) { this.registry.set(name, builder) } @@ -21,7 +21,7 @@ export class SessionStore { * * If driver not found, throws. Options is a simple key/value bag passed to the builder. */ - public static make (name: 'file' | 'memory' | 'database' | 'redis', sessionId: string, options: DriverOption = {}) { + public static make (name: 'file' | 'memory' | 'database' | 'redis', sessionId: string, options: SessionDriverOption = {}) { const builder = this.registry.get(name) if (!builder) throw new Error(`Session driver "${name}" is not registered`) return builder(sessionId, options) diff --git a/packages/session/src/adapters.ts b/packages/session/src/adapters.ts index 4ffb834a..166ba6f6 100644 --- a/packages/session/src/adapters.ts +++ b/packages/session/src/adapters.ts @@ -1,4 +1,4 @@ -import { DriverBuilder, DriverOption } from './Contracts/SessionContract' +import { SessionDriverBuilder, SessionDriverOption } from '@h3ravel/contracts' import { DatabaseDriver } from './drivers/DatabaseDriver' import { FileDriver } from './drivers/FileDriver' @@ -9,7 +9,7 @@ import { RedisDriver } from './drivers/RedisDriver' * FileDriver builder * constructor(sessionId: string, sessionDir?: string, cwd?: string) */ -export const fileBuilder: DriverBuilder = (sessionId, options: DriverOption = {}) => { +export const fileBuilder: SessionDriverBuilder = (sessionId, options: SessionDriverOption = {}) => { const sessionDir = options.sessionDir ?? options.dir ?? './storage/sessions' const cwd = options.cwd ?? process.cwd() return new FileDriver(sessionId, sessionDir, cwd) @@ -19,7 +19,7 @@ export const fileBuilder: DriverBuilder = (sessionId, options: DriverOption = {} * DatabaseDriver builder * constructor(sessionId: string, table?: string) */ -export const dbBuilder: DriverBuilder = (sessionId, options: DriverOption = {}) => { +export const dbBuilder: SessionDriverBuilder = (sessionId, options: SessionDriverOption = {}) => { const table = options.table ?? 'sessions' return new DatabaseDriver(options.sessionId ?? sessionId, table) } @@ -28,7 +28,7 @@ export const dbBuilder: DriverBuilder = (sessionId, options: DriverOption = {}) * MemoryDriver builder * constructor(sessionId: string) */ -export const memoryBuilder: DriverBuilder = (sessionId) => { +export const memoryBuilder: SessionDriverBuilder = (sessionId) => { return new MemoryDriver(sessionId) } @@ -36,7 +36,7 @@ export const memoryBuilder: DriverBuilder = (sessionId) => { * RedisDriver builder * constructor(sessionId: string, redisClient?: RedisClient, prefix?: string) */ -export const redisBuilder: DriverBuilder = (sessionId, options: DriverOption = {}) => { +export const redisBuilder: SessionDriverBuilder = (sessionId, options: SessionDriverOption = {}) => { const client = options.client // optional client instance const prefix = options.prefix ?? 'h3ravel:sessions:' return new RedisDriver(sessionId, client, prefix) diff --git a/packages/session/src/drivers/DatabaseDriver.ts b/packages/session/src/drivers/DatabaseDriver.ts index 6e0c8b4a..fbe1503c 100644 --- a/packages/session/src/drivers/DatabaseDriver.ts +++ b/packages/session/src/drivers/DatabaseDriver.ts @@ -3,7 +3,7 @@ import { safeDot, setNested } from '@h3ravel/support' import { DB } from '@h3ravel/database' import { Driver } from './Driver' import { FlashBag } from '../FlashBag' -import { SessionDriver } from '../Contracts/SessionContract' +import { ISessionDriver } from '@h3ravel/contracts' /** * DatabaseDriver @@ -11,14 +11,13 @@ import { SessionDriver } from '../Contracts/SessionContract' * Stores sessions in a database table. Each session ID maps to a row. * The `payload` column contains all session key/value pairs as JSON. */ -export class DatabaseDriver extends Driver implements SessionDriver { - constructor( - /** - * The current session ID - */ - protected sessionId: string, - private table: string = 'sessions' - ) { +export class DatabaseDriver extends Driver implements ISessionDriver { + /** + * + * @param sessionId The current session ID + * @param table + */ + constructor(protected sessionId: string, private table: string = 'sessions') { super() } diff --git a/packages/session/src/drivers/Driver.ts b/packages/session/src/drivers/Driver.ts index 0d7ad1b4..bedd7be1 100644 --- a/packages/session/src/drivers/Driver.ts +++ b/packages/session/src/drivers/Driver.ts @@ -1,22 +1,19 @@ -import { safeDot, setNested } from 'packages/support/dist' +import { safeDot, setNested } from '@h3ravel/support' import { Encryption } from '../Encryption' import { FlashBag } from '../FlashBag' -import { SessionDriver } from '../Contracts/SessionContract' +import { ISessionDriver } from '@h3ravel/contracts' /** * Driver * * Base Session driver. */ -export abstract class Driver implements SessionDriver { +export abstract class Driver extends ISessionDriver { protected encryptor = new Encryption() protected sessionId!: string public flashBag: FlashBag = new FlashBag() - constructor() { - } - /** * Invalidate session completely and regenerate empty session. */ diff --git a/packages/session/src/drivers/FileDriver.ts b/packages/session/src/drivers/FileDriver.ts index 335d6bbf..8cab364e 100644 --- a/packages/session/src/drivers/FileDriver.ts +++ b/packages/session/src/drivers/FileDriver.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' import { Driver } from './Driver' import { FlashBag } from '../FlashBag' -import { SessionDriver } from '../Contracts/SessionContract' +import { ISessionDriver } from '@h3ravel/contracts' import path from 'path' /** @@ -12,7 +12,7 @@ import path from 'path' * Each session is stored in its own file named after the session ID. * Ideal for local development or low-scale deployments. */ -export class FileDriver extends Driver implements SessionDriver { +export class FileDriver extends Driver implements ISessionDriver { constructor( protected sessionId: string, private sessionDir: string = path.resolve('.sessions'), diff --git a/packages/session/src/drivers/MemoryDriver.ts b/packages/session/src/drivers/MemoryDriver.ts index f0d2e04e..ab356352 100644 --- a/packages/session/src/drivers/MemoryDriver.ts +++ b/packages/session/src/drivers/MemoryDriver.ts @@ -1,6 +1,6 @@ import { Driver } from './Driver' import { FlashBag } from '../FlashBag' -import { SessionDriver } from '../Contracts/SessionContract' +import { ISessionDriver } from '@h3ravel/contracts' import crypto from 'crypto' /** @@ -9,7 +9,7 @@ import crypto from 'crypto' * Lightweight, ephemeral session storage. * Intended for tests, local development, or short-lived apps. */ -export class MemoryDriver extends Driver implements SessionDriver { +export class MemoryDriver extends Driver implements ISessionDriver { private static store: Record> = {} constructor(protected sessionId: string) { @@ -25,11 +25,11 @@ export class MemoryDriver extends Driver implements SessionDriver { * * @returns Decrypted and usable payload */ - protected fetchPayload (): Record { + protected fetchPayload> (): T { const payload = { ...MemoryDriver.store[this.sessionId] } // Merge flash data with payload - return payload + return payload as T } /** diff --git a/packages/session/src/drivers/RedisDriver.ts b/packages/session/src/drivers/RedisDriver.ts index 1a0a3acd..296ba708 100644 --- a/packages/session/src/drivers/RedisDriver.ts +++ b/packages/session/src/drivers/RedisDriver.ts @@ -1,11 +1,11 @@ -import { SessionDriver } from '../Contracts/SessionContract' -import { FlashBag } from '../FlashBag' import { Driver } from './Driver' +import { FlashBag } from '../FlashBag' +import { ISessionDriver } from '@h3ravel/contracts' /** * RedisDriver (placeholder) */ -export class RedisDriver extends Driver implements SessionDriver { +export class RedisDriver extends Driver implements ISessionDriver { private static store: Record> = {} constructor( @@ -24,8 +24,8 @@ export class RedisDriver extends Driver implements SessionDriver { * * @returns Decrypted and usable payload */ - protected fetchPayload (): Record { - return {} + protected fetchPayload> (): T { + return {} as T } /** @@ -33,7 +33,7 @@ export class RedisDriver extends Driver implements SessionDriver { * * @param data */ - protected savePayload (payload: Record): void { + protected savePayload (_payload: Record): void { } /** diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index d47e2cef..d169f740 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -1,6 +1,5 @@ export * from './adapters' export * from './Commands/MakeSessionTableCommand' -export * from './Contracts/SessionContract' export * from './drivers/DatabaseDriver' export * from './drivers/Driver' export * from './drivers/FileDriver' diff --git a/packages/session/tests/file.spec.ts b/packages/session/tests/file.spec.ts index 8aeb000a..16ead5ef 100644 --- a/packages/session/tests/file.spec.ts +++ b/packages/session/tests/file.spec.ts @@ -2,13 +2,13 @@ import { Application, h3ravel } from '@h3ravel/core' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { existsSync, readFileSync } from 'node:fs' -import { HttpContext } from '@h3ravel/shared' +import { IHttpContext } from '@h3ravel/contracts' import { SessionManager } from '../src/SessionManager' import { SessionServiceProvider } from '../src/Providers/SessionServiceProvider' import path from 'node:path' import { rmdir } from 'node:fs/promises' -let ctx: HttpContext +let ctx: IHttpContext let app: Application let event: any const appKey = 'base64:dnZm+Ei7ExEHzhj/wO/3YKUckMQtpLjRVk1VLYiV/es=' diff --git a/packages/session/tests/memory.spec.ts b/packages/session/tests/memory.spec.ts index 35008ad6..ecdd5846 100644 --- a/packages/session/tests/memory.spec.ts +++ b/packages/session/tests/memory.spec.ts @@ -2,12 +2,12 @@ import { Application, h3ravel } from '@h3ravel/core' import { beforeAll, beforeEach, describe, expect, it } from 'vitest' import { Encryption } from '../src/Encryption' -import { HttpContext } from '@h3ravel/shared' +import { IHttpContext } from '@h3ravel/contracts' import { SessionManager } from '../src/SessionManager' import { SessionServiceProvider } from '../src/Providers/SessionServiceProvider' import path from 'node:path' -let ctx: HttpContext +let ctx: IHttpContext let app: Application let event: any const appKey = 'base64:dnZm+Ei7ExEHzhj/wO/3YKUckMQtpLjRVk1VLYiV/es=' diff --git a/packages/shared/src/Utils/PathLoader.ts b/packages/shared/src/Utils/PathLoader.ts index d0e7877c..48ad2ed7 100644 --- a/packages/shared/src/Utils/PathLoader.ts +++ b/packages/shared/src/Utils/PathLoader.ts @@ -2,7 +2,9 @@ import { IPathName } from '@h3ravel/contracts' import nodepath from 'path' export class PathLoader { - private paths = { + private paths: Record = { + app: '/src/app', + src: '/src/', base: '', views: '/src/resources/views', assets: '/public/assets', @@ -11,7 +13,7 @@ export class PathLoader { public: '/public', storage: '/storage', database: '/src/database', - commands: '/src/App/Console/Commands/' + commands: '/src/app/Console/Commands' } /** @@ -35,7 +37,7 @@ export class PathLoader { if (name === 'public') { path = path.replace('/public', nodepath.join('/', process.env.DIST_DIR ?? '.h3ravel/serve')) } else { - path = path.replace('/src/', `/${process.env.DIST_DIR ?? 'src'}/`.replace(/([^:]\/)\/+/g, '$1')) + path = path.replace('/src/', `/${process.env.DIST_DIR ?? '.h3ravel/serve'}/`) } return nodepath.normalize(path) diff --git a/packages/support/package.json b/packages/support/package.json index 6d66625e..d92e603a 100644 --- a/packages/support/package.json +++ b/packages/support/package.json @@ -15,6 +15,10 @@ "import": "./dist/facades.js", "require": "./dist/facades.cjs" }, + "./traits": { + "import": "./dist/traits.js", + "require": "./dist/traits.cjs" + }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/support/src/Facades/HashFacade.ts b/packages/support/src/Facades/HashFacade.ts new file mode 100644 index 00000000..506d9639 --- /dev/null +++ b/packages/support/src/Facades/HashFacade.ts @@ -0,0 +1,10 @@ +import { Facades } from './Facades' +import { IHashManager } from '@h3ravel/contracts' + +class HashFacade extends Facades { + protected static getFacadeAccessor () { + return 'hash' + } +} + +export const Hash = HashFacade.createFacade() \ No newline at end of file diff --git a/packages/support/src/Facades/RequestFacade.ts b/packages/support/src/Facades/RequestFacade.ts new file mode 100644 index 00000000..0134bbda --- /dev/null +++ b/packages/support/src/Facades/RequestFacade.ts @@ -0,0 +1,10 @@ +import { Facades } from './Facades' +import { IRequest } from '@h3ravel/contracts' + +class RequestFacade extends Facades { + protected static getFacadeAccessor () { + return 'http.request' + } +} + +export const Request = RequestFacade.createFacade() \ No newline at end of file diff --git a/packages/support/src/Facades/ResponseFacade.ts b/packages/support/src/Facades/ResponseFacade.ts new file mode 100644 index 00000000..869bb048 --- /dev/null +++ b/packages/support/src/Facades/ResponseFacade.ts @@ -0,0 +1,10 @@ +import { Facades } from './Facades' +import { IResponse } from '@h3ravel/contracts' + +class ResponseFacade extends Facades { + protected static getFacadeAccessor () { + return 'http.response' + } +} + +export const Response = ResponseFacade.createFacade() \ No newline at end of file diff --git a/packages/support/src/Facades/index.ts b/packages/support/src/Facades/index.ts index 05c58bee..17b1bf1f 100644 --- a/packages/support/src/Facades/index.ts +++ b/packages/support/src/Facades/index.ts @@ -1,2 +1,5 @@ export * from './Facades' +export * from './HashFacade' +export * from './RequestFacade' +export * from './ResponseFacade' export * from './RouteFacade' diff --git a/packages/support/src/Helpers/Obj.ts b/packages/support/src/Helpers/Obj.ts index c7b12984..0085b65e 100644 --- a/packages/support/src/Helpers/Obj.ts +++ b/packages/support/src/Helpers/Obj.ts @@ -420,7 +420,7 @@ export function data_forget ( * @param allowArray * @returns */ -export function isPlainObject

(value: P, allowArray?: boolean): value is P { +export function isPlainObject

> (value: unknown, allowArray?: boolean): value is P { return ( value !== null && typeof value === 'object' && @@ -595,13 +595,33 @@ export class Obj { }) } + /** + * Checks if an object is not empty + * + * @param obj + * @returns + */ + static isNotEmpty> (obj: T): obj is T { + return Object.keys(obj).length >= 1 + } + + /** + * Checks if an object is empty + * + * @param obj + * @returns + */ + static isEmpty> (obj: T) { + return !this.isNotEmpty(obj) + } + /** * Check if an object is associative (has at least one non-numeric key). * * @param obj * @returns */ - static isAssoc (obj: unknown): obj is Record { + static isAssoc> (obj: unknown): obj is T { if (!Obj.accessible(obj)) return false return Object.keys(obj).some(k => isNaN(Number(k))) } @@ -613,10 +633,54 @@ export class Obj { * @param allowArray * @returns */ - static isPlainObject

(value: P, allowArray?: boolean): value is P { + static isPlainObject> (value: unknown, allowArray?: boolean): value is T { return isPlainObject(value, allowArray) } + /** + * Removes the last element from an object and returns it. + * If the object is empty, undefined is returned and the object is not modified. + * + * @param obj + * @returns + */ + static pop> ( + obj: T + ): T[keyof T] | undefined { + const keys = Object.keys(obj) as Array + + if (!keys.length) return undefined + + const lastKey = keys[keys.length - 1] + const value = obj[lastKey] + + delete obj[lastKey] + + return value + } + + /** + * Removes the first element from an array and returns it. + * If the array is empty, undefined is returned and the array is not modified. + * + * @param obj + * @returns + */ + static shift> ( + obj: T + ): T[keyof T] | undefined { + const keys = Object.keys(obj) as Array + + if (!keys.length) return undefined + + const firstKey = keys[0] + const value = obj[firstKey] + + delete obj[firstKey] + + return value + } + /** * Add a prefix to all keys of the object. * @@ -659,6 +723,21 @@ export class Obj { return parts.join('&') } + /** + * If the given value is not an associative object, wrap it in one. + * + * @param value + */ + static wrap (value: N | Record | N[]): Record { + value = typeof value === 'string' || typeof value === 'number' ? this.arrayWrap(value) : value + + if (Array.isArray(value)) { + value = Object.fromEntries(value.map((e, i) => [i, e])) + } + + return value as Record + } + /** * undot * @@ -670,7 +749,20 @@ export class Obj { * @param obj * @returns */ - undot (obj: Record): Record { + static undot (obj: Record): Record { return undot(obj) } + + /** + * If the given value is not an array and not null, wrap it in one. + * + * Non-array values become [value]; null/undefined becomes []. + * + * @param value + * @returns + */ + private static arrayWrap (value: T | T[] | null | undefined): T[] { + if (value === null || value === undefined) return [] + return Array.isArray(value) ? value : [value] + } } diff --git a/packages/support/src/Helpers/Time.ts b/packages/support/src/Helpers/Time.ts index 0b64898c..25356d81 100644 --- a/packages/support/src/Helpers/Time.ts +++ b/packages/support/src/Helpers/Time.ts @@ -1,4 +1,4 @@ -import dayjs, { ConfigType, Dayjs, OpUnitType, OptionType } from 'dayjs' +import dayjs, { ConfigType, Dayjs, OpUnitType, OptionType, QUnitType } from 'dayjs' import advancedFormat from 'dayjs/plugin/advancedFormat.js' import customParseFormat from 'dayjs/plugin/customParseFormat.js' @@ -9,6 +9,7 @@ import relativeTime from 'dayjs/plugin/relativeTime.js' import timezone from 'dayjs/plugin/timezone.js' import utc from 'dayjs/plugin/utc.js' +// dayjs.extend(duration) dayjs.extend(utc) dayjs.extend(timezone) dayjs.extend(dayOfYear) @@ -31,16 +32,20 @@ export function format (date: ConfigType, fmt: string) { } // export interface Time extends Dayjs { } -const TimeClass = class { } as { new(date?: dayjs.ConfigType): Dayjs } & typeof Dayjs +const TimeClass = class { } as { new(date?: any): Dayjs } & typeof Dayjs export class DateTime extends TimeClass { private instance: Dayjs - constructor(config?: ConfigType) - constructor(config?: ConfigType, format?: OptionType, locale?: boolean) - constructor(config?: ConfigType, format?: OptionType, locale?: string | boolean, strict?: boolean) { + constructor(config?: ConfigType | DateTime) + constructor(config?: ConfigType | DateTime, format?: OptionType, locale?: boolean) + constructor(config?: ConfigType | DateTime, format?: OptionType, locale?: string | boolean, strict?: boolean) { super(config) + if (config instanceof DateTime) { + config = config.instance + } + this.instance = dayjs(config, format, locale as never, strict) return new Proxy(this, { get: (target, prop, receiver) => { @@ -78,6 +83,22 @@ export class DateTime extends TimeClass { return new DateTime(this.tz(timezone, keepLocalTime)) } + /** + * Returns a cloned Day.js object with a specified amount of time added. + * ``` + * dayjs().add(7, 'day')// => Dayjs + * ``` + * Units are case insensitive, and support plural and short forms. + * + * Docs: https://day.js.org/docs/en/manipulate/add + * + * @alias dayjs().add() + */ + // @ts-expect-error plugin conflict, safe to ignore + add (value: number, unit?: dayjs.ManipulateType | undefined) { + return new DateTime(this.instance.add(value, unit)) + } + /** * End time of a specific unit. * @@ -87,6 +108,33 @@ export class DateTime extends TimeClass { return this.endOf(unit) } + /** + * This indicates the difference between two date-time in the specified unit. + * + * To get the difference in milliseconds, use `dayjs#diff` + * ``` + * const date1 = dayjs('2019-01-25') + * const date2 = dayjs('2018-06-05') + * date1.diff(date2) // 20214000000 default milliseconds + * date1.diff() // milliseconds to current time + * ``` + * + * To get the difference in another unit of measurement, pass that measurement as the second argument. + * ``` + * const date1 = dayjs('2019-01-25') + * date1.diff('2018-06-05', 'month') // 7 + * ``` + * Units are case insensitive, and support plural and short forms. + * + * Docs: https://day.js.org/docs/en/display/difference + */ + diff (date?: string | number | Dayjs | DateTime | Date | null | undefined, unit?: QUnitType | OpUnitType, float?: boolean) { + if (date instanceof DateTime) { + date = date.instance + } + return this.instance.diff(date, unit, float) + } + /** * Get the first day of the month of the given date * @@ -100,6 +148,21 @@ export class DateTime extends TimeClass { return template ? this.format(phpToDayjsTokens(template)) : this.format() } + /** + * This returns the Unix timestamp (the number of **seconds** since the Unix Epoch) of the Day.js object. + * ``` + * dayjs('2019-01-25').unix() // 1548381600 + * ``` + * This value is floored to the nearest second, and does not include a milliseconds component. + * + * Docs: https://day.js.org/docs/en/display/unix-timestamp + * + * @alias dayjs('2019-01-25').unix() + */ + getTimestamp () { + return this.instance.unix() + } + /** * Get the last day of the month of the given date * @@ -198,6 +261,18 @@ export class DateTime extends TimeClass { return new DateTime(time).randomTime(startHour, startMinute, endHour, endMinute) } + /** + * Use a dayjs plugin + * + * @param plugin + * @param option + * @returns + */ + static plugin (plugin: dayjs.PluginFunc, option?: T | undefined): typeof dayjs { + dayjs.extend(plugin, option) + return dayjs + } + /** * Get the first day of the month of the given date * diff --git a/packages/support/src/Traits/InteractsWithTime.ts b/packages/support/src/Traits/InteractsWithTime.ts new file mode 100644 index 00000000..e30ce522 --- /dev/null +++ b/packages/support/src/Traits/InteractsWithTime.ts @@ -0,0 +1,67 @@ +import { DateTime } from '../Helpers/Time' +import duration from 'dayjs/plugin/duration.js' +import { trait } from '@h3ravel/shared' + +export const InteractsWithTime = trait((Base) => class InteractsWithTime extends Base { + /** + * Get the number of seconds until the given DateTime. + * + * @param delay + */ + secondsUntil (delay: DateTime | number) { + delay = this.parseDateInterval(delay) + + return delay instanceof DateTime + ? Math.max(0, delay.getTimestamp() - this.currentTime()) + : Number(delay) + } + + /** + * Get the "available at" UNIX timestamp. + * + * @param delay + */ + availableAt (delay: DateTime | number = 0) { + delay = this.parseDateInterval(delay) + + return delay instanceof DateTime + ? delay.getTimestamp() + : DateTime.now().add(delay, 'seconds').getTimestamp() + } + + /** + * If the given value is an interval, convert it to a DateTime instance. + * + * @param delay + */ + parseDateInterval (delay: DateTime | number) { + if (typeof delay === 'number') { + delay = DateTime.now().add(delay) + } + + return delay + } + + /** + * Get the current system time as a UNIX timestamp. + */ + currentTime () { + return DateTime.now().getTimestamp() + } + + /** + * Given a start time, format the total run time for human readability. + * + * @param startTime + * @param endTime + */ + runTimeForHumans (startTime: number, endTime?: number): string { + endTime ??= Date.now() + + const runTime = endTime - startTime + + if (runTime < 1000) return `${runTime.toFixed(2)}ms` + + return DateTime.plugin(duration).duration(runTime).humanize() + } +}) \ No newline at end of file diff --git a/packages/support/src/Traits/index.ts b/packages/support/src/Traits/index.ts new file mode 100644 index 00000000..55e93e80 --- /dev/null +++ b/packages/support/src/Traits/index.ts @@ -0,0 +1 @@ +export * from './InteractsWithTime' diff --git a/packages/support/tsdown.config.ts b/packages/support/tsdown.config.ts index 77b7da14..4ffcc4c8 100644 --- a/packages/support/tsdown.config.ts +++ b/packages/support/tsdown.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ clean: true, entry: { index: 'src/index.ts', + traits: 'src/Traits/index.ts', facades: 'src/Facades/index.ts', }, }) diff --git a/packages/url/src/app.globals.d.ts b/packages/url/src/app.globals.d.ts index 6ff84127..c9db5faa 100644 --- a/packages/url/src/app.globals.d.ts +++ b/packages/url/src/app.globals.d.ts @@ -4,11 +4,6 @@ import { RequestAwareHelpers } from '.' export { } declare global { - /** - * Create a URL from a named route - */ - function route (name: string, params?: Record): string; - /** * Create a URL from a controller action */ diff --git a/packages/url/tests/Url.spec.ts b/packages/url/tests/Url.spec.ts index 83317680..35f37363 100644 --- a/packages/url/tests/Url.spec.ts +++ b/packages/url/tests/Url.spec.ts @@ -49,8 +49,8 @@ describe('Url', () => { app = await h3ravel([EventsServiceProvider, HttpServiceProvider, RouteServiceProvider, UrlServiceProvider], process.cwd()) Object.assign(mockApp, app) Object.assign(globalThis, globalThat) - app.make('router').get('path', () => ({ success: true }), 'path') - app.make('router').get('path/index', [ExampleController, 'index'], 'path.index') + app.make('router').get('path', () => ({ success: true })).name('path') + app.make('router').get('path/index', [ExampleController, 'index']).name('path.index') app.fire() }) diff --git a/packages/validation/src/ValidationException.ts b/packages/validation/src/ValidationException.ts index c7d9e683..7341c1e8 100644 --- a/packages/validation/src/ValidationException.ts +++ b/packages/validation/src/ValidationException.ts @@ -1,4 +1,5 @@ -import { IRequest } from '@h3ravel/contracts' +import { IHttpResponse, IRequest } from '@h3ravel/contracts' + import { MessageBag } from './utilities/MessageBag' import { Str } from '@h3ravel/support' import { UnprocessableEntityHttpException } from '@h3ravel/foundation' @@ -18,7 +19,6 @@ export class ValidationException extends UnprocessableEntityHttpException { this.validator = validator this.response = response this.errorBag = errorBag - Object.setPrototypeOf(this, ValidationException.prototype) } diff --git a/packages/validation/src/env.d.ts b/packages/validation/src/env.d.ts new file mode 100644 index 00000000..913c732b --- /dev/null +++ b/packages/validation/src/env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/packages/view/package.json b/packages/view/package.json index 42516bb0..c877001d 100644 --- a/packages/view/package.json +++ b/packages/view/package.json @@ -70,6 +70,7 @@ "vitest": "^2.0.0" }, "peerDependencies": { - "@h3ravel/shared": "workspace:*" + "@h3ravel/shared": "workspace:*", + "@h3ravel/support": "workspace:*" } -} +} \ No newline at end of file diff --git a/packages/view/src/Providers/ViewServiceProvider.ts b/packages/view/src/Providers/ViewServiceProvider.ts index 0e347676..6a1cd9f6 100644 --- a/packages/view/src/Providers/ViewServiceProvider.ts +++ b/packages/view/src/Providers/ViewServiceProvider.ts @@ -1,6 +1,6 @@ import { EdgeViewEngine } from '../EdgeViewEngine' import { Responsable } from '@h3ravel/http' -import { ServiceProvider } from '@h3ravel/core' +import { ServiceProvider } from '@h3ravel/support' /** * View Service Provider @@ -18,7 +18,7 @@ export class ViewServiceProvider extends ServiceProvider { }) // Register the app instance if available - viewEngine.global('app', this.app) + // viewEngine.global('app', this.app) const edge = viewEngine.getEdge() @@ -47,11 +47,6 @@ export class ViewServiceProvider extends ServiceProvider { return response.html(await this.app.make('edge').render(template, data), true) } - /** - * Bind the view method to the global variable space - */ - globalThis.view = view - /** * Dynamically bind the view renderer to the service container. * This allows any part of the request lifecycle to render templates using Edge. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 294b8257..7051f970 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,14 +143,14 @@ catalogs: version: 5.1.4 prod: '@h3ravel/arquebus': - specifier: ^0.7.4 - version: 0.7.4 + specifier: ^0.7.6 + version: 0.7.6 '@h3ravel/collect.js': specifier: ^5.3.3 version: 5.3.3 '@h3ravel/musket': - specifier: ^0.6.8 - version: 0.6.8 + specifier: ^0.6.9 + version: 0.6.9 h3: specifier: 2.0.1-rc.5 version: 2.0.1-rc.5 @@ -272,7 +272,7 @@ importers: dependencies: '@h3ravel/arquebus': specifier: catalog:prod - version: 0.7.4(@types/node@24.9.2)(sqlite3@5.1.7) + version: 0.7.6(@types/node@24.9.2)(sqlite3@5.1.7) '@h3ravel/cache': specifier: workspace:^ version: link:../../packages/cache @@ -308,7 +308,7 @@ importers: version: link:../../packages/mail '@h3ravel/musket': specifier: catalog:prod - version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.9.2) + version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.9.2) '@h3ravel/queue': specifier: workspace:^ version: link:../../packages/queue @@ -382,7 +382,7 @@ importers: dependencies: '@h3ravel/musket': specifier: catalog:prod - version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -413,7 +413,7 @@ importers: version: link:../foundation '@h3ravel/musket': specifier: catalog:prod - version: 0.6.8(@h3ravel/support@0.15.6)(@types/node@24.10.0) + version: 0.6.9(@h3ravel/support@0.15.6)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -463,7 +463,7 @@ importers: devDependencies: '@h3ravel/musket': specifier: catalog:prod - version: 0.6.8(@h3ravel/support@0.16.1)(@types/node@24.10.0) + version: 0.6.9(@h3ravel/support@0.16.1)(@types/node@24.10.0) edge.js: specifier: 'catalog:' version: 6.3.0 @@ -530,7 +530,7 @@ importers: dependencies: '@h3ravel/arquebus': specifier: catalog:prod - version: 0.7.4(@types/node@24.10.0)(sqlite3@5.1.7) + version: 0.7.6(@types/node@24.10.0)(sqlite3@5.1.7) '@h3ravel/core': specifier: workspace:^ version: link:../core @@ -539,7 +539,7 @@ importers: version: link:../filesystem '@h3ravel/musket': specifier: catalog:prod - version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -571,7 +571,7 @@ importers: version: link:../core '@h3ravel/musket': specifier: catalog:prod - version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -587,7 +587,7 @@ importers: dependencies: '@h3ravel/musket': specifier: catalog:prod - version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -610,12 +610,18 @@ importers: packages/hashing: dependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts '@h3ravel/core': specifier: workspace:^ version: link:../core '@h3ravel/foundation': specifier: workspace:^ version: link:../foundation + '@h3ravel/shared': + specifier: workspace:^ + version: link:../shared '@h3ravel/support': specifier: workspace:^ version: link:../support @@ -640,7 +646,7 @@ importers: version: link:../foundation '@h3ravel/musket': specifier: catalog:prod - version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/session': specifier: workspace:^ version: link:../session @@ -701,9 +707,6 @@ importers: '@h3ravel/contracts': specifier: workspace:^ version: link:../contracts - '@h3ravel/core': - specifier: workspace:^ - version: link:../core '@h3ravel/database': specifier: workspace:^ version: link:../database @@ -718,7 +721,7 @@ importers: version: link:../http '@h3ravel/musket': specifier: catalog:prod - version: 0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -870,10 +873,13 @@ importers: version: link:../http '@h3ravel/musket': specifier: catalog:prod - version: 0.6.8(@h3ravel/support@0.16.1)(@types/node@24.10.0) + version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:* version: link:../shared + '@h3ravel/support': + specifier: workspace:* + version: link:../support edge.js: specifier: 'catalog:' version: 6.3.0 @@ -1627,8 +1633,8 @@ packages: '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} - '@h3ravel/arquebus@0.7.4': - resolution: {integrity: sha512-/INp32wRy02H+8JQWUohnqJmXANTQGy2hGDdonemZ/X8I4uj3jGb03+dbt/qiBWSNCk6ypLoMJ/feZPf+oSw+w==} + '@h3ravel/arquebus@0.7.6': + resolution: {integrity: sha512-nBwW/47UBFQX+8qVuCEWA/BxvZBZSnI6oX6Yw5nmts8ddnFS/AMjgfVj5FwfK0tPt2v/An5u1PNL6wfh6LBqbQ==} engines: {node: '>=14', pnpm: '>=4'} hasBin: true @@ -1638,15 +1644,18 @@ packages: '@h3ravel/contracts@0.28.1': resolution: {integrity: sha512-Ub2+5rvabNjMvcxDkVWfBBucXLpVDqCX8/rl3QQwbDpcNilZfcnzw1aHfSuk5XpNUBw4zmxYJGlQkuNS3St+TQ==} - '@h3ravel/musket@0.6.8': - resolution: {integrity: sha512-OCFIi9lxVvnc1fU0QRWYNvbbwWvhRlDEVD19akXBmRxHaOGlhwJc3oF7Fl5F84gSzfBqgQtANbL33Tsqu81SHg==} + '@h3ravel/musket@0.6.9': + resolution: {integrity: sha512-eD4BkSlLuI8EWBAPYIIcGsNxoqZQOPh0pE9b+9ldgfZd75BdDeTUFW80JErPGziALjSwnr0KBIXjcL8x78zRcw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - '@h3ravel/support': ^0.15.6 + '@h3ravel/support': ^0.16.1 '@h3ravel/shared@0.27.7': resolution: {integrity: sha512-vuH/VlpWNoJHvkrvqz6OxObWyMtOwYmz2fDK4HvV9MREGkVEnKjFRUp96Gr/wplVLTAbvKxKaL6QeUZokulw8Q==} + '@h3ravel/shared@0.28.4': + resolution: {integrity: sha512-9D+pdJ5UdLRVau4KdISRDikuUeE2NpqY2SWVgfi/SVNc9R7+eyYbRofZRujWO3H/DvVyJuPA7L6IdxWmdZcQyg==} + '@h3ravel/support@0.15.6': resolution: {integrity: sha512-fAZvxgXotHczhznZhg83FrQfucfJ8XQNNO1xtVQQ8Z7mOCTA+MLIfni0oSaHaqpPf1xpgfpVaXlEmdFu1xcGoQ==} @@ -7048,7 +7057,7 @@ snapshots: '@gar/promisify@1.1.3': optional: true - '@h3ravel/arquebus@0.7.4(@types/node@24.10.0)(sqlite3@5.1.7)': + '@h3ravel/arquebus@0.7.6(@types/node@24.10.0)(sqlite3@5.1.7)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.10.0) '@h3ravel/support': 0.15.6 @@ -7079,7 +7088,7 @@ snapshots: - sqlite3 - supports-color - '@h3ravel/arquebus@0.7.4(@types/node@24.9.2)(sqlite3@5.1.7)': + '@h3ravel/arquebus@0.7.6(@types/node@24.9.2)(sqlite3@5.1.7)': dependencies: '@h3ravel/shared': 0.27.7(@types/node@24.9.2) '@h3ravel/support': 0.15.6 @@ -7118,9 +7127,9 @@ snapshots: transitivePeerDependencies: - crossws - '@h3ravel/musket@0.6.8(@h3ravel/support@0.15.6)(@types/node@24.10.0)': + '@h3ravel/musket@0.6.9(@h3ravel/support@0.15.6)(@types/node@24.10.0)': dependencies: - '@h3ravel/shared': 0.27.7(@types/node@24.10.0) + '@h3ravel/shared': 0.28.4(@types/node@24.10.0) '@h3ravel/support': 0.15.6 chalk: 5.6.2 commander: 14.0.2 @@ -7135,9 +7144,9 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.6.8(@h3ravel/support@0.16.1)(@types/node@24.10.0)': + '@h3ravel/musket@0.6.9(@h3ravel/support@0.16.1)(@types/node@24.10.0)': dependencies: - '@h3ravel/shared': 0.27.7(@types/node@24.10.0) + '@h3ravel/shared': 0.28.4(@types/node@24.10.0) '@h3ravel/support': 0.16.1 chalk: 5.6.2 commander: 14.0.2 @@ -7152,9 +7161,9 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.6.8(@h3ravel/support@packages+support)(@types/node@24.10.0)': + '@h3ravel/musket@0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0)': dependencies: - '@h3ravel/shared': 0.27.7(@types/node@24.10.0) + '@h3ravel/shared': 0.28.4(@types/node@24.10.0) '@h3ravel/support': link:packages/support chalk: 5.6.2 commander: 14.0.2 @@ -7169,9 +7178,9 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.6.8(@h3ravel/support@packages+support)(@types/node@24.9.2)': + '@h3ravel/musket@0.6.9(@h3ravel/support@packages+support)(@types/node@24.9.2)': dependencies: - '@h3ravel/shared': 0.27.7(@types/node@24.9.2) + '@h3ravel/shared': 0.28.4(@types/node@24.9.2) '@h3ravel/support': link:packages/support chalk: 5.6.2 commander: 14.0.2 @@ -7212,6 +7221,32 @@ snapshots: - '@types/node' - crossws + '@h3ravel/shared@0.28.4(@types/node@24.10.0)': + dependencies: + '@inquirer/prompts': 7.9.0(@types/node@24.10.0) + chalk: 5.6.2 + edge.js: 6.3.0 + escalade: 3.2.0 + h3: 2.0.1-rc.5 + inquirer-autocomplete-standalone: 0.8.1 + preferred-pm: 4.1.1 + transitivePeerDependencies: + - '@types/node' + - crossws + + '@h3ravel/shared@0.28.4(@types/node@24.9.2)': + dependencies: + '@inquirer/prompts': 7.9.0(@types/node@24.9.2) + chalk: 5.6.2 + edge.js: 6.3.0 + escalade: 3.2.0 + h3: 2.0.1-rc.5 + inquirer-autocomplete-standalone: 0.8.1 + preferred-pm: 4.1.1 + transitivePeerDependencies: + - '@types/node' + - crossws + '@h3ravel/support@0.15.6': dependencies: dayjs: 1.11.19 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c32771bf..c91f6723 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -71,9 +71,9 @@ catalog: catalogs: prod: - '@h3ravel/arquebus': ^0.7.4 + '@h3ravel/arquebus': ^0.7.6 '@h3ravel/collect.js': ^5.3.3 - '@h3ravel/musket': ^0.6.8 + '@h3ravel/musket': ^0.6.9 h3: 2.0.1-rc.5 ignoredBuiltDependencies: diff --git a/tsconfig.base.json b/tsconfig.base.json index 17aab07b..abe3acaf 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,6 +17,7 @@ "@h3ravel/shared": ["packages/shared/src/index.ts"], "@h3ravel/support": ["packages/support/src/index.ts"], "@h3ravel/support/facades": ["packages/support/src/Facades/index.ts"], + "@h3ravel/support/traits": ["packages/support/src/Traits/index.ts"], "@h3ravel/url": ["packages/url/src/index.ts"], "@h3ravel/view": ["packages/view/src/index.ts"], "@h3ravel/session": ["packages/session/src/index.ts"], From aa372097aede0802a666358389d41801db8e2825 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Fri, 16 Jan 2026 03:45:20 +0100 Subject: [PATCH 23/28] feat: Refactor URL handling and update related interfaces --- .../Http/Controllers/UrlExampleController.ts | 1 - packages/contracts/src/Http/IHttpRequest.ts | 3 +- packages/contracts/src/Url/IUrlGenerator.ts | 4 +- packages/core/src/Application.ts | 4 +- .../Contracts/ServiceProviderConstructor.ts | 2 - packages/core/src/H3ravel.ts | 6 +-- packages/core/src/env.d.ts | 1 + packages/core/tsconfig.json | 3 +- packages/foundation/env.d.ts | 2 - .../src/Bootstrapers/RegisterHelpers.ts | 12 ----- .../src/Configuration/AppBuilder.ts | 2 +- .../foundation/src/Console/ConsoleKernel.ts | 9 ++-- packages/foundation/src/Helpers.ts | 13 ++++-- packages/foundation/src/Http/Kernel.ts | 2 - packages/foundation/src/app.globals.d.ts | 35 +++++++++++---- packages/foundation/src/index.ts | 1 - .../router/src/Commands/RouteListCommand.ts | 45 ++++++++++--------- packages/router/src/UrlGenerator.ts | 8 ++-- packages/shared/src/Utils/Logger.ts | 4 +- packages/shared/src/Utils/PathLoader.ts | 2 +- packages/support/src/Facades/URLFacade.ts | 10 +++++ packages/support/src/Facades/index.ts | 1 + packages/url/src/Url.ts | 20 ++------- packages/url/src/app.globals.d.ts | 7 --- pnpm-lock.yaml | 36 +++++++-------- pnpm-workspace.yaml | 2 +- 26 files changed, 118 insertions(+), 117 deletions(-) create mode 100644 packages/core/src/env.d.ts delete mode 100644 packages/foundation/env.d.ts delete mode 100644 packages/foundation/src/Bootstrapers/RegisterHelpers.ts create mode 100644 packages/support/src/Facades/URLFacade.ts diff --git a/examples/basic-app/src/app/Http/Controllers/UrlExampleController.ts b/examples/basic-app/src/app/Http/Controllers/UrlExampleController.ts index 60d907f9..477e109b 100644 --- a/examples/basic-app/src/app/Http/Controllers/UrlExampleController.ts +++ b/examples/basic-app/src/app/Http/Controllers/UrlExampleController.ts @@ -35,7 +35,6 @@ export class UrlExampleController extends Controller { currentUrl: url().current(), fullUrl: url().full(), previousUrl: url().previous(), - queryParams: url().query(), // Route-based URLs (demonstrating with existing routes) routeUrl: route('url.examples'), diff --git a/packages/contracts/src/Http/IHttpRequest.ts b/packages/contracts/src/Http/IHttpRequest.ts index e351a850..e5f6c73b 100644 --- a/packages/contracts/src/Http/IHttpRequest.ts +++ b/packages/contracts/src/Http/IHttpRequest.ts @@ -8,7 +8,6 @@ import { IServerBag } from './IServerBag' import { IUrl } from '../Url/IUrl' import { InputBag } from './IInputBag' import { RequestMethod } from '../Utilities/Utilities' -import { RouteParams } from '../Url/Utils' export abstract class IHttpRequest { /** @@ -31,7 +30,7 @@ export abstract class IHttpRequest { /** * Query string parameters (GET). */ - abstract _query: RouteParams + abstract _query: InputBag /** * Server and execution environment parameters */ diff --git a/packages/contracts/src/Url/IUrlGenerator.ts b/packages/contracts/src/Url/IUrlGenerator.ts index 5ab87cc0..f56dbf81 100644 --- a/packages/contracts/src/Url/IUrlGenerator.ts +++ b/packages/contracts/src/Url/IUrlGenerator.ts @@ -109,7 +109,7 @@ export abstract class IUrlGenerator { abstract hasValidSignature (request: IRequest): boolean; - abstract route (name: string, parameters?: GenericObject, absolute?: boolean): string; + abstract route (name: string, parameters?: RouteParams, absolute?: boolean): string; /** * Get the URL for a given route instance. @@ -118,7 +118,7 @@ export abstract class IUrlGenerator { * @param parameters * @param absolute */ - abstract toRoute (route: IRoute, parameters?: GenericObject, absolute?: boolean): string; + abstract toRoute (route: IRoute, parameters?: RouteParams, absolute?: boolean): string; /** * Combine root and path into a final URL. diff --git a/packages/core/src/Application.ts b/packages/core/src/Application.ts index c9f68935..7b43a5d6 100644 --- a/packages/core/src/Application.ts +++ b/packages/core/src/Application.ts @@ -619,8 +619,6 @@ export class Application extends Container implements IApplication { * @param bootstrappers */ async bootstrapWith (bootstrappers: ConcreteConstructor[]): Promise { - this.bootstrapped = true - for (const bootstrapper of bootstrappers) { if (this.has('app.events')) this.make('app.events').dispatch('bootstrapping: ' + bootstrapper.name, [this]) @@ -630,6 +628,8 @@ export class Application extends Container implements IApplication { if (this.has('app.events')) this.make('app.events').dispatch('bootstrapped: ' + bootstrapper.name, [this]) } + + this.bootstrapped = true } /** diff --git a/packages/core/src/Contracts/ServiceProviderConstructor.ts b/packages/core/src/Contracts/ServiceProviderConstructor.ts index 869b4f19..819b5ad0 100644 --- a/packages/core/src/Contracts/ServiceProviderConstructor.ts +++ b/packages/core/src/Contracts/ServiceProviderConstructor.ts @@ -1,5 +1,3 @@ -/// - import type { Application, ServiceProvider } from '..' import { IServiceProvider } from '@h3ravel/contracts' diff --git a/packages/core/src/H3ravel.ts b/packages/core/src/H3ravel.ts index 27575e4d..bc57c896 100644 --- a/packages/core/src/H3ravel.ts +++ b/packages/core/src/H3ravel.ts @@ -3,6 +3,7 @@ import { Application, OServiceProvider } from '.' import { EntryConfig } from './Contracts/H3ravelContract' import { Facades } from '@h3ravel/support/facades' import { H3 } from 'h3' +import { Helpers } from '@h3ravel/foundation' import { IApplication } from '@h3ravel/contracts' /** @@ -49,9 +50,8 @@ export const h3ravel = async ( app.setH3App(h3App) app.singleton(IApplication, () => app) - if (!Facades.getApplication()) { - Facades.setApplication(app) - } + if (!Facades.getApplication()) Facades.setApplication(app) + if (!Helpers.isLoaded()) Helpers.load(app) await app.handleRequest(config) } catch { diff --git a/packages/core/src/env.d.ts b/packages/core/src/env.d.ts new file mode 100644 index 00000000..913c732b --- /dev/null +++ b/packages/core/src/env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 11f8ffa8..8bedfcfe 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "outDir": "dist", - "types": ["./src/app.globals"] + "outDir": "dist" }, "exclude": ["dist", "node_modules"] } diff --git a/packages/foundation/env.d.ts b/packages/foundation/env.d.ts deleted file mode 100644 index 50174eeb..00000000 --- a/packages/foundation/env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// \ No newline at end of file diff --git a/packages/foundation/src/Bootstrapers/RegisterHelpers.ts b/packages/foundation/src/Bootstrapers/RegisterHelpers.ts deleted file mode 100644 index 5762842e..00000000 --- a/packages/foundation/src/Bootstrapers/RegisterHelpers.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IApplication, IBootstraper } from '@h3ravel/contracts' - -import { Helpers } from '../Helpers' - -export class RegisterHelpers extends IBootstraper { - /** - * Bootstrap application helpers. - */ - bootstrap (app: IApplication) { - Helpers.load(app) - } -} \ No newline at end of file diff --git a/packages/foundation/src/Configuration/AppBuilder.ts b/packages/foundation/src/Configuration/AppBuilder.ts index d5f1d476..bc529334 100644 --- a/packages/foundation/src/Configuration/AppBuilder.ts +++ b/packages/foundation/src/Configuration/AppBuilder.ts @@ -151,7 +151,7 @@ export class AppBuilder { * @param apiPrefix * @param then */ - protected buildRoutingCallback ({ web, api, health, apiPrefix, then }: { + protected buildRoutingCallback ({ web, api, apiPrefix, then }: { web?: string | string[]; api?: string | string[]; health?: string; diff --git a/packages/foundation/src/Console/ConsoleKernel.ts b/packages/foundation/src/Console/ConsoleKernel.ts index 13019e0b..eb12d715 100644 --- a/packages/foundation/src/Console/ConsoleKernel.ts +++ b/packages/foundation/src/Console/ConsoleKernel.ts @@ -10,7 +10,6 @@ import { Injectable } from '..' import { KeyGenerateCommand } from './Commands/KeyGenerateCommand' import { MakeCommand } from './Commands/MakeCommand' import { PostinstallCommand } from './Commands/PostinstallCommand' -import { RegisterHelpers } from '../Bootstrapers/RegisterHelpers' import { Terminating } from '../Core/Events/Terminating' import { altLogo } from './logo' import { createRequire } from 'module' @@ -28,7 +27,6 @@ export class ConsoleKernel extends CKernel { * The bootstrap classes for the application. */ #bootstrappers: ConcreteConstructor[] = [ - RegisterHelpers, RegisterFacades, BootProviders ] @@ -111,10 +109,9 @@ export class ConsoleKernel extends CKernel { */ async handle () { this.commandStartedAt = DateTime.now() + await this.bootstrap() try { - await this.bootstrap() - const status = await this.getConsole().run(true); ['SIGINT', 'SIGTERM', 'SIGTSTP'].forEach(sig => process.on(sig, () => { @@ -251,6 +248,10 @@ export class ConsoleKernel extends CKernel { hideMusketInfo: true, // discoveryPaths is commented out so we can rely on the console kernel to provide it // discoveryPaths: [app_path('Console/Commands/*.js').replace('/src/', this.DIST_DIR)], + exceptionHandler: (e) => { + this.reportException(e) + this.renderException(e) + } }) .setPackages([ { name: '@h3ravel/core', alias: 'H3ravel Framework' }, diff --git a/packages/foundation/src/Helpers.ts b/packages/foundation/src/Helpers.ts index 542cc579..83ba3754 100644 --- a/packages/foundation/src/Helpers.ts +++ b/packages/foundation/src/Helpers.ts @@ -1,13 +1,18 @@ -import { IApplication, IUrlGenerator } from '@h3ravel/contracts' +import { IApplication, IUrlGenerator, RouteParams } from '@h3ravel/contracts' export class Helpers { - static app: IApplication + private static app: IApplication + private static helpersLoaded: boolean static load (app: IApplication) { this.app = app this.loadHelpers() } + static isLoaded () { + return this.helpersLoaded + } + /** * Get the available app instance. * @@ -104,7 +109,7 @@ export class Helpers { * @param absolute */ private static route () { - return (name: string, parameters?: (string | number)[], absolute = true) => { + return (name: string, parameters?: RouteParams, absolute = true) => { return this.app.make('url').route(name, parameters, absolute) } } @@ -124,7 +129,7 @@ export class Helpers { } private static url () { - return (path?: string, parameters: (string | number)[] = [], secure?: boolean): IUrlGenerator | string => { + return (path?: string, parameters: (string | number)[] = [], secure?: boolean): any => { if (!path) { return this.app.make(IUrlGenerator) } diff --git a/packages/foundation/src/Http/Kernel.ts b/packages/foundation/src/Http/Kernel.ts index d2072c93..62e63ad6 100644 --- a/packages/foundation/src/Http/Kernel.ts +++ b/packages/foundation/src/Http/Kernel.ts @@ -7,7 +7,6 @@ import { Facades } from '@h3ravel/support/facades' import { Injectable } from '..' import { InteractsWithTime } from '@h3ravel/support/traits' import { RegisterFacades } from '../Bootstrapers/RegisterFacades' -import { RegisterHelpers } from '../Bootstrapers/RegisterHelpers' import { RequestHandled } from './Events/RequestHandled' import { Terminating } from '../Core/Events/Terminating' @@ -17,7 +16,6 @@ export class Kernel extends mix(IKernel, use(InteractsWithTime)) { * The bootstrap classes for the application. */ #bootstrappers: ConcreteConstructor[] = [ - RegisterHelpers, RegisterFacades ] diff --git a/packages/foundation/src/app.globals.d.ts b/packages/foundation/src/app.globals.d.ts index 78ece55a..5e26434f 100644 --- a/packages/foundation/src/app.globals.d.ts +++ b/packages/foundation/src/app.globals.d.ts @@ -42,19 +42,38 @@ declare global { /** * Global env variable - * - * @param path */ function env (): NodeJS.ProcessEnv; - function env (key: T, def?: any): any; + /** + * Global env variable + * + * @param key + * @param defaultValue + */ + function env (key: T, defaultValue?: any): any; /** - * Load config option + * Load config options */ function config> (): X; - function config, T extends Extract> (key: T, def?: any): X[T]; + /** + * Load config option + * + * @param key + * @param defaultValue + */ + function config, T extends Extract> (key: T, defaultValue?: any): X[T]; + /** + * Load config option + * + * @param key + */ function config> (key: T): void; + /** + * Generate a URL instance. + */ + function url (): IUrlGenerator; /** * Generate a URL for the current application instance. * @@ -62,7 +81,7 @@ declare global { * @param parameters * @param secure */ - function url (path?: string, parameters: (string | number)[] = [], secure?: boolean): IUrlGenerator | string + function url (path?: string, parameters: (string | number)[] = [], secure?: boolean): string; /** * Get the URL to a named route. @@ -86,9 +105,9 @@ declare global { * Get static asset * * @param asset Name of the asset to serve - * @param def Default asset to serve if asset does not exist + * @param defaultValue Default asset to serve if asset does not exist */ - function asset (asset: string, def: string): string + function asset (asset: string, defaultValue?: string): string /** * Get an instance of the Request class diff --git a/packages/foundation/src/index.ts b/packages/foundation/src/index.ts index 7ea1c709..424da96c 100644 --- a/packages/foundation/src/index.ts +++ b/packages/foundation/src/index.ts @@ -2,7 +2,6 @@ export * from './Helpers' export * from './Adapters/InMemoryRateLimiter' export * from './Bootstrapers/BootProviders' export * from './Bootstrapers/RegisterFacades' -export * from './Bootstrapers/RegisterHelpers' export * from './Configuration/AppBuilder' export * from './Configuration/Middleware' export * from './Console/ConsoleKernel' diff --git a/packages/router/src/Commands/RouteListCommand.ts b/packages/router/src/Commands/RouteListCommand.ts index 1171f57d..238f1ae0 100644 --- a/packages/router/src/Commands/RouteListCommand.ts +++ b/packages/router/src/Commands/RouteListCommand.ts @@ -1,4 +1,4 @@ -import { ClassicRouteDefinition, IApplication, RouteMethod } from '@h3ravel/contracts' +import { IApplication, RouteMethod } from '@h3ravel/contracts' import { Logger, LoggerChalk } from '@h3ravel/shared' import { Command } from '@h3ravel/musket' @@ -28,10 +28,10 @@ export class RouteListCommand extends Command { * Execute the console command. */ public async handle (this: any) { - console.log('') + this.newLine() const command = (this.dictionary.baseCommand ?? this.dictionary.name) - await this[command]() + await Reflect.apply(this[command], this, []) } /** @@ -41,32 +41,37 @@ export class RouteListCommand extends Command { /** * Sort the routes alphabetically */ - const list = [...(this.app.make('app.routes') as ClassicRouteDefinition[])].sort((a, b) => { + const list = this.app.make('router').getRoutes().getRoutes().sort((a, b) => { if (a.path === '/' && b.path !== '/') return -1 if (b.path === '/' && a.path !== '/') return 1 return a.path.localeCompare(b.path) - }).filter(e => !['head', 'patch'].includes(e.method)) + }) - /** - * Log the route list - */ + // /** + // * Log the route list + // */ list.forEach(route => { - const path = route.path === '/' - ? route.path - : Logger.log((route.path.slice(1)).split('/').map(e => [ - (e.includes(':') ? Logger.log('/', 'white', false) : '') + e, - e.startsWith(':') ? 'yellow' : 'white' - ] as [string, LoggerChalk]), '', false) + const uri = route.uri() + const name = route.getName() ?? '' + const formatedPath = uri === '/' + ? uri + : uri + .split('/') + .map(e => [e, /\{.*\}/.test(e) ? 'yellow' : 'white'] as [string, LoggerChalk]) + .reduce((acc, [segment, color], i) => { + return acc + (i > 0 ? Logger.log('/', 'white', false) : '') + Logger.log(segment, color, false) + }, '') - const method = (route.method.startsWith('/') ? route.method.slice(1) : route.method).toUpperCase() as RouteMethod - const name = route.signature[1] ? [route.name ?? '', route.name ? '›' : '', route.signature.join('@')].join(' ') : '' - const desc = Logger.describe( - Logger.log(Logger.log(method + this.pair(method), this.color(method), false), 'green', false), path, 15, false - ) - return Logger.twoColumnDetail(desc.join(''), name) + const formatedMethod = route.getMethods().map(method => Logger.log(method, this.color(method), false)).join(Logger.log('|', 'gray', false)) + const formatedName = route.action.controller ? [name, name !== '' ? '›' : '', route.action.controller].join(' ') : name + const desc = Logger.describe(Logger.log(formatedMethod, 'green', false), formatedPath, 15, false) + return Logger.twoColumnDetail(desc.join(''), formatedName) }) + + this.newLine(2) + Logger.split('', Logger.log(`Showing [${list.length}] routes`, ['blue', 'bold'], false), 'info', false, false, ' ') } /** diff --git a/packages/router/src/UrlGenerator.ts b/packages/router/src/UrlGenerator.ts index d5de657f..14ad1371 100644 --- a/packages/router/src/UrlGenerator.ts +++ b/packages/router/src/UrlGenerator.ts @@ -262,7 +262,7 @@ export class UrlGenerator extends IUrlGenerator { * @param absolute * @returns */ - route (name: string, parameters: GenericObject = {}, absolute = true): string { + route (name: string, parameters: RouteParams = {}, absolute = true): string { const route = this.routes.getByName(name) if (route != null) { @@ -284,7 +284,7 @@ export class UrlGenerator extends IUrlGenerator { * @param parameters * @param absolute */ - toRoute (route: IRoute, parameters: GenericObject = {}, absolute: boolean = true) { + toRoute (route: IRoute, parameters: RouteParams = {}, absolute: boolean = true) { return this.routeUrl().to( route, parameters, @@ -510,6 +510,8 @@ export class UrlGenerator extends IUrlGenerator { * Get the previous URL from the session if possible. */ protected getPreviousUrlFromSession () { - return this.getSession()?.previousUrl() + // TODO: Implement session features to get previous URL + // return this.getSession()?.previousUrl() + return '' } } diff --git a/packages/shared/src/Utils/Logger.ts b/packages/shared/src/Utils/Logger.ts index eb639efb..ec68bd10 100644 --- a/packages/shared/src/Utils/Logger.ts +++ b/packages/shared/src/Utils/Logger.ts @@ -78,11 +78,11 @@ export class Logger { * @param exit * @param preserveCol */ - static split (name: string, value: string, status?: 'success' | 'info' | 'error', exit = false, preserveCol = false) { + static split (name: string, value: string, status?: 'success' | 'info' | 'error', exit = false, preserveCol = false, spacer = '.') { status ??= 'info' const color = { success: chalk.bgGreen, info: chalk.bgBlue, error: chalk.bgRed } - const [_name, dots, val] = this.twoColumnDetail(name, value, false) + const [_name, dots, val] = this.twoColumnDetail(name, value, false, spacer) console.log(this.textFormat(_name, color[status], preserveCol), dots, val) diff --git a/packages/shared/src/Utils/PathLoader.ts b/packages/shared/src/Utils/PathLoader.ts index 48ad2ed7..0c667e7c 100644 --- a/packages/shared/src/Utils/PathLoader.ts +++ b/packages/shared/src/Utils/PathLoader.ts @@ -59,7 +59,7 @@ export class PathLoader { } distPath (path: string, skipExt = false) { - path = path.replace('/src/', `/${process.env.DIST_DIR ?? 'src'}/`.replace(/([^:]\/)\/+/g, '$1')) + path = path.replace('/src/', `/${process.env.DIST_DIR ?? '.h3ravel/serve'}/`.replace(/([^:]\/)\/+/g, '$1')) if (!skipExt) { path = path.replace(/\.(ts|tsx|mts|cts)$/, '.js') diff --git a/packages/support/src/Facades/URLFacade.ts b/packages/support/src/Facades/URLFacade.ts new file mode 100644 index 00000000..4faf568f --- /dev/null +++ b/packages/support/src/Facades/URLFacade.ts @@ -0,0 +1,10 @@ +import { Facades } from './Facades' +import { IUrlGenerator } from '@h3ravel/contracts' + +class URLFacade extends Facades { + protected static getFacadeAccessor () { + return 'url' + } +} + +export const URL = URLFacade.createFacade() \ No newline at end of file diff --git a/packages/support/src/Facades/index.ts b/packages/support/src/Facades/index.ts index 17b1bf1f..469ff0e9 100644 --- a/packages/support/src/Facades/index.ts +++ b/packages/support/src/Facades/index.ts @@ -3,3 +3,4 @@ export * from './HashFacade' export * from './RequestFacade' export * from './ResponseFacade' export * from './RouteFacade' +export * from './URLFacade' diff --git a/packages/url/src/Url.ts b/packages/url/src/Url.ts index b5c13bf5..b9896ed2 100644 --- a/packages/url/src/Url.ts +++ b/packages/url/src/Url.ts @@ -86,20 +86,8 @@ export class Url { throw new Error('Application instance required for route generation') } - // Use (app as any).make to avoid TS error if make is not typed on Application - const router = app.make('router') - if (!router) { - throw new Error('Router not available or does not support route generation') - } - - if (typeof router.getRoutes !== 'function') { - throw new Error('Router does not support route generation') - } + const routeUrl = app.make('url').route(name, params) - const routeUrl = router.getRoutes().getByName(name)?.uri() - // TODO: Provide route params - // const routeUrl = router.route(name, params) - void params if (!routeUrl) { throw new Error(`Route "${name}" not found`) } @@ -115,8 +103,7 @@ export class Url { params: TParams = {} as TParams, app?: IApplication ): Url { - const url = Url.route(name, params, app) - return url.withSignature(app) + return Url.route(name, params, app).withSignature(app) } /** @@ -128,8 +115,7 @@ export class Url { expiration: number, app?: IApplication ): Url { - const url = Url.route(name, params, app) - return url.withSignature(app, expiration) + return Url.route(name, params, app).withSignature(app, expiration) } /** diff --git a/packages/url/src/app.globals.d.ts b/packages/url/src/app.globals.d.ts index c9db5faa..8e23e002 100644 --- a/packages/url/src/app.globals.d.ts +++ b/packages/url/src/app.globals.d.ts @@ -1,5 +1,4 @@ import { ExtractClassMethods } from '@h3ravel/shared' -import { RequestAwareHelpers } from '.' export { } @@ -11,10 +10,4 @@ declare global { controller: string | [C, methodName: ExtractClassMethods>], params?: Record ): string; - - /** - * Get request-aware URL helpers - */ - function url (): RequestAwareHelpers; - function url (path: string): string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7051f970..ecdde7c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,8 +149,8 @@ catalogs: specifier: ^5.3.3 version: 5.3.3 '@h3ravel/musket': - specifier: ^0.6.9 - version: 0.6.9 + specifier: ^0.6.10 + version: 0.6.10 h3: specifier: 2.0.1-rc.5 version: 2.0.1-rc.5 @@ -308,7 +308,7 @@ importers: version: link:../../packages/mail '@h3ravel/musket': specifier: catalog:prod - version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.9.2) + version: 0.6.10(@h3ravel/support@packages+support)(@types/node@24.9.2) '@h3ravel/queue': specifier: workspace:^ version: link:../../packages/queue @@ -382,7 +382,7 @@ importers: dependencies: '@h3ravel/musket': specifier: catalog:prod - version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.10(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -413,7 +413,7 @@ importers: version: link:../foundation '@h3ravel/musket': specifier: catalog:prod - version: 0.6.9(@h3ravel/support@0.15.6)(@types/node@24.10.0) + version: 0.6.10(@h3ravel/support@0.15.6)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -463,7 +463,7 @@ importers: devDependencies: '@h3ravel/musket': specifier: catalog:prod - version: 0.6.9(@h3ravel/support@0.16.1)(@types/node@24.10.0) + version: 0.6.10(@h3ravel/support@0.16.1)(@types/node@24.10.0) edge.js: specifier: 'catalog:' version: 6.3.0 @@ -539,7 +539,7 @@ importers: version: link:../filesystem '@h3ravel/musket': specifier: catalog:prod - version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.10(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -571,7 +571,7 @@ importers: version: link:../core '@h3ravel/musket': specifier: catalog:prod - version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.10(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -587,7 +587,7 @@ importers: dependencies: '@h3ravel/musket': specifier: catalog:prod - version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.10(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -646,7 +646,7 @@ importers: version: link:../foundation '@h3ravel/musket': specifier: catalog:prod - version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.10(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/session': specifier: workspace:^ version: link:../session @@ -721,7 +721,7 @@ importers: version: link:../http '@h3ravel/musket': specifier: catalog:prod - version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.10(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -873,7 +873,7 @@ importers: version: link:../http '@h3ravel/musket': specifier: catalog:prod - version: 0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0) + version: 0.6.10(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:* version: link:../shared @@ -1644,8 +1644,8 @@ packages: '@h3ravel/contracts@0.28.1': resolution: {integrity: sha512-Ub2+5rvabNjMvcxDkVWfBBucXLpVDqCX8/rl3QQwbDpcNilZfcnzw1aHfSuk5XpNUBw4zmxYJGlQkuNS3St+TQ==} - '@h3ravel/musket@0.6.9': - resolution: {integrity: sha512-eD4BkSlLuI8EWBAPYIIcGsNxoqZQOPh0pE9b+9ldgfZd75BdDeTUFW80JErPGziALjSwnr0KBIXjcL8x78zRcw==} + '@h3ravel/musket@0.6.10': + resolution: {integrity: sha512-+An+HX3fM853f8ZCVE2CBwJ0oM+2jH/xUimx0EW7l05fNo8F9ltxrrl5997DLtCzn+GWsf6em++iHxDHkOiQDA==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@h3ravel/support': ^0.16.1 @@ -7127,7 +7127,7 @@ snapshots: transitivePeerDependencies: - crossws - '@h3ravel/musket@0.6.9(@h3ravel/support@0.15.6)(@types/node@24.10.0)': + '@h3ravel/musket@0.6.10(@h3ravel/support@0.15.6)(@types/node@24.10.0)': dependencies: '@h3ravel/shared': 0.28.4(@types/node@24.10.0) '@h3ravel/support': 0.15.6 @@ -7144,7 +7144,7 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.6.9(@h3ravel/support@0.16.1)(@types/node@24.10.0)': + '@h3ravel/musket@0.6.10(@h3ravel/support@0.16.1)(@types/node@24.10.0)': dependencies: '@h3ravel/shared': 0.28.4(@types/node@24.10.0) '@h3ravel/support': 0.16.1 @@ -7161,7 +7161,7 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.6.9(@h3ravel/support@packages+support)(@types/node@24.10.0)': + '@h3ravel/musket@0.6.10(@h3ravel/support@packages+support)(@types/node@24.10.0)': dependencies: '@h3ravel/shared': 0.28.4(@types/node@24.10.0) '@h3ravel/support': link:packages/support @@ -7178,7 +7178,7 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.6.9(@h3ravel/support@packages+support)(@types/node@24.9.2)': + '@h3ravel/musket@0.6.10(@h3ravel/support@packages+support)(@types/node@24.9.2)': dependencies: '@h3ravel/shared': 0.28.4(@types/node@24.9.2) '@h3ravel/support': link:packages/support diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c91f6723..d9e42c18 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -73,7 +73,7 @@ catalogs: prod: '@h3ravel/arquebus': ^0.7.6 '@h3ravel/collect.js': ^5.3.3 - '@h3ravel/musket': ^0.6.9 + '@h3ravel/musket': ^0.6.10 h3: 2.0.1-rc.5 ignoredBuiltDependencies: From 26f1df5cb6435ceb572bf261fceb8bd3d088adb7 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Fri, 16 Jan 2026 04:12:44 +0100 Subject: [PATCH 24/28] feat: Add count method to route collection interfaces and implementations --- .../src/Routing/IAbstractRouteCollection.ts | 1 + packages/contracts/src/Routing/IRouter.ts | 5 + .../router/src/AbstractRouteCollection.ts | 7 ++ .../router/src/Commands/RouteListCommand.ts | 104 ++++++++++++++++-- 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/packages/contracts/src/Routing/IAbstractRouteCollection.ts b/packages/contracts/src/Routing/IAbstractRouteCollection.ts index e70d63d5..149882fe 100644 --- a/packages/contracts/src/Routing/IAbstractRouteCollection.ts +++ b/packages/contracts/src/Routing/IAbstractRouteCollection.ts @@ -6,4 +6,5 @@ export declare abstract class IAbstractRouteCollection { abstract get (): IRoute[]; abstract get (method: string): Record; abstract getRoutes (): IRoute[]; + abstract count (): number } \ No newline at end of file diff --git a/packages/contracts/src/Routing/IRouter.ts b/packages/contracts/src/Routing/IRouter.ts index 3de91471..52cc4c68 100644 --- a/packages/contracts/src/Routing/IRouter.ts +++ b/packages/contracts/src/Routing/IRouter.ts @@ -298,4 +298,9 @@ export abstract class IRouter { * @param callback */ abstract substituteImplicitBindingsUsing (callback: CallableConstructor): this + + /** + * Count the number of items in the collection. + */ + abstract count (): number } \ No newline at end of file diff --git a/packages/router/src/AbstractRouteCollection.ts b/packages/router/src/AbstractRouteCollection.ts index 83cceeeb..5cee6f84 100644 --- a/packages/router/src/AbstractRouteCollection.ts +++ b/packages/router/src/AbstractRouteCollection.ts @@ -102,4 +102,11 @@ export abstract class AbstractRouteCollection implements IAbstractRouteCollectio */ return path === route.uri() } + + /** + * Count the number of items in the collection. + */ + count (): number { + return this.getRoutes().length + } } diff --git a/packages/router/src/Commands/RouteListCommand.ts b/packages/router/src/Commands/RouteListCommand.ts index 238f1ae0..34d60ee1 100644 --- a/packages/router/src/Commands/RouteListCommand.ts +++ b/packages/router/src/Commands/RouteListCommand.ts @@ -1,4 +1,4 @@ -import { IApplication, RouteMethod } from '@h3ravel/contracts' +import { IApplication, IRoute, RouteMethod } from '@h3ravel/contracts' import { Logger, LoggerChalk } from '@h3ravel/shared' import { Command } from '@h3ravel/musket' @@ -14,6 +14,11 @@ export class RouteListCommand extends Command { {list : List all registered routes. | {--json : Output the route list as JSON} | {--r|reverse : Reverse the ordering of the routes} + | {--s|sort=uri : Sort the routes by a given column (uri, name, method)} + | {--m|method= : Filter the routes by a specific HTTP method} + | {--n|name= : Filter the routes by a specific name} + | {--p|path= : Filter the routes by a specific path} + | {--e|except-path= : Exclude routes with a specific path} } ` @@ -27,17 +32,27 @@ export class RouteListCommand extends Command { /** * Execute the console command. */ - public async handle (this: any) { + public async handle () { + this.newLine() - const command = (this.dictionary.baseCommand ?? this.dictionary.name) - await Reflect.apply(this[command], this, []) + if (!this.app.make('router').getRoutes().count()) { + this.error('ERROR: Your application doesn\'t have any routes.').newLine() + return + } + + const routes = this.getRoutes() + if (routes.length === 0) { + this.error('ERROR: Your application doesn\'t have any routes matching the given criteria.').newLine() + return + } + await this.showRoutes(routes) } /** - * List all registered routes. + * Compile the routes into a displayable format. */ - protected async list () { + protected getRoutes () { /** * Sort the routes alphabetically */ @@ -47,10 +62,64 @@ export class RouteListCommand extends Command { return a.path.localeCompare(b.path) }) + if (this.option('reverse')) { + list.reverse() + } + + if (this.option('sort')) { + const sort = this.option('sort')!.toLowerCase() + list.sort((a, b) => { + switch (sort) { + case 'uri': + return a.path.localeCompare(b.path) + case 'name': + return (a.getName() ?? '').localeCompare(b.getName() ?? '') + case 'method': + return a.methods.join('|').localeCompare(b.methods.join('|')) + default: + return 0 + } + }) + } + + if (this.option('method')) { + const method = this.option('method')!.toUpperCase() + list.splice(0, list.length, ...list.filter(route => route.getMethods().includes(method as RouteMethod))) + } + + if (this.option('name')) { + const name = this.option('name')! + list.splice(0, list.length, ...list.filter(route => route.getName() === name)) + } + + if (this.option('path')) { + const path = this.option('path')! + list.splice(0, list.length, ...list.filter(route => route.path === path)) + } + + if (this.option('except-path')) { + const path = this.option('except-path')! + list.splice(0, list.length, ...list.filter(route => route.path !== path)) + } - // /** - // * Log the route list - // */ + return list + } + + /** + * List all registered routes. + */ + protected async showRoutes (list: IRoute[]) { + if (this.option('json')) { + return this.asJson(list) + } + + return this.forCli(list) + } + + private forCli (list: IRoute[]) { + /** + * Log the route list + */ list.forEach(route => { const uri = route.uri() const name = route.getName() ?? '' @@ -74,6 +143,23 @@ export class RouteListCommand extends Command { Logger.split('', Logger.log(`Showing [${list.length}] routes`, ['blue', 'bold'], false), 'info', false, false, ' ') } + private asJson (list: IRoute[]) { + const routes = list.map(route => ({ + methods: route.getMethods(), + uri: route.uri(), + name: route.getName(), + action: route.action, + })) + + if (this.app.runningInConsole()) { + this.newLine() + console.log(JSON.stringify(routes, null, 2)) + this.newLine() + } else { + return routes + } + } + /** * Get the color * From 85070d61b5f3e760ea1ae78b0d2dca765728dc37 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Fri, 16 Jan 2026 04:52:39 +0100 Subject: [PATCH 25/28] feat: bump all package versions. --- packages/cache/package.json | 2 +- packages/config/package.json | 2 +- packages/console/package.json | 2 +- packages/contracts/package.json | 2 +- packages/core/package.json | 2 +- packages/database/package.json | 2 +- packages/filesystem/package.json | 4 ++-- packages/hashing/package.json | 2 +- packages/http/package.json | 2 +- packages/mail/package.json | 4 ++-- packages/queue/package.json | 2 +- packages/router/package.json | 2 +- packages/shared/package.json | 2 +- packages/support/package.json | 2 +- packages/url/package.json | 2 +- packages/view/package.json | 2 +- 16 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/cache/package.json b/packages/cache/package.json index 28156673..67ae7f87 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/cache", - "version": "11.0.16", + "version": "11.1.0", "description": "Cache system with multiple drivers for H3ravel.", "h3ravel": { "providers": [ diff --git a/packages/config/package.json b/packages/config/package.json index d848f27b..8b9c073c 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/config", - "version": "1.4.18", + "version": "1.5.0", "description": "Environment/config loading and management system for H3ravel.", "h3ravel": { "providers": [ diff --git a/packages/console/package.json b/packages/console/package.json index 580edacd..fa80d74f 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/console", - "version": "11.14.10", + "version": "11.15.0", "description": "CLI utilities for scaffolding, running migrations, tasks and for H3ravel.", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 4e4209c7..f76af7f2 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/contracts", - "version": "0.28.1", + "version": "0.29.0", "description": "H3ravel Contracts.", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/core/package.json b/packages/core/package.json index 7f6d7045..53530049 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/core", - "version": "1.21.7", + "version": "1.22.0", "description": "Core application container, lifecycle management and service providers for H3ravel.", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/database/package.json b/packages/database/package.json index 081449fd..950e8bfc 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/database", - "version": "11.4.11", + "version": "11.5.0", "description": "Modeling data and migration system for H3ravel.", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/filesystem/package.json b/packages/filesystem/package.json index 153aa4cb..3dac7cc0 100644 --- a/packages/filesystem/package.json +++ b/packages/filesystem/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/filesystem", - "version": "0.4.13", + "version": "0.4.15", "description": "Filesystem manager for H3ravel.", "h3ravel": { "providers": [ @@ -68,4 +68,4 @@ "devDependencies": { "typescript": "^5.4.0" } -} +} \ No newline at end of file diff --git a/packages/hashing/package.json b/packages/hashing/package.json index 8021a726..b4611ab0 100644 --- a/packages/hashing/package.json +++ b/packages/hashing/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/hashing", - "version": "0.1.15", + "version": "0.2.0", "description": "Secure framework-agnostic Bcrypt and Argon2 hashing for storing user passwords in H3ravel and node Apps.", "h3ravel": { "providers": [ diff --git a/packages/http/package.json b/packages/http/package.json index d612ff7a..f004d55d 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/http", - "version": "11.7.7", + "version": "11.8.0", "description": "HTTP kernel, middleware pipeline, request/response classes for H3ravel.", "h3ravel": { "providers": [ diff --git a/packages/mail/package.json b/packages/mail/package.json index bdf7f919..99c0b130 100644 --- a/packages/mail/package.json +++ b/packages/mail/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/mail", - "version": "11.0.16", + "version": "11.0.17", "description": "Mail drivers and templates system for H3ravel.", "h3ravel": { "providers": [ @@ -57,4 +57,4 @@ "@aws-sdk/client-sesv2": "^3.864.0", "nodemailer": "^7.0.5" } -} +} \ No newline at end of file diff --git a/packages/queue/package.json b/packages/queue/package.json index 58801959..c211af37 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/queue", - "version": "11.0.13", + "version": "11.1.0", "description": "Job queues, workers and broadcasting support system for H3ravel.", "h3ravel": { "providers": [ diff --git a/packages/router/package.json b/packages/router/package.json index 11be5d50..35608a00 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/router", - "version": "1.13.6", + "version": "1.15.0", "description": "Route facade, decorators and controller system for H3ravel.", "h3ravel": { "providers": [ diff --git a/packages/shared/package.json b/packages/shared/package.json index 948bdb7c..b2ac2786 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/shared", - "version": "0.28.4", + "version": "0.29.0", "description": "Shared Utilities.", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/support/package.json b/packages/support/package.json index d92e603a..8d228e20 100644 --- a/packages/support/package.json +++ b/packages/support/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/support", - "version": "0.16.1", + "version": "0.17.0", "description": "Shared helpers, facades and utilities for H3ravel.", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/url/package.json b/packages/url/package.json index 6151b85d..7d61946d 100644 --- a/packages/url/package.json +++ b/packages/url/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/url", - "version": "1.0.15", + "version": "1.1.0", "description": "Request-aware URI builder and URL manipulation utilities for H3ravel.", "h3ravel": { "providers": [ diff --git a/packages/view/package.json b/packages/view/package.json index c877001d..2904ad41 100644 --- a/packages/view/package.json +++ b/packages/view/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/view", - "version": "0.1.13", + "version": "0.1.14", "description": "View rendering system for H3ravel framework", "h3ravel": { "providers": [ From ff6311aa053aea4603a2bcb32a590610ec94ef35 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Fri, 16 Jan 2026 05:08:12 +0100 Subject: [PATCH 26/28] feat: update glob import and enhance d.ts file handling in tsdown config --- tsdown.config.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tsdown.config.ts b/tsdown.config.ts index 5ffe9910..02591a38 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,5 +1,6 @@ import { type UserConfig, defineConfig } from 'tsdown' -import { copyFile, glob, mkdir, readFile, writeFile } from 'node:fs/promises' +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { glob } from 'glob' import path from 'node:path' import { exists, findUpConfig } from './utils/fs' @@ -32,7 +33,7 @@ export const baseConfig: UserConfig = { // Make globale stubs partern const stubs = base.replace('package.json', 'packages/**/src/**/*.stub') - for await (const entry of glob([gdts, stubs])) { + for await (const entry of glob.stream([gdts, stubs])) { const target = entry.replace('src', 'dist') // Ensure the target dir exists if (await exists(entry) && !await exists(path.dirname(target))) @@ -41,8 +42,8 @@ export const baseConfig: UserConfig = { if (await exists(entry) && entry.includes(ctx.options.cwd)) copyFile(entry, target) // Augment the d.ts file to the index.d.ts - if (entry.includes('.d.ts')) { - for await (const indexFile of glob(path.join(outDir, 'index.d.*ts'))) { + if (entry.includes('.d.ts') && !entry.includes('node_modules') && !entry.includes('env.d.ts')) { + for await (const indexFile of glob.stream(path.join(outDir, 'index.d.*ts'))) { const reference = `/// \n` if (await exists(indexFile)) { let content = await readFile(indexFile, 'utf8') From 9c9d5d5923555b2747f936f3cb2f8ffe59d55cc6 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Fri, 16 Jan 2026 05:23:29 +0100 Subject: [PATCH 27/28] feat: remove temporary JSON files from session storage --- ...40427184c0c1950560444d5a17dd9205784e94441e80094a166dd1b9.json | 1 - ...e93053a7d27b34c7eaa88afe5f5bbbf7497f314ce13f87b4610d8be0.json | 1 - 2 files changed, 2 deletions(-) delete mode 100644 var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/1330775240427184c0c1950560444d5a17dd9205784e94441e80094a166dd1b9.json delete mode 100644 var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/ed95f69be93053a7d27b34c7eaa88afe5f5bbbf7497f314ce13f87b4610d8be0.json diff --git a/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/1330775240427184c0c1950560444d5a17dd9205784e94441e80094a166dd1b9.json b/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/1330775240427184c0c1950560444d5a17dd9205784e94441e80094a166dd1b9.json deleted file mode 100644 index ea37b354..00000000 --- a/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/1330775240427184c0c1950560444d5a17dd9205784e94441e80094a166dd1b9.json +++ /dev/null @@ -1 +0,0 @@ -a6ce7448e1f2c0035cc675ec9c140c72:607e94b7cad134ce4828b6dee3c8dc1d \ No newline at end of file diff --git a/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/ed95f69be93053a7d27b34c7eaa88afe5f5bbbf7497f314ce13f87b4610d8be0.json b/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/ed95f69be93053a7d27b34c7eaa88afe5f5bbbf7497f314ce13f87b4610d8be0.json deleted file mode 100644 index b2809ef4..00000000 --- a/var/folders/3g/7l4zp_5s0wd20ktyvq69r1q80000gn/T/@h3ravel-session2AJpa4/ed95f69be93053a7d27b34c7eaa88afe5f5bbbf7497f314ce13f87b4610d8be0.json +++ /dev/null @@ -1 +0,0 @@ -be751fe1ecd1bc6d356b0b13a1d5197d:746d6f3c9c450abd907facd5984ed7dd \ No newline at end of file From a30d0cd7c39e6b6a79b840ed45133ab1ce337e1e Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Fri, 16 Jan 2026 05:23:42 +0100 Subject: [PATCH 28/28] feat: update import statement in memory.spec.ts and clean up .gitignore --- .gitignore | 1 + packages/session/tests/memory.spec.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b8ba5459..7ec6cdfe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Logs +var logs *.log npm-debug.log* diff --git a/packages/session/tests/memory.spec.ts b/packages/session/tests/memory.spec.ts index ecdd5846..156328ef 100644 --- a/packages/session/tests/memory.spec.ts +++ b/packages/session/tests/memory.spec.ts @@ -47,7 +47,7 @@ describe('@h3ravel/session MemoryDriver', () => { beforeEach(async () => { event = makeEvent() - const { Request, Response, HttpContext } = (await import(('@h3ravel/http'))) + const { Request, Response, HttpContext } = await import('@h3ravel/http') ctx = HttpContext.init({ app,