diff --git a/.barrelize b/.barrelize index 11675fc5..8fe3df78 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", @@ -127,11 +135,56 @@ }, { "name": "index.ts", - "root": "packages/support/src", + "root": "packages/validation/src", + "exclude": [ + "**/*.test.ts", + "**/*.d.ts" + ] + }, + { + "name": "index.ts", + "root": "packages/session/src", + "exclude": [ + "**/*.test.ts", + "**/*.d.ts" + ] + }, + { + "name": "index.ts", + "root": "packages/foundation/src", "exclude": [ "**/*.test.ts", "**/*.d.ts" ], + "order": [ + "Exceptions/HttpException" + ] + }, + { + "name": "index.ts", + "root": "packages/support/src/Facades", + "exclude": [ + "**/*.test.ts", + "**/*.d.ts" + ] + }, + { + "name": "index.ts", + "root": "packages/support/src/Traits", + "exclude": [ + "**/*.test.ts", + "**/*.d.ts" + ] + }, + { + "name": "index.ts", + "root": "packages/support/src", + "exclude": [ + "**/*.test.ts", + "**/*.d.ts", + "**/Traits/*.*", + "**/Facades/*.*" + ], "exports": { "**/Arr.ts": [ "Arr", @@ -161,6 +214,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/.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/.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/.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/packages/config/src/Contracts/.gitkeep b/db.sqlite similarity index 100% rename from packages/config/src/Contracts/.gitkeep rename to db.sqlite 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/.h3ravel/tsconfig.json b/examples/basic-app/.h3ravel/tsconfig.json index ac510211..1fefc38d 100644 --- a/examples/basic-app/.h3ravel/tsconfig.json +++ b/examples/basic-app/.h3ravel/tsconfig.json @@ -1,34 +1,31 @@ { - "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/*"], - "@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", - "moduleResolution": "Node", + "moduleResolution": "bundler", "esModuleInterop": true, "strict": true, "allowJs": true, @@ -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..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", @@ -31,6 +39,10 @@ "@h3ravel/support": "workspace:^", "@h3ravel/url": "workspace:^", "@h3ravel/view": "workspace:^", + "@h3ravel/validation": "workspace:^", + "@h3ravel/foundation": "workspace:^", + "@h3ravel/session": "workspace:^", + "@h3ravel/events": "workspace:^", "cross-env": "catalog:", "h3": "catalog:prod", "reflect-metadata": "catalog:", @@ -41,7 +53,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/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/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/app/Http/Controllers/ProjectController.ts b/examples/basic-app/src/app/Http/Controllers/ProjectController.ts new file mode 100644 index 00000000..caae839a --- /dev/null +++ b/examples/basic-app/src/app/Http/Controllers/ProjectController.ts @@ -0,0 +1,45 @@ +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 { + @Injectable() + index (user: User) { + return user.getRelated('projects') + } + + @Injectable() + async store (request: Request, response: Response, user: User) { + const validate = await request.validate({ + name: ['required', 'string'], + }) + + console.log(validate, user) + + return response + .setStatusCode(202) + .json({ message: `Project ${request.input('name')} created` }) + } + + @Injectable() + 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: `Project ${request.input('name')} updated`, user, project }) + } + + @Injectable() + destroy ({ request }: HttpContext, user: User, project: Project) { + return { message: `Project ${request.input('id')} deleted`, user, project } + } +} diff --git a/examples/basic-app/src/app/Http/Controllers/UrlExampleController.ts b/examples/basic-app/src/app/Http/Controllers/UrlExampleController.ts index 7ed5d55b..477e109b 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(), @@ -31,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/examples/basic-app/src/app/Http/Controllers/UserController.ts b/examples/basic-app/src/app/Http/Controllers/UserController.ts index 34988f3c..cb2411ec 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` }) @@ -17,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 })) + .setContent(user) } 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/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 9e5adc87..9fb912c7 100644 --- a/examples/basic-app/src/bootstrap/app.ts +++ b/examples/basic-app/src/bootstrap/app.ts @@ -1,10 +1,46 @@ -import { h3ravel } from '@h3ravel/core' +import { Application, h3ravel } from '@h3ravel/core' + +import { UnprocessableEntityHttpException } from '@h3ravel/foundation' +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 }, async () => undefined) - + const app = await h3ravel(providers, process.cwd(), { autoload: true, initialize: false }) + this.configure(app) return await app.fire() } + + 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 + /** + * Register global reporters here + */ + .report((error) => { + console.error('Unhandled Exception:', error.message, '(Reported at src/bootstrap/app.ts)') + }) + /** + * Prevent some exceptions from being reported + */ + .dontReport([ + UnprocessableEntityHttpException, + ]) + /** + * Configure request exception message truncation + */ + .truncateRequestExceptionsAt(200) + }) + .withMiddleware(() => { + console.log('-=withMiddleware=-') + }) + } } 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/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/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 new file mode 100644 index 00000000..aa47e3fe --- /dev/null +++ b/examples/basic-app/src/resources/views/test.form.edge @@ -0,0 +1,22 @@ + + + + + + + +
+ Name: +
+
+ Age: +
+
+ +
+ Failing Validation: +
+ {{JSON.stringify(await request().old( ))}} + + + \ No newline at end of file diff --git a/examples/basic-app/src/routes/api.ts b/examples/basic-app/src/routes/api.ts index a21d466c..f59cf206 100644 --- a/examples/basic-app/src/routes/api.ts +++ b/examples/basic-app/src/routes/api.ts @@ -1,17 +1,12 @@ import { AuthMiddleware } from 'App/Http/Middlewares/AuthMiddleware' -import { Router } from '@h3ravel/router' +import { ProjectController } from 'src/app/Http/Controllers/ProjectController' +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.apiResource('/users/{user}/projects', ProjectController).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 2fc56f67..cd5b27de 100644 --- a/examples/basic-app/src/routes/web.ts +++ b/examples/basic-app/src/routes/web.ts @@ -1,25 +1,48 @@ import { HomeController } from 'App/Http/Controllers/HomeController' -import { MailController } from 'src/app/Http/Controllers/MailController' -import { Router } from '@h3ravel/router' -import { UrlExampleController } from 'src/app/Http/Controllers/UrlExampleController' +import { HttpContext } from '@h3ravel/http' +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('.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') +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.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.match(['PUT', 'POST'], '/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, + }) +}) \ No newline at end of file diff --git a/packages/hashing/src/Contracts/.gitkeep b/examples/basic-app/storage/framework/sessions/.gitkeep similarity index 100% rename from packages/hashing/src/Contracts/.gitkeep rename to examples/basic-app/storage/framework/sessions/.gitkeep 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/package.json b/package.json index a045dc8d..a45a17bf 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "fetchdts": "^0.1.7", "husky": "catalog:", "knex": "catalog:", + "mysql2": "catalog:", "madge": "^8.0.0", "nodemailer": "^7.0.10", "path": "catalog:", @@ -70,7 +71,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 f3c43055..4d5ef862 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": [ @@ -56,7 +56,7 @@ "version-patch": "pnpm version patch" }, "peerDependencies": { - "@h3ravel/core": "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 2dc29ae7..a1fe123d 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/support' /** * Cache drivers and utilities. diff --git a/packages/config/package.json b/packages/config/package.json index 8014f94e..ae4c407c 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": [ @@ -63,6 +63,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..a396c4c6 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] @@ -34,9 +35,8 @@ export class ConfigRepository { if (!this.loaded) { const configPath = this.app.getPath('config') - - globalThis.env = this.app.make('env') - Registerer.register(this.app) + globalThis.env ??= this.app.make('env') + 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..caecc238 100644 --- a/packages/config/src/Providers/ConfigServiceProvider.ts +++ b/packages/config/src/Providers/ConfigServiceProvider.ts @@ -1,10 +1,9 @@ -/// +/// import { ConfigRepository, EnvLoader } from '..' -import { Bindings } from '@h3ravel/shared' import { ConfigPublishCommand } from '../Commands/ConfigPublishCommand' -import { ServiceProvider } from '@h3ravel/core' +import { ServiceProvider } from '@h3ravel/support' /** * Loads configuration and environment files. @@ -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/package.json b/packages/console/package.json index f7f594e7..8cdc5300 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", @@ -70,19 +70,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/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/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/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/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/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..f76af7f2 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,70 @@ +{ + "name": "@h3ravel/contracts", + "version": "0.29.0", + "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" + ], + "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:", + "@h3ravel/musket": "catalog:prod" + }, + "dependencies": { + "h3": "catalog:prod" + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..11485714 --- /dev/null +++ b/packages/contracts/src/Core/IApplication.ts @@ -0,0 +1,266 @@ +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' +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 + abstract context?: (event: H3Event) => Promise + abstract 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 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 + * + * @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 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 + + /** + * 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 (config?: EntryConfig): 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 (): 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 + * + * 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; + + /** + * 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 + + /** + * 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. + * + * @param h3App The current H3 app instance + * @returns + */ + abstract setH3App (h3App?: H3): this; + + /** + * Set the HttpContext. + * + * @param ctx + */ + abstract setHttpContext (ctx: IHttpContext): this + + /** + * Get the HttpContext. + */ + 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 + * + * @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..5bfc0c74 --- /dev/null +++ b/packages/contracts/src/Core/IContainer.ts @@ -0,0 +1,198 @@ +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' + +/** + * Interface for the Container contract, defining methods for dependency injection and service resolution. + */ +export abstract class IContainer { + 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: IBinding): 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 + + /** + * 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. + * + * @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; + + /** + * Determine if the given abstract type has been resolved. + * + * @param abstract + */ + 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. + * + * @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..4c88801c --- /dev/null +++ b/packages/contracts/src/Core/IController.ts @@ -0,0 +1,24 @@ +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: ResourceMethod, 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..e4fb51a1 --- /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/Database/IModel.ts b/packages/contracts/src/Database/IModel.ts new file mode 100644 index 00000000..88d0e2df --- /dev/null +++ b/packages/contracts/src/Database/IModel.ts @@ -0,0 +1,29 @@ +export abstract class IModel { + /** + * 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: any, value: any, field?: undefined | string | null): any; + + /** + * 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/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..c4d5910b --- /dev/null +++ b/packages/contracts/src/Exceptions/IExceptionHandler.ts @@ -0,0 +1,79 @@ +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 to the console. + * + * @param e + */ + abstract renderForConsole (e: Error): void + /** + * 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 | Error, level: 'log' | 'debug' | 'warn' | 'info' | 'error'): this + /** + * 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/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/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/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/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/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..e5f6c73b --- /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 (): void; + /** + * 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/contracts/src/Http/IRequest.ts b/packages/contracts/src/Http/IRequest.ts new file mode 100644 index 00000000..26b0e7c1 --- /dev/null +++ b/packages/contracts/src/Http/IRequest.ts @@ -0,0 +1,463 @@ +import type { DotNestedKeys, DotNestedValue } from '../Utilities/ObjContract' + +import type { H3Event } from 'h3' +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 abstract class IRequest< + D extends Record = Record, + R extends Record = Record, + U extends Record = Record +> extends IHttpRequest { + /** + * The current app instance + */ + abstract app: IApplication + /** + * Parsed request body + */ + abstract body: unknown + /** + * The current Http Context + */ + abstract context: IHttpContext + /** + * Gets route parameters. + * @returns An object containing route parameters. + */ + abstract params: NonNullable + + /** + * The request attributes (parameters parsed from the PATH_INFO, ...). + */ + 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. + */ + static create ( + /** + * The current H3 H3Event instance + */ + event: H3Event, + /** + * The current app instance + */ + app: IApplication + ): Promise { + void event + void app + return Promise.resolve({} as IRequest) + } + /** + * 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 (): void; + /** + * Retrieve all data from the instance (query + body). + */ + abstract all> (keys?: string | string[]): T; + /** + * Retrieve an input item from the request. + * + * @param key + * @param defaultValue + * @returns + */ + abstract input (key?: K, defaultValue?: any): K extends undefined ? RequestObject : any; + /** + * Retrieve a file from the request. + * + * By default a single `UploadedFile` instance will always be returned by + * the method (first file in property when there are multiple), unless + * the `expectArray` parameter is set to true, in which case, the method + * returns an `UploadedFile[]` array. + * + * @param key + * @param defaultValue + * @param expectArray set to true to return an `UploadedFile[]` array. + * @returns + */ + 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 + */ + abstract hasFile (key: string): boolean; + /** + * Get an object with all the files on the request. + */ + abstract allFiles (): Record; + /** + * Extract and convert uploaded files from FormData. + */ + abstract convertUploadedFiles (files: Record): Record; + /** + * Determine if the data contains a given key. + * + * @param keys + * @returns + */ + abstract has (keys: string[] | string): boolean; + /** + * Determine if the instance is missing a given key. + */ + abstract missing (key: string | string[]): boolean; + /** + * Get a subset containing the provided keys with values from the instance data. + * + * @param keys + * @returns + */ + abstract only> (keys: string[]): T; + /** + * Get all of the data except for a specified array of items. + * + * @param keys + * @returns + */ + 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. + */ + 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 + */ + abstract mergeIfMissing (input: Record): this; + /** + * Get the keys for all of the input and files. + */ + abstract keys (): string[]; + /** + * Get an instance of the current session manager + * + * @param key + * @param defaultValue + * @returns an instance of the current session manager. + */ + public abstract session | undefined = undefined> (key?: K, defaultValue?: any): K extends undefined + ? ISessionManager + : K extends string + ? any : void | Promise + /** + * Determine if the request is sending JSON. + * + * @return bool + */ + abstract isJson (): boolean; + /** + * Determine if the current request probably expects a JSON response. + * + * @returns + */ + abstract expectsJson (): boolean; + /** + * Determine if the current request is asking for JSON. + * + * @returns + */ + abstract wantsJson (): boolean; + /** + * Gets a list of content types acceptable by the client browser in preferable order. + * @returns {string[]} + */ + abstract getAcceptableContentTypes (): string[]; + /** + * Determine if the request is the result of a PJAX call. + * + * @return bool + */ + abstract pjax (): boolean; + /** + * Returns true if the request is an XMLHttpRequest (AJAX). + * + * @alias isXmlHttpRequest() + * @returns {boolean} + */ + abstract ajax (): boolean; + /** + * Returns true if the request is an XMLHttpRequest (AJAX). + */ + abstract isXmlHttpRequest (): 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; + /** + * 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 "real" request method. + * + * @see getMethod() + */ + abstract getRealMethod (): RequestMethod; + /** + * Get the client IP address. + */ + 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. + */ + abstract uri (): unknown; + /** + * 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 path (): string + /** + * Return the Request instance. + */ + abstract instance (): this; + /** + * Get the request method. + */ + abstract method (): RequestMethod; + /** + * Get the JSON payload for the request. + * + * @param key + * @param defaultValue + * @return {InputBag} + */ + 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 + /** + * 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. + * + * @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; + /** + * Validate the incoming request data + * + * @param data + * @param rules + * @param messages + */ + abstract validate ( + rules: R, + messages?: Partial> + ): Promise; + /** + * Enables support for the _method request parameter to determine the intended HTTP method. + * + * Be warned that enabling this feature might lead to CSRF issues in your code. + * Check that you are using CSRF tokens when required. + * If the HTTP method parameter override is enabled, an html-form with method "POST" can be altered + * and used to send a "PUT" or "DELETE" request via the _method request parameter. + * If these methods are not protected against CSRF, this presents a possible vulnerability. + * + * The HTTP method can only be overridden when the real HTTP method is POST. + */ + static enableHttpMethodParameterOverride (): void { } + /** + * Checks whether support for the _method request parameter is enabled. + */ + static getHttpMethodParameterOverride (): boolean { + return false + } + /** + * Dump the items. + * + * @param keys + * @return this + */ + abstract dump (...keys: any[]): this; + /** + * Get the base event + */ + 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..64c5b920 --- /dev/null +++ b/packages/contracts/src/Http/IResponse.ts @@ -0,0 +1,99 @@ +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): IResponsable; + /** + * Sends content for the current web response. + */ + abstract send (type?: 'html' | 'json' | 'text' | 'xml'): IResponsable; + + /** + * 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; + + /** + * Reset the response class to it's defautl + */ + abstract reset (): void +} + +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..149882fe --- /dev/null +++ b/packages/contracts/src/Routing/IAbstractRouteCollection.ts @@ -0,0 +1,10 @@ +import type { IRoute } from './IRoute' +import type { RouteMethod } from '../Utilities/Utilities' + +export declare abstract class IAbstractRouteCollection { + static verbs: RouteMethod[] + 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/ICallableDispatcher.ts b/packages/contracts/src/Routing/ICallableDispatcher.ts new file mode 100644 index 00000000..78fde1a8 --- /dev/null +++ b/packages/contracts/src/Routing/ICallableDispatcher.ts @@ -0,0 +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/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..09239ed0 --- /dev/null +++ b/packages/contracts/src/Routing/IControllerDispatcher.ts @@ -0,0 +1,24 @@ +import { ResourceMethod, 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: ResourceMethod): 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/contracts/src/Routing/IMiddlewareHandler.ts b/packages/contracts/src/Routing/IMiddlewareHandler.ts new file mode 100644 index 00000000..b4fee7bf --- /dev/null +++ b/packages/contracts/src/Routing/IMiddlewareHandler.ts @@ -0,0 +1,20 @@ +import type { IHttpContext } from '../Http/IHttpContext' +import type { IMiddleware } from './IMiddleware' + +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 current HttpContext. + * @param next - Callback to execute when middleware completes. + * @returns A promise resolving to the final handler's result. + */ + run (context: IHttpContext, next: (ctx: IHttpContext) => Promise): Promise; +} \ No newline at end of file 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 new file mode 100644 index 00000000..5f00f6da --- /dev/null +++ b/packages/contracts/src/Routing/IRoute.ts @@ -0,0 +1,330 @@ +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: GenericObject + /** + * The compiled version of the route. + */ + public abstract compiled?: ICompiledRoute + /** + * The array of matched parameters. + */ + public abstract parameters?: GenericObject + /** + * 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?: MiddlewareList + /** + * 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; + /** + * 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. + * + * @param middleware + */ + 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. + * + * @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 (): GenericObject + /** + * Get the matched parameters object. + */ + abstract getParameters (): GenericObject + /** + * 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; + + /** + * 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 + + /** + * 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. + * + * @param key + * @param value + */ + abstract defaults (key: string, value: any): this; + + /** + * Set the default values for the route. + * + * @param defaults + */ + abstract setDefaults (defaults: GenericObject): this; + + /** + * Get the optional parameter names for the route. + */ + 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 (): 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/IRouteCollection.ts b/packages/contracts/src/Routing/IRouteCollection.ts new file mode 100644 index 00000000..71560a78 --- /dev/null +++ b/packages/contracts/src/Routing/IRouteCollection.ts @@ -0,0 +1,60 @@ +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. + * + * @param method + */ + public get (): IRoute[] + public get (method: string): Record + /** + * 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 | undefined; + /** + * Get a route instance by its controller action. + */ + getByAction (action: string): IRoute | undefined; + /** + * 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/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 new file mode 100644 index 00000000..52cc4c68 --- /dev/null +++ b/packages/contracts/src/Routing/IRouter.ts @@ -0,0 +1,306 @@ +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' +import type { IMiddleware } from './IMiddleware' +import { IPendingResourceRegistration } from './IPendingResourceRegistration' +import { IPendingSingletonResourceRegistration } from './IPendingSingletonResourceRegistration' +import { IRequest } from '../Http/IRequest' +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. + */ + 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 uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns + */ + abstract get (uri: string, action: ActionInput): IRoute + /** + * Registers a route that responds to HTTP POST requests. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns + */ + abstract post (uri: string, action: ActionInput): IRoute + /** + * Registers a route that responds to HTTP PUT requests. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns + */ + abstract put (uri: string, action: ActionInput): IRoute + /** + * Registers a route that responds to HTTP PATCH requests. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns + */ + abstract patch (uri: string, action: ActionInput): IRoute + /** + * 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 + */ + 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: RouteMethod | RouteMethod[], + uri: string, + action: ActionInput + ): IRoute; + /** + * Route a resource to a controller. + * + * @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 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 + * + * @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 h3middleware ( + path: string | IMiddleware[] | Middleware, + 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: MiddlewareList, excluded: MiddlewareList): any + /** + * Register a group of middleware. + * + * @param name + * @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 + + /** + * Count the number of items in the collection. + */ + abstract count (): number +} \ 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/Session/FlashBag.ts b/packages/contracts/src/Session/FlashBag.ts new file mode 100644 index 00000000..f0282d76 --- /dev/null +++ b/packages/contracts/src/Session/FlashBag.ts @@ -0,0 +1,71 @@ +export abstract class FlashBag { + /** + * Flash a value for the next request + * + * @param key Key to store in flash + * @param value Value to be flashed + */ + 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 + */ + abstract now (key: string, value: any): void; + /** + * Reflash all current flash data for another request cycle + */ + abstract reflash (): void; + /** + * Keep only specific flash keys for the next request + * + * @param keys Keys to keep + */ + abstract 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 + */ + abstract 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 + */ + abstract get (key: string, defaultValue?: any): any; + /** + * Check if a flash key exists + * + * @param key Key to check + * @returns Boolean indicating existence + */ + abstract has (key: string): boolean; + /** + * Get all flash data + * + * @returns Combined flash data + */ + abstract all (): Record; + /** + * Get all flash data keys + * + * @returns Combined flash data + */ + abstract keys (): string[]; + /** + * Get the raww flash data + * + * @returns raw flash data + */ + abstract raw (): Record; + /** + * Clear all flash data + */ + abstract clear (): void; +} \ No newline at end of file diff --git a/packages/contracts/src/Session/ISessionDriver.ts b/packages/contracts/src/Session/ISessionDriver.ts new file mode 100644 index 00000000..ccc20a52 --- /dev/null +++ b/packages/contracts/src/Session/ISessionDriver.ts @@ -0,0 +1,169 @@ +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 abstract class ISessionDriver { + abstract flashBag: FlashBag + + /** + * Retrieve a value from the session by key. + * + * @param key + * @param defaultValue + */ + abstract get (key: string, defaultValue?: any): T | Promise + + /** + * Store multiple values in the session. + * + * @param key + * @param defaultValue + */ + abstract set (value: Record): void | Promise + + /** + * Retrieve all data from the session including flash + * + * @returns + */ + abstract getAll> (): Promise | T + + /** + * Store a value in the session. + * + * @param key + * @param value + */ + abstract put (key: string, value: any): void | Promise + + /** + * Append a value to an array key + * + * @param key + * @param value + */ + abstract push (key: string, value: any): Promise | void + + /** + * Remove a key from the session. + * + * @param key + */ + abstract forget (key: string): Promise | void + + /** + * Determine if a key is present in the session. + * + * @param key + */ + abstract has (key: string): Promise | boolean + + /** + * Determine if a key exists in the session (even if null). + * + * @param key + */ + abstract exists (key: string): Promise | boolean + + /** + * Get all data from the session. + */ + abstract all> (): Promise | T + + /** + * Get only a subset of session keys. + * + * @param keys + */ + abstract only> (keys: string[]): Promise | T + + /** + * Get all session data except the specified keys. + * + * @param keys + */ + abstract except> (keys: string[]): Promise | T + + /** + * Get and remove an item from the session. + * + * @param key + * @param defaultValue + */ + abstract pull (key: string, defaultValue?: any): Promise | T + + /** + * Increment a numeric session value. + * + * @param key + * @param amount + */ + abstract increment (key: string, amount?: number): Promise | number + + /** + * Decrement a numeric session value. + * + * @param key + * @param amount + */ + abstract decrement (key: string, amount?: number): Promise | number + + /** + * Flash a key/value pair for the next request only. + * + * @param key + * @param value + */ + abstract flash (key: string, value: any): Promise | void + + /** + * Reflash all current flash data for another request cycle. + */ + abstract reflash (): Promise | void + + /** + * Keep only specific flash data for another request. + * + * @param keys + */ + abstract keep (keys: string[]): Promise | void + + /** + * Store data for the current request only (not persisted). + * + * @param key + * @param value + */ + abstract now (key: string, value: any): Promise | void + + /** + * Regenerate the session ID and optionally persist the data. + */ + abstract regenerate (): Promise | void + + /** + * Invalidate the session completely and regenerate ID. + */ + abstract invalidate (): Promise | void + + /** + * Determine if an item is not present in the session. + * + * @param key + */ + abstract missing (key: string): Promise | boolean + + /** + * Flush all session data + */ + abstract flush (): Promise | void + + /** + * Age flash data at the end of the request lifecycle. + */ + 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 new file mode 100644 index 00000000..b3c0494e --- /dev/null +++ b/packages/contracts/src/Session/ISessionManager.ts @@ -0,0 +1,163 @@ +import { FlashBag } from './FlashBag' +import { IApplication } from '../Core/IApplication' +import type { IHttpContext } from '../Http/IHttpContext' +import { ISessionDriver } from './ISessionDriver' + +/** + * SessionManager + * + * Handles session initialization, ID generation, and encryption. + * Each request gets a unique session namespace tied to its ID. + */ +export abstract class ISessionManager { + abstract flashBag: FlashBag + + /** + * Access the current session ID. + */ + abstract id (): string; + + /** + * Get the current session driver + */ + abstract getDriver (): ISessionDriver + /** + * Retrieve a value from the session + * + * @param key + * @returns + */ + abstract get (key: string, defaultValue?: any): Promise | any; + /** + * Store a value in the session + * + * @param key + * @param value + */ + abstract set (value: Record): Promise | void; + /** + * Store multiple key/value pairs + * + * @param values + */ + abstract put (key: string, value: any): void | Promise; + /** + * Append a value to an array key + * + * @param key + * @param value + */ + abstract push (key: string, value: any): void | Promise; + /** + * Remove a key from the session + * + * @param key + */ + abstract forget (key: string): void | Promise; + /** + * Retrieve all session data + * + * @returns + */ + abstract all (): Record | Promise>; + /** + * Determine if a key exists (even if null). + * + * @param key + * @returns + */ + abstract exists (key: string): Promise | boolean; + /** + * Determine if a key has a non-null value. + * + * @param key + * @returns + */ + abstract has (key: string): Promise | boolean; + /** + * Get only specific keys. + * + * @param keys + * @returns + */ + abstract only (keys: string[]): Record | Promise>; + /** + * Return all keys except the specified ones. + * + * @param keys + * @returns + */ + abstract except (keys: string[]): Record | Promise>; + /** + * Return and delete a key from the session. + * + * @param key + * @param defaultValue + * @returns + */ + abstract pull (key: string, defaultValue?: any): any; + /** + * Increment a numeric value by amount (default 1). + * + * @param key + * @param amount + * @returns + */ + abstract increment (key: string, amount?: number): Promise | number; + /** + * Decrement a numeric value by amount (default 1). + * + * @param key + * @param amount + * @returns + */ + abstract decrement (key: string, amount?: number): number | Promise; + /** + * Flash a value for next request only. + * + * @param key + * @param value + */ + abstract flash (key: string, value: any): void | Promise; + /** + * Reflash all flash data for one more cycle. + * + * @returns + */ + abstract reflash (): void | Promise; + /** + * Keep only selected flash data. + * + * @param keys + * @returns + */ + abstract keep (keys: string[]): void | Promise; + /** + * Store data only for current request cycle (not persisted). + * + * @param key + * @param value + */ + abstract now (key: string, value: any): void | Promise; + /** + * Regenerate session ID and persist data under new ID. + */ + abstract regenerate (): void | Promise; + /** + * Determine if an item is not present in the session. + * + * @param key + * @returns + */ + abstract missing (key: string): Promise | boolean; + /** + * Flush all session data + */ + abstract flush (): void | Promise; + /** + * Age flash data at the end of the request lifecycle. + * + * @returns + */ + 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 new file mode 100644 index 00000000..13427e69 --- /dev/null +++ b/packages/contracts/src/Session/SessionContract.ts @@ -0,0 +1,18 @@ +import { ISessionDriver } from './ISessionDriver' + +export interface SessionDriverOption { + 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 SessionDriverBuilder = (sessionId: string, options?: SessionDriverOption) => ISessionDriver \ 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/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/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/IUrlGenerator.ts b/packages/contracts/src/Url/IUrlGenerator.ts new file mode 100644 index 00000000..f56dbf81 --- /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?: RouteParams, absolute?: boolean): string; + + /** + * Get the URL for a given route instance. + * + * @param route + * @param parameters + * @param absolute + */ + abstract toRoute (route: IRoute, parameters?: RouteParams, 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/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..8f0a8460 --- /dev/null +++ b/packages/contracts/src/Url/Utils.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..d0951a0f --- /dev/null +++ b/packages/contracts/src/Utilities/BindingsContract.ts @@ -0,0 +1,60 @@ +import type { H3, serve } from 'h3' +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 = { + [K in keyof T as string extends K + ? never + : number extends K + ? never + : K]: T[K] +} + +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 + 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] + set (key: T, value: any): void + load?(): any + } + session: ISessionManager; + 'app.events': IDispatcher + 'hash.driver': ReturnType + 'http.app': H3 + '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 + +export type IBinding = UseKey | (new (...args: any[]) => unknown) \ No newline at end of file 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..83900c40 --- /dev/null +++ b/packages/contracts/src/Utilities/PathLoader.ts @@ -0,0 +1,29 @@ +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 + + /** + * + * @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 new file mode 100644 index 00000000..e7f3b142 --- /dev/null +++ b/packages/contracts/src/Utilities/Utilities.ts @@ -0,0 +1,111 @@ +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 = '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'; +export type ResourceMethod = 'index' | 'create' | 'store' | 'show' | 'edit' | 'update' | 'destroy' +export type GenericObject = Record; +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 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 = ClassConstructor & Record +export type CallableConstructor = (...args: Y[]) => X +export type AppEvent = CallableConstructor +export type AppListener = CallableConstructor +export type ConcreteConstructor = new (...args: any[]) => RA extends true ? Required : T + +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?: any + uses?: any + http?: boolean + https?: boolean + middleware?: MiddlewareList + namespace?: string + excluded_middleware?: any + scopeBindings?: boolean + scope_bindings?: boolean + 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 { + 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 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/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/contracts/src/Validation/ValidationRuleName.ts b/packages/contracts/src/Validation/ValidationRuleName.ts new file mode 100644 index 00000000..d46813dd --- /dev/null +++ b/packages/contracts/src/Validation/ValidationRuleName.ts @@ -0,0 +1,83 @@ +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 type { Rule } from 'simple-body-validator' + +export type ParamableValidationRuleName = + | 'accepted_if' + | 'after' + | 'after_or_equal' + | 'before' + | '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' + | 'required_with_all' + | 'required_without' + | 'required_without_all' + | 'same' + | 'size' + | 'starts_with' + | 'unique' + +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' + | 'hex' + | 'uuid' + +export type ValidationRuleName = ParamableValidationRuleName | PlainRuleName + +type MethodRules = Regex | In | NotIn | RequiredIf + +/** + * Single rule value (supports autocomplete + arbitrary strings + Rule instances) + */ +type RuleName = ValidationRuleName | `${ParamableValidationRuleName}:${string}` | Rule | MethodRules + +export type ValidationRuleSet = + | RuleName + | RuleName[] + | `${ValidationRuleName}${string & `|${string}`}` \ No newline at end of file diff --git a/packages/contracts/src/Validation/ValidatorContracts.ts b/packages/contracts/src/Validation/ValidatorContracts.ts new file mode 100644 index 00000000..cffe4dc3 --- /dev/null +++ b/packages/contracts/src/Validation/ValidatorContracts.ts @@ -0,0 +1,56 @@ +import { ValidationRuleName, ValidationRuleSet } 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, 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..72ad355b --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,69 @@ +export * from './Configuration/IAppBuilder' +export * from './Core/IApplication' +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' +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' +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/IPendingResourceRegistration' +export * from './Routing/IPendingSingletonResourceRegistration' +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/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' +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/queue/src/Jobs/.gitkeep b/packages/contracts/tests/.gitignore similarity index 100% rename from packages/queue/src/Jobs/.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 6fd3b433..90fbfc4d 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", @@ -61,6 +61,7 @@ "dependencies": { "@h3ravel/shared": "workspace:^", "@h3ravel/support": "workspace:^", + "@h3ravel/foundation": "workspace:^", "chalk": "^5.6.2", "commander": "^14.0.1", "detect-port": "catalog:", @@ -74,6 +75,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 e77767e4..7b43a5d6 100644 --- a/packages/core/src/Application.ts +++ b/packages/core/src/Application.ts @@ -1,44 +1,89 @@ import 'reflect-metadata' -import { FileSystem, type HttpContext, type IApplication, type IPathName, Logger } from '@h3ravel/shared' -import type { H3, H3Event } from 'h3' -import { InvalidArgumentException, Str } from '@h3ravel/support' +import { FileSystem, Logger, PathLoader } from '@h3ravel/shared' +import { H3, serve, type H3Event } from 'h3' -import { AServiceProvider } from './Contracts/ServiceProviderConstructor' +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, HttpException, NotFoundHttpException, ResponseCodes } from '@h3ravel/foundation' 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' 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 { CoreServiceProvider } from './Providers/CoreServiceProvider' +import { EntryConfig } from './Contracts/H3ravelContract' +import { createRequire } from 'node:module' export class Application extends Container implements IApplication { - public paths = new PathLoader() - public context?: (event: H3Event) => Promise + /** + * Indicates if the application has "booted". + */ + #booted = false + paths = new PathLoader() + context?: (event: H3Event) => Promise + 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 namespace?: string private static versions: { [key: string]: string, app: string, ts: string } = { app: '0.0.0', ts: '0.0.0' } - private providers: Array = [] - protected externalProviders: Array = [] + private h3App?: H3 + private providers: Array = [] + protected externalProviders: Array> = [] protected filteredProviders: Array = [] + private autoRegisterProviders: boolean = false + + /** + * The route resolver callback. + */ + protected uriResolver?: () => typeof IUrl /** * List of registered console commands */ - public registeredCommands: (new (app: any, kernel: any) => any)[] = [] + registeredCommands: (new (app: any, kernel: any) => any)[] = [] - constructor(basePath: string) { - super() + /** + * The array of booted callbacks. + */ + protected bootedCallbacks: Array<(app: this) => void> = [] + + /** + * The array of booting callbacks. + */ + 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. + */ + protected bootstrapped = false + + /** + * Controls logging + */ + private logsDisabled = false + + /** + * The conrrent HttpContext + */ + private httpContext?: IHttpContext + + constructor(basePath: string, protected initializer?: string) { + super() dotenvExpand.expand(dotenv.config({ quiet: true })) this.basePath = basePath @@ -52,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) @@ -88,7 +134,7 @@ export class Application extends Container implements IApplication { /** * Get all registered providers */ - public getRegisteredProviders () { + getRegisteredProviders (): IServiceProvider[] { return this.providers } @@ -100,13 +146,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, + CoreServiceProvider ] } - protected async getAllProviders (): Promise> { + protected async getAllProviders (): Promise>> { const coreProviders = await this.getConfiguredProviders() return [...coreProviders, ...this.externalProviders] } @@ -120,26 +166,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() @@ -156,15 +211,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: ServiceProvider) { + async register (provider: IServiceProvider) { await new ContainerResolver(this).resolveMethodParams(provider, 'register', this) if (provider.registeredCommands && provider.registeredCommands.length > 0) { this.registeredCommands.push(...provider.registeredCommands) @@ -177,7 +232,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 @@ -186,14 +241,21 @@ 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 } - public getRuntimeEnv (): 'browser' | 'node' | 'unknown' { + /** + * checks if the application is running in Unit Test + */ + runningUnitTests (): boolean { + return process.env.VITEST === 'true' + } + + getRuntimeEnv (): 'browser' | 'node' | 'unknown' { if (typeof window !== 'undefined' && typeof document !== 'undefined') { return 'browser' } @@ -203,21 +265,47 @@ export class Application extends Container implements IApplication { return 'unknown' } + /** + * Determine if the application has booted. + */ + 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 - 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) { @@ -233,13 +321,218 @@ 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 boot listener. + * + * @param callable $callback + */ + booting (callback: (app: this) => void): void { + this.bootingCallbacks.push(callback) + } + + /** + * Register a new "booted" listener. + * + * @param callback + */ + booted (callback: (app: this) => void): void { + this.bootedCallbacks.push(callback) + + if (this.isBooted()) { + callback(this) + } + } + + /** + * 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. + * + * @param callbacks + */ + protected fireAppCallbacks (callbacks: Array<(app: this) => 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 config Configuration option to pass to the initializer + */ + async handleRequest (config?: EntryConfig): Promise { + this.h3App?.all('/**', async (event) => { + // Define app context factory + this.context = (event) => this.buildContext(event, config) + + this.h3Event = event + + const context = await this.context!(event) + + const kernel = this.make(IKernel) + + 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) + + kernel.terminate(context.request, response!) + + let finalResponse: IResponse | IResponsable | undefined + + if (response && ['Response', 'JsonResponse'].includes(response.constructor.name)) { + finalResponse = response.prepare(context.request).send() + } else { + 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. + */ + async handleCommand () { + const kernel = this.make(CKernel) + + const status = await kernel.handle() + + kernel.terminate(status) + + return status + } + + /** + * 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. + */ + shouldSkipMiddleware () { + return this.bound('middleware.disable') && this.make('middleware.disable') === true + } + + /** + * Provide safe overides for the app + */ + configure (): AppBuilder { + return new AppBuilder(this) + .withKernels() + .withCommands() + } + + /** + * Check if the current application environment matches the one provided + */ + environment (env: E): E extends undefined ? string : boolean { + return (this.make('config').get('app.env') === env) as never + } + /** * Fire up the developement server using the user provided arguments * @@ -247,15 +540,39 @@ 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 { + async fire (): Promise + async fire (h3App: H3, preferredPort?: number): Promise + async fire (h3App?: H3, preferredPort?: number): Promise { + + if (h3App) + this.h3App = 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) + } + + + /** + * 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 + */ + 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) @@ -296,6 +613,98 @@ export class Application extends Container implements IApplication { return this } + /** + * Run the given array of bootstrap classes. + * + * @param bootstrappers + */ + async bootstrapWith (bootstrappers: ConcreteConstructor[]): Promise { + 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]) + } + + this.bootstrapped = true + } + + /** + * Determine if the application has been bootstrapped before. + */ + hasBeenBootstrapped (): boolean { + return this.bootstrapped + } + + /** + * 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 + } + + /** + * Set the HttpContext. + * + * @param ctx + */ + setHttpContext (ctx: IHttpContext): this { + this.httpContext = ctx + + return this + } + + /** + * Get the 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') + } + /** * Get the base path of the app * diff --git a/packages/core/src/Container.ts b/packages/core/src/Container.ts index aade1959..e616d765 100644 --- a/packages/core/src/Container.ts +++ b/packages/core/src/Container.ts @@ -1,10 +1,56 @@ -import type { Bindings, IContainer, UseKey } from '@h3ravel/shared' +import 'reflect-metadata' +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' +import { Application } from '.' -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 middlewareHandler?: MiddlewareHandler + /** + * The current globally available container (if any). + */ + protected static instance?: Application + + /** + * 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 = new Map any)[]>() + /** + * The container's shared instances. + */ + protected instances = new Map any>() + /** + * The container's resolved instances. + */ + protected resolvedInstances = new Map() + /** + * 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() + + /** + * The extension closures for services. + */ + protected extenders = new Map() /** * Check if the target has any decorators @@ -30,6 +76,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 @@ -40,8 +89,35 @@ 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 + * + * @param key */ unbind (key: T | T[]) { if (Array.isArray(key)) { @@ -56,50 +132,239 @@ 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]), 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 { + this.bindings.set(key, () => { if (!this.singletons.has(key)) { - this.singletons.set(key, factory(this)) + this.singletons.set(key, this.call(factory)) } - return this.singletons.get(key)! + + return this.singletons.get(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) + } + + /** + * 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 + * + * @param key */ make (key: T): Bindings[T] 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 */ - if (this.bindings.has(key)) { - return this.bindings.get(key)!() + let resolved: any + + if (this.resolvedInstances.has(abstract)) { + return this.resolvedInstances.get(abstract) + } + + 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(abstract) + } else { + throw new Error( + `No binding found for key: ${typeof abstract === 'string' ? abstract : (abstract as any)?.name}` + ) } /** - * If this is a class constructor, auto-resolve via reflection + * 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. */ - if (typeof key === 'function') { - return this.build(key) + for (const extender of this.getExtenders(abstract)) { + resolved = extender(resolved, this) } - throw new Error( - `No binding found for key: ${typeof key === 'string' ? key : (key as any)?.name}` - ) + if (raiseEvents) + this.runAfterResolvingCallbacks(abstract, resolved) + + this.resolvedInstances.set(abstract, resolved) + + return resolved + } + + /** + * Register a callback to be executed after a service is resolved + * + * @param key + * @param callback + */ + 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 + * + * @param key + * @param resolved + */ + 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) + } } /** * Automatically build a class with constructor dependency injection + * + * @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__)) { @@ -111,13 +376,266 @@ export class Container implements IContainer { dependencies = paramTypes.map((dep) => this.make(dep)) } + if (dependencies.length === 0) { + dependencies = [this] + } + return new ClassType(...dependencies) } + /** + * Determine if a given string is an alias. + * + * @param name + */ + isAlias (name: IBinding | 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)) + + if (abstract == null) + return abstract + + 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. + * + * @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 + } + + /** + * 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. + * + * @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 + * + * @param key + * @returns + */ + has (key: T): boolean + has any> (key: C): boolean + has any> (key: F): boolean + has (key: any): boolean { + 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) + } + + /** + * "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. + * + * @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(this) + } + + /** + * 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.get(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) + } + } + } + + /** + * 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 */ - has (key: UseKey): boolean { - return this.bindings.has(key) + static setInstance (container?: Application): Application | undefined { + return Container.instance = container } } diff --git a/packages/core/src/Contracts/H3ravelContract.ts b/packages/core/src/Contracts/H3ravelContract.ts index 811ba267..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. * @@ -24,4 +28,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/Contracts/ServiceProviderConstructor.ts b/packages/core/src/Contracts/ServiceProviderConstructor.ts index 0b9fac11..819b5ad0 100644 --- a/packages/core/src/Contracts/ServiceProviderConstructor.ts +++ b/packages/core/src/Contracts/ServiceProviderConstructor.ts @@ -1,8 +1,6 @@ -/// - 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..298df15b 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) { - this.app = app + 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/Di/Inject.ts b/packages/core/src/Di/Inject.ts deleted file mode 100644 index 060182d5..00000000 --- a/packages/core/src/Di/Inject.ts +++ /dev/null @@ -1,35 +0,0 @@ -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; -// } 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 8fd80e1a..bc57c896 100644 --- a/packages/core/src/H3ravel.ts +++ b/packages/core/src/H3ravel.ts @@ -1,8 +1,10 @@ -import { Application, ConfigException, Kernel, OServiceProvider } from '.' -import { HttpContext, LogRequests, Request, Response } from '@h3ravel/http' +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' /** * Simple global entry point for H3ravel applications @@ -24,93 +26,44 @@ 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: HttpContext) => Promise = async () => undefined, ): Promise => { // Initialize the H3 app instance 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) { + 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) + // @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 h3App = app.make('http.app') + app.setH3App(h3App) - // Define app context factory - app.context = async (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(event, app), - }); - - (event as any)._h3ravelContext = ctx - return ctx - } + app.singleton(IApplication, () => app) + if (!Facades.getApplication()) Facades.setApplication(app) + if (!Helpers.isLoaded()) Helpers.load(app) - // Initialize the Application Kernel - const kernel = new Kernel(async (event) => app.context!(event), [new LogRequests()]) - - // Register kernel with H3 - h3App.use((event) => kernel.handle(event, middleware)) + await app.handleRequest(config) } catch { if (!h3App && config.h3) { h3App = config.h3 } } - 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/Http/Kernel.ts b/packages/core/src/Http/Kernel.ts index 10195c4d..1e1f585b 100644 --- a/packages/core/src/Http/Kernel.ts +++ b/packages/core/src/Http/Kernel.ts @@ -1,20 +1,39 @@ -import type { HttpContext, IMiddleware } from '@h3ravel/shared' +import { Arr, Obj } from '@h3ravel/support' +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) => IHttpContext | Promise + protected applicationContext!: IHttpContext + /** - * @param context - A factory function that converts an H3Event into an 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.router = app.make('router') + this.context = async (event) => app.context!(event) + } /** * Handles an incoming request and passes it through middleware before invoking the next handler. @@ -25,39 +44,40 @@ 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 */ - const ctx = await this.context(event) + this.applicationContext = await this.context(event) - const { app } = ctx.request - - /** - * 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([], this.app); + + (request.constructor as any).enableHttpMethodParameterOverride() /** * 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') } @@ -65,48 +85,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 - - const runner = async (i: number): Promise => { - if (i <= index) throw new Error('next() called multiple times') - index = i - const middleware = this.middleware[i] - - if (middleware) { - /** - * Execute the current middleware and proceed to the next one - */ - return middleware.handle(context, () => runner(i + 1)) - } else { - /** - * If no more middleware, call the final handler - */ - return next(context) - } - } + public async resolve ( + event: H3Event, + middleware: IMiddleware | IMiddleware[], + handler: (ctx: IHttpContext) => Promise + ): Promise { + const { Response } = await import('@h3ravel/http') - return runner(0) - } + this.middleware = Array.from(new Set([...this.middleware, ...Arr.wrap(middleware)])) - /** - * 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) + return this.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 never).send()) + } else { + resolve(response) + } + }) + } else { + resolve(handler(ctx)) + } + })) } } diff --git a/packages/core/src/Di/ContainerResolver.ts b/packages/core/src/Manager/ContainerResolver.ts similarity index 67% rename from packages/core/src/Di/ContainerResolver.ts rename to packages/core/src/Manager/ContainerResolver.ts index 1ca21e94..adb65acc 100644 --- a/packages/core/src/Di/ContainerResolver.ts +++ b/packages/core/src/Manager/ContainerResolver.ts @@ -1,9 +1,15 @@ import 'reflect-metadata' import { Application } from '..' +import { IApplication } from '@h3ravel/contracts' + +type Predicate = + | string + | ((...args: any[]) => any) + | (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[]) { /** @@ -33,9 +39,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/Inject.ts b/packages/core/src/Manager/Inject.ts new file mode 100644 index 00000000..0c023dc1 --- /dev/null +++ b/packages/core/src/Manager/Inject.ts @@ -0,0 +1 @@ +export { Inject, Injectable } from '@h3ravel/foundation' diff --git a/packages/core/src/ProviderRegistry.ts b/packages/core/src/ProviderRegistry.ts index addb9ae9..3788d83e 100644 --- a/packages/core/src/ProviderRegistry.ts +++ b/packages/core/src/ProviderRegistry.ts @@ -1,13 +1,13 @@ +import { ConcreteConstructor, IServiceProvider } from '@h3ravel/contracts' + import type { Application } from './Application' -import { ContainerResolver } from '../src/Di/ContainerResolver' -import { ServiceProvider } from './ServiceProvider' +import { ContainerResolver } from '../src/Manager/ContainerResolver' +import { createRequire } from 'module' 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 +27,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 +49,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 +66,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 +92,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,51 +116,50 @@ export class ProviderRegistry { * @param providers * @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) - }) + 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)) + + // 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 */ 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) @@ -175,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( @@ -194,7 +195,7 @@ export class ProviderRegistry { * * @returns */ - static all (): ProviderCtor[] { + static all (): ConcreteConstructor[] { return Array.from(this.providers.values()) } @@ -204,7 +205,7 @@ export class ProviderRegistry { * @param provider * @returns */ - static has (provider: ProviderCtor): boolean { + static has (provider: ConcreteConstructor): boolean { return this.providers.has(this.getKey(provider)) } @@ -221,12 +222,11 @@ export class ProviderRegistry { 'node_modules/h3ravel-*/package.json', ]) - const providers: ProviderCtor[] = [] + 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( pkg.h3ravel.providers.map( @@ -235,7 +235,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) } @@ -250,15 +250,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/Providers/CoreServiceProvider.ts b/packages/core/src/Providers/CoreServiceProvider.ts index a3762a68..9cd5c3c7 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,13 +21,10 @@ export class CoreServiceProvider extends ServiceProvider { Object.assign(globalThis, { str, }) + + this.app.alias(IApplication, Application) } boot (): void | Promise { - try { - Object.assign(globalThis, { - asset: this.app.make('asset'), - }) - } catch {/** */ } } } 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..971466c8 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/support' \ No newline at end of file diff --git a/packages/core/src/app.globals.d.ts b/packages/core/src/app.globals.d.ts deleted file mode 100644 index 1cdbe34a..00000000 --- a/packages/core/src/app.globals.d.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { HTTPResponse } from 'h3' - -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/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/src/index.ts b/packages/core/src/index.ts index b697782a..761e7bcf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,12 +3,10 @@ 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/Inject' export * from './ProviderRegistry' export * from './Providers/CoreServiceProvider' export * from './Registerer' diff --git a/packages/core/tests/single-entry-point.test.ts b/packages/core/tests/single-entry-point.test.ts index eba307d0..7d280bbc 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 { beforeEach, describe, expect, it } from 'vitest' -import { FileSystem } from '@h3ravel/shared' +import { Application } from '@h3ravel/core' +import { ConfigException } from '@h3ravel/foundation' 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 { EventsServiceProvider } = await import(('@h3ravel/events')) + const { RouteServiceProvider } = await import(('@h3ravel/router')) + app = await h3ravel([EventsServiceProvider, RouteServiceProvider]) }) it('returns the fully configured Application instance', async () => { @@ -23,15 +18,15 @@ 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]')) }) }) 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 () => { @@ -39,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/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/database/package.json b/packages/database/package.json index 3e865ab2..27807220 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/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/database/src/Model.ts b/packages/database/src/Model.ts index e981526a..463bdb3a 100644 --- a/packages/database/src/Model.ts +++ b/packages/database/src/Model.ts @@ -1,14 +1,108 @@ +import { BelongsToMany, HasManyThrough } from '@h3ravel/arquebus/relations' +import { IBuilder, Relation } 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 + */ + // @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 + } + + /** + * Retrieve the model for a bound value. + * + * @param query + * @param value + * @param field + */ + 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. + */ + getRouteKey () { + return this.getAttribute(this.getRouteKeyName()) + } + + /** + * Get the route key for the model. */ - public resolveRouteBinding (value: any, field: undefined | string | null = null): Promise { - return this.newQuery().where(field ?? 'ids', value).firstOrFail()! as Promise + getRouteKeyName () { + return this.getKeyName() } } diff --git a/packages/database/src/Providers/DatabaseServiceProvider.ts b/packages/database/src/Providers/DatabaseServiceProvider.ts index 6f4cd092..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' @@ -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/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 @@ +

+ +# 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/router/src/Contracts/.gitkeep b/packages/events/src/Contracts/.gitkeep similarity index 100% rename from packages/router/src/Contracts/.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..dd4fa820 --- /dev/null +++ b/packages/events/src/Providers/EventsServiceProvider.ts @@ -0,0 +1,31 @@ +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([ + ['events', 'app.events'], + [Dispatcher, 'app.events'], + [IDispatcher, 'app.events'], + ]) + } +} 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/Middleware/.gitkeep b/packages/events/tests/.gitkeep similarity index 100% rename from packages/router/src/Middleware/.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/filesystem/package.json b/packages/filesystem/package.json index fc2883fd..f4978cf2 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": [ 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..4df23c93 --- /dev/null +++ b/packages/foundation/package.json @@ -0,0 +1,75 @@ +{ + "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": { + "@types/supertest": "^6.0.3", + "@h3ravel/contracts": "workspace:^", + "supertest": "^7.1.4" + }, + "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 new file mode 100644 index 00000000..35f8f353 --- /dev/null +++ b/packages/foundation/src/Adapters/InMemoryRateLimiter.ts @@ -0,0 +1,31 @@ +import { RateLimiterAdapter } from '@h3ravel/contracts' + +/** + * 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/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 new file mode 100644 index 00000000..bc529334 --- /dev/null +++ b/packages/foundation/src/Configuration/AppBuilder.ts @@ -0,0 +1,229 @@ +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, AssetsServiceProvider } from '@h3ravel/support' +import { existsSync, statSync } from 'node:fs' +import { Command } from '@h3ravel/musket' + +export class AppBuilder { + + /** + * The Folio / page middleware that have been defined by the user. + */ + 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. + */ + withKernels () { + this.app.singleton(IKernel, Kernel) + + this.app.singleton(CKernel, () => new ConsoleKernel(this.app)) + + return this + } + + /** + * Register and wire up the application's exception handling layer. + * + * @param using + **/ + withExceptions (using: (exceptions: Exceptions) => void) { + // Register the ExceptionHandler as a singleton + 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(IExceptionHandler, (handler) => { + using(new Exceptions(handler)) + }) + + return this + } + + /** + * Register and wire up the application's middleware handling layer. + * + * @param using + **/ + 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) + // TODO: Implement the route() method and use here + .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 + } + + /** + * 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, AssetsServiceProvider]) + }) + + 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, 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 + */ + create () { + + } +} \ No newline at end of file diff --git a/packages/foundation/src/Configuration/Middleware.ts b/packages/foundation/src/Configuration/Middleware.ts new file mode 100644 index 00000000..7dbecf4d --- /dev/null +++ b/packages/foundation/src/Configuration/Middleware.ts @@ -0,0 +1,610 @@ +import { IApplication, IMiddleware, MiddlewareList, RedirectHandler } from '@h3ravel/contracts' + +import { Arr } from '@h3ravel/support' + +/** + * 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: 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 = {} + + constructor(private app?: IApplication) { } + + /** + * Prepend middleware to the application's global middleware stack. + * + * @param middleware + * @returns + */ + public prepend (middleware: MiddlewareList | IMiddleware): 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 | IMiddleware): 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 | IMiddleware): 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 | IMiddleware): 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 | IMiddleware): 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 | IMiddleware): 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: IMiddleware): 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 | IMiddleware | [] = [], + prepend: MiddlewareList | IMiddleware | [] = [], + remove: MiddlewareList | IMiddleware | [] = [], + 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 | IMiddleware | [] = [], + prepend: MiddlewareList | IMiddleware | [] = [], + remove: MiddlewareList | IMiddleware | [] = [], + 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 | 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) + } + 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 [key, middleware] of Object.entries(replace)) { + this.replaceInGroup(group, key, middleware) + } + } + return this + } + /** + * Register the page middleware for the application. + * + * @param middleware + * @returns + */ + public pages (middleware: MiddlewareList[]): 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: MiddlewareList): this { + this._priority = [...list] + return this + } + + /** + * Prepend middleware to the priority middleware. + * + * @param before + * @param prependKey + * @returns + */ + public prependToPriorityList (before: IMiddleware, prependKey: string): this { + this.prependPriority[prependKey] = before + return this + } + + /** + * Append middleware to the priority middleware + * + * @param after + * @param appendKey + * @returns + */ + public appendToPriorityList (after: IMiddleware, 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. + */ + public getMiddlewareGroups (): Record { + const built: Record = {} + + 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), + } + + // start with defaults if provided, else use current groups + const base = { ...middleware, ...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 for the authentication and guest middleware. + * + * @param guests + * @param users + * @returns + */ + public redirectTo (guests?: RedirectHandler, users?: RedirectHandler): this { + + guests = typeof guests === 'string' ? () => String(guests) : guests + users = typeof users === 'string' ? () => String(users) : users + + if (guests) { + // Authenticate.redirectUsing(guests) + // AuthenticateSession.redirectUsing(guests) + // AuthenticationException.redirectUsing(guests) + } + + if (users) { + // RedirectIfAuthenticated.redirectUsing(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 (): MiddlewareList[] { + 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', + } + + // @ts-expect-error TODO: ensure that actuall middlewares are aliased, not strings + return aliases + } + + /** + * Get the middleware priority for the application + * + * @returns + */ + public getMiddlewarePriority (): MiddlewareList { + 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/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..eb12d715 --- /dev/null +++ b/packages/foundation/src/Console/ConsoleKernel.ts @@ -0,0 +1,296 @@ +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 '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() + await this.bootstrap() + + try { + 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)], + exceptionHandler: (e) => { + this.reportException(e) + this.renderException(e) + } + }) + .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/Decorators.ts b/packages/foundation/src/Container/Decorators.ts new file mode 100644 index 00000000..d248e1a0 --- /dev/null +++ b/packages/foundation/src/Container/Decorators.ts @@ -0,0 +1,36 @@ +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 +// } \ 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/Database/Exceptions/ModelNotFoundException.ts b/packages/foundation/src/Database/Exceptions/ModelNotFoundException.ts new file mode 100755 index 00000000..99f31ba2 --- /dev/null +++ b/packages/foundation/src/Database/Exceptions/ModelNotFoundException.ts @@ -0,0 +1,50 @@ +import { ConcreteConstructor, IModel } from '@h3ravel/contracts' + +import { Arr } from '@h3ravel/support' +import { RecordsNotFoundException } from './RecordsNotFoundException' + +export class ModelNotFoundException extends RecordsNotFoundException { + /** + * Name of the affected Eloquent model. + */ + protected model?: ConcreteConstructor + + /** + * The affected model IDs. + */ + protected ids: (number | string)[] = [] + + /** + * Set the affected Eloquent model and instance ids. + * + * @param model + * @param ids + */ + public setModel (model: ConcreteConstructor, ids: (number | string)[] = []) { + this.model = model + this.ids = Arr.wrap(ids) + this.message = `No query results for model [${model.name ?? 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/foundation/src/Database/Exceptions/RecordNotFoundException.ts b/packages/foundation/src/Database/Exceptions/RecordNotFoundException.ts new file mode 100644 index 00000000..e80a38b2 --- /dev/null +++ b/packages/foundation/src/Database/Exceptions/RecordNotFoundException.ts @@ -0,0 +1,2 @@ +export class RecordNotFoundException extends Error { +} diff --git a/packages/foundation/src/Database/Exceptions/RecordsNotFoundException.ts b/packages/foundation/src/Database/Exceptions/RecordsNotFoundException.ts new file mode 100644 index 00000000..754108bd --- /dev/null +++ b/packages/foundation/src/Database/Exceptions/RecordsNotFoundException.ts @@ -0,0 +1,4 @@ +import { NotFoundHttpException } from '../../Exceptions/NotFoundHttpException' + +export class RecordsNotFoundException extends NotFoundHttpException { +} diff --git a/packages/foundation/src/Exceptions/AccessDeniedHttpException.ts b/packages/foundation/src/Exceptions/AccessDeniedHttpException.ts new file mode 100644 index 00000000..9c833b87 --- /dev/null +++ b/packages/foundation/src/Exceptions/AccessDeniedHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './Base/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..9b4a677b --- /dev/null +++ b/packages/foundation/src/Exceptions/BadRequestHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './Base/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/Base/ExceptionHandler.ts b/packages/foundation/src/Exceptions/Base/ExceptionHandler.ts new file mode 100644 index 00000000..336773f7 --- /dev/null +++ b/packages/foundation/src/Exceptions/Base/ExceptionHandler.ts @@ -0,0 +1,51 @@ +import { Handler } from './Handler' +import { HttpException } from './HttpException' +import { IHttpContext } from '@h3ravel/contracts' +import { RequestException } from './RequestException' + +export class ExceptionHandler extends Handler { + public async handle (error: Error, ctx: IHttpContext) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + 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.request) + 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.request, 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/Base/Exceptions.ts b/packages/foundation/src/Exceptions/Base/Exceptions.ts new file mode 100644 index 00000000..5786701c --- /dev/null +++ b/packages/foundation/src/Exceptions/Base/Exceptions.ts @@ -0,0 +1,157 @@ +import { Arr } from '@h3ravel/support' +import { IExceptionHandler } from '@h3ravel/contracts' +import { RequestException } from './RequestException' + +export class Exceptions { + /** + * Create a new exception handling configuration instance. + */ + constructor(public handler: IExceptionHandler) { } + + /** + * 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 | Error, level: 'log' | 'debug' | 'warn' | 'info' | 'error') { + 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 + } + + /** + * 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. + */ + 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/Base/Handler.ts b/packages/foundation/src/Exceptions/Base/Handler.ts new file mode 100644 index 00000000..c1a59347 --- /dev/null +++ b/packages/foundation/src/Exceptions/Base/Handler.ts @@ -0,0 +1,744 @@ +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, 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' + +/** + * + * Base Exception Handler + * . + * - We will use `RateLimiterAdapter` to plug in Redis / cache-backed limiters later. + */ +export abstract class Handler extends IExceptionHandler { + /** + * List of exception constructors that should not be reported. + */ + protected dontReportList: ExceptionConstructor[] = [] + + /** + * A map of exceptions with their corresponding custom log levels. + */ + protected levels = new Map>() + + /** + * Internal exceptions that are not reported by default. Subclasses may expand. + */ + protected internalDontReport: ExceptionConstructor[] = [] + + /** + * Callbacks that inspect exceptions to determine if they should NOT be reported. + */ + protected dontReportCallbacks: ExceptionConditionCallback[] = [] + + /** + * Reportable callbacks (can cancel reporting by returning false). + */ + protected reportCallbacks: ReportExceptionCallback[] = [] + + /** + * Render callbacks (can return a Response for a specific exception type). + */ + protected renderCallbacks: RenderExceptionCallback[] = [] + + /** + * 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: ThrottleExceptionCallback[] = [] + + /** + * 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() + + /** + * The exception handler method + * + * @param error + * @param ctx + */ + handle?(error: Error, ctx: IHttpContext): 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 + */ + reportable (cb: ReportExceptionCallback) { + this.reportCallbacks.push(cb) + return this + } + + renderable (cb: RenderExceptionCallback) { + this.renderCallbacks.push(cb) + return this + } + + dontReport (exceptions: ExceptionConstructor | ExceptionConstructor[]) { + const arr = Array.isArray(exceptions) ? exceptions : [exceptions] + this.dontReportList = Array.from(new Set([...this.dontReportList, ...arr])) + return this + } + + 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 + } + + dontReportWhen (cb: ExceptionConditionCallback) { + this.dontReportCallbacks.push(cb) + return this + } + + dontReportDuplicates () { + this.withoutDuplicates = true + return this + } + + map (from: ExceptionConstructor, mapper: (error: any) => any) { + this.exceptionMap.set(from, mapper) + return this + } + + throttleUsing (cb: ThrottleExceptionCallback) { + this.throttleCallbacks.push(cb) + return this + } + + buildContextUsing (cb: (e: any, current?: Record) => Record) { + this.contextCallbacks.push(cb) + return this + } + + setRateLimiter (adapter: RateLimiterAdapter) { + this.rateLimiter = adapter + return this + } + + respondUsing (cb: (response: IResponse, error: any, request: IRequest) => IResponse | Promise) { + this.finalizeResponseCallback = cb + return this + } + + shouldRenderJsonWhen (cb: (request: IRequest, error: any) => boolean) { + this.shouldRenderJsonWhenCallback = cb + return this + } + + /** + * Entry point to reporting an exception. + * + * @param error + * @returns + */ + async report (error: Error): Promise { + const e = this.mapException(error) + + if (this.shouldntReport(e)) { + return + } + + 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. + * + * @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[level] === 'function') { + logger[level](context) + } else if (typeof logger.log === 'function') { + logger.log(level, context) + } else { + Console.error(`[${level}]`, 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 + */ + async render (request: IRequest, 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(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 ResponsableType-like `toResponse(request)` + */ + if (e && typeof e.toResponse === 'function') { + try { + const resp = await Promise.resolve(e.toResponse(request)) + + 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 + } + } + + /** + * Try render callbacks + */ + for (const cb of this.renderCallbacks) { + try { + const resp = await Promise.resolve(cb(e, request)) + if (resp instanceof Response) { + return this.finalizeRenderedResponse(request, resp, e) + } + } catch { + // swallow render callback errors + } + } + + /** + * Return JSON response when shouldRenderJson / expectsJson, else generic HTML/text + */ + if (this.shouldReturnJson(request, e)) { + return this.finalizeRenderedResponse(request, this.prepareJsonResponse(request, e), e) + } + + return await this.finalizeRenderedResponse(request, await this.prepareResponse(request, e), e) + } + + /** + * getResponse + */ + getResponse (request: IRequest, payload: Record, e: any): IResponse | Promise { + if (this.shouldReturnJson(request, e)) { + return response() + .setCharset('utf-8') + .setStatusCode(this.isHttpException(e) ? e.getStatusCode() : 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() + .setCharset('utf-8') + .setStatusCode(this.isHttpException(e) ? e.getStatusCode() : 500) + .viewTemplate(readFileSync(view, { encoding: 'utf-8' }), { + statusCode: this.isHttpException(e) ? e.getStatusCode() : 500, + statusText: statusTexts[this.isHttpException(e) ? e.getStatusCode() : 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 { + void request + + 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() + .setCharset('utf-8') + .setStatusCode(this.isHttpException(e) ? e.getStatusCode() : 500) + .viewTemplate(readFileSync(view, { encoding: 'utf-8' }), { + statusCode: this.isHttpException(e) ? e.getStatusCode() : 500, + statusText: statusTexts[this.isHttpException(e) ? e.getStatusCode() : 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() + .setCharset('utf-8') + .setStatusCode(this.isHttpException(e) ? e.getStatusCode() : 500) + .json(payload) + } + + /** + * Convert exception into debug-friendly array/object. + * + * @param e + * @returns + */ + protected convertExceptionToArray (e: any): { message?: string; exception?: string; trace?: string[] } { + 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 { + /** + * TODO: To be implemented + * Example: if we have an Auth module, we canfetch 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: ExceptionConstructor[]) { + 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 HttpExceptionFactory { + return e instanceof HttpExceptionFactory + } + + /** + * Default mapping — subclasses can override for custom logic + * + * @param _e + */ + 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 () { + 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 + */ + truncateRequestExceptionsAt (_length: number) { + return this + } + + /** + * Set the log level + * + * @param _attributes + */ + level (type: string | Error, level: Exclude) { + this.levels.set(type, level) + return this + } + + /** + * Not implemented here; applicable to validation pipeline/UI. + * + * @param _attributes + */ + dontFlash (_attributes: string | string[]) { + return this + } +} \ No newline at end of file diff --git a/packages/foundation/src/Exceptions/Base/HttpException.ts b/packages/foundation/src/Exceptions/Base/HttpException.ts new file mode 100644 index 00000000..c3bbab13 --- /dev/null +++ b/packages/foundation/src/Exceptions/Base/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/Base/HttpExceptionFactory.ts b/packages/foundation/src/Exceptions/Base/HttpExceptionFactory.ts new file mode 100644 index 00000000..91593b93 --- /dev/null +++ b/packages/foundation/src/Exceptions/Base/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/Base/RequestException.ts b/packages/foundation/src/Exceptions/Base/RequestException.ts new file mode 100644 index 00000000..06a87c6f --- /dev/null +++ b/packages/foundation/src/Exceptions/Base/RequestException.ts @@ -0,0 +1,67 @@ +import { IResponse } from '@h3ravel/contracts' + +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/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/ConflictHttpException.ts b/packages/foundation/src/Exceptions/ConflictHttpException.ts new file mode 100644 index 00000000..fc77d8c4 --- /dev/null +++ b/packages/foundation/src/Exceptions/ConflictHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './Base/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/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 68% rename from packages/core/src/Exceptions/ConfigException.ts rename to packages/foundation/src/Exceptions/Core/ConfigException.ts index c788aaaa..889a5a4d 100644 --- a/packages/core/src/Exceptions/ConfigException.ts +++ b/packages/foundation/src/Exceptions/Core/ConfigException.ts @@ -2,12 +2,11 @@ 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`, - 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) @@ -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 new file mode 100644 index 00000000..8c89dd78 --- /dev/null +++ b/packages/foundation/src/Exceptions/GoneHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './Base/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/LengthRequiredHttpException.ts b/packages/foundation/src/Exceptions/LengthRequiredHttpException.ts new file mode 100644 index 00000000..b1c91221 --- /dev/null +++ b/packages/foundation/src/Exceptions/LengthRequiredHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './Base/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..c3809652 --- /dev/null +++ b/packages/foundation/src/Exceptions/LockedHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './Base/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..7dce989f --- /dev/null +++ b/packages/foundation/src/Exceptions/NotAcceptableHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './Base/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..a78c4af4 --- /dev/null +++ b/packages/foundation/src/Exceptions/NotFoundHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './Base/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..8e2caf5b --- /dev/null +++ b/packages/foundation/src/Exceptions/PreconditionFailedHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './Base/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..827906aa --- /dev/null +++ b/packages/foundation/src/Exceptions/PreconditionRequiredHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './Base/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/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/ServiceUnavailableHttpException.ts b/packages/foundation/src/Exceptions/ServiceUnavailableHttpException.ts new file mode 100644 index 00000000..041041e0 --- /dev/null +++ b/packages/foundation/src/Exceptions/ServiceUnavailableHttpException.ts @@ -0,0 +1,25 @@ +import { HttpExceptionFactory } from './Base/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..effaa170 --- /dev/null +++ b/packages/foundation/src/Exceptions/TooManyRequestsHttpException.ts @@ -0,0 +1,25 @@ +import { HttpExceptionFactory } from './Base/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..c967fea0 --- /dev/null +++ b/packages/foundation/src/Exceptions/UnprocessableEntityHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './Base/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..32338b2c --- /dev/null +++ b/packages/foundation/src/Exceptions/UnsupportedMediaTypeHttpException.ts @@ -0,0 +1,12 @@ +import { HttpExceptionFactory } from './Base/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/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/Helpers.ts b/packages/foundation/src/Helpers.ts new file mode 100644 index 00000000..83ba3754 --- /dev/null +++ b/packages/foundation/src/Helpers.ts @@ -0,0 +1,158 @@ +import { IApplication, IUrlGenerator, RouteParams } from '@h3ravel/contracts' + +export class Helpers { + 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. + * + * @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?: RouteParams, 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): any => { + 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/Events/RequestHandled.ts b/packages/foundation/src/Http/Events/RequestHandled.ts new file mode 100644 index 00000000..c2edba51 --- /dev/null +++ b/packages/foundation/src/Http/Events/RequestHandled.ts @@ -0,0 +1,24 @@ +import { IRequest, IResponse } from '@h3ravel/contracts' + +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..62e63ad6 --- /dev/null +++ b/packages/foundation/src/Http/Kernel.ts @@ -0,0 +1,578 @@ +import { Arr, DateTime, InvalidArgumentException } from '@h3ravel/support' +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 { RequestHandled } from './Events/RequestHandled' +import { Terminating } from '../Core/Events/Terminating' + +@Injectable() +export class Kernel extends mix(IKernel, use(InteractsWithTime)) { + /** + * The bootstrap classes for the application. + */ + #bootstrappers: ConcreteConstructor[] = [ + RegisterFacades + ] + + /** + * 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 as any).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 { + const { Pipeline } = await import('@h3ravel/router') + + this.app.instance('request', request) + + Facades.clearResolvedInstance('request') + + await this.bootstrap() + + 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 + */ + async bootstrap () { + if (!this.app.hasBeenBootstrapped()) { + await 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): void { + 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) + } + } + + response.reset() + 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.push({ + threshold, + 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) + } + + // 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') + } + } + + /** + * 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.make(IExceptionHandler).report(e) + } + + /** + * Render the exception to a response. + * + * @param request + * @param e + */ + protected renderException (request: IRequest, e: Error) { + return this.app.make(IExceptionHandler).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 new file mode 100644 index 00000000..4c5bf62a --- /dev/null +++ b/packages/foundation/src/Http/MiddlewareHandler.ts @@ -0,0 +1,60 @@ +import { IApplication, IHttpContext, IMiddleware, IMiddlewareHandler } from '@h3ravel/contracts' + +import { Arr } from '@h3ravel/support' + +/* + * Handles registration and execution of middleware. + * Every middleware implements IMiddleware with a handle(context, next) method. + */ +export class MiddlewareHandler implements IMiddlewareHandler { + constructor(private middleware: IMiddleware[] = [], private app: IApplication) { } + + /** + * 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: IHttpContext, + next: (ctx: IHttpContext) => 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 + */ + // 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) + } +} \ No newline at end of file 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/Testing/supertestAdapter.ts b/packages/foundation/src/Testing/supertestAdapter.ts new file mode 100644 index 00000000..401437b2 --- /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/support')) + + 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/app.globals.d.ts b/packages/foundation/src/app.globals.d.ts new file mode 100644 index 00000000..5e26434f --- /dev/null +++ b/packages/foundation/src/app.globals.d.ts @@ -0,0 +1,190 @@ +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 + */ + function env (): NodeJS.ProcessEnv; + /** + * Global env variable + * + * @param key + * @param defaultValue + */ + function env (key: T, defaultValue?: any): any; + + /** + * Load config options + */ + function config> (): X; + /** + * 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. + * + * @param path + * @param parameters + * @param secure + */ + function url (path?: string, parameters: (string | number)[] = [], secure?: boolean): 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 defaultValue Default asset to serve if asset does not exist + */ + function asset (asset: string, defaultValue?: 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 new file mode 100644 index 00000000..424da96c --- /dev/null +++ b/packages/foundation/src/index.ts @@ -0,0 +1,49 @@ +export * from './Helpers' +export * from './Adapters/InMemoryRateLimiter' +export * from './Bootstrapers/BootProviders' +export * from './Bootstrapers/RegisterFacades' +export * from './Configuration/AppBuilder' +export * from './Configuration/Middleware' +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' +export * from './Exceptions/LockedHttpException' +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 './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' +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/src/views/errors/error.edge b/packages/foundation/src/views/errors/error.edge new file mode 100644 index 00000000..1aa35950 --- /dev/null +++ b/packages/foundation/src/views/errors/error.edge @@ -0,0 +1,118 @@ + + + + + + + {{ statusCode || 500 }} | Something went wrong + + + + +
+

{{ statusCode || 500 }}

+

{{ 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..8bedfcfe --- /dev/null +++ b/packages/foundation/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "exclude": ["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/hashing/package.json b/packages/hashing/package.json index 14f34c20..ee3b807e 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": [ @@ -64,6 +64,8 @@ }, "peerDependencies": { "@h3ravel/core": "workspace:^", + "@h3ravel/foundation": "workspace:^", + "@h3ravel/shared": "workspace:^", "@h3ravel/support": "workspace:^" }, "peerDependenciesMeta": { @@ -72,6 +74,8 @@ } }, "dependencies": { - "argon2": "catalog:" + "argon2": "catalog:", + "@h3ravel/contracts": "workspace:^", + "@h3ravel/foundation": "workspace:^" } } \ No newline at end of file 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 e8b92563..584c3c20 100644 --- a/packages/hashing/src/Utils/Manager.ts +++ b/packages/hashing/src/Utils/Manager.ts @@ -1,17 +1,19 @@ -import { InvalidArgumentException, type SnakeToTitleCase, Str } from '@h3ravel/support' +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' 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` -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/package.json b/packages/http/package.json index 4d267ece..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": [ @@ -56,15 +56,17 @@ "version-patch": "pnpm version patch" }, "dependencies": { + "@h3ravel/contracts": "workspace:^", "@h3ravel/support": "workspace:^", "@h3ravel/musket": "catalog:prod", "@h3ravel/shared": "workspace:^", - "@h3ravel/url": "workspace:^", + "@h3ravel/session": "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 704728f2..040cdfb7 100644 --- a/packages/http/src/HttpContext.ts +++ b/packages/http/src/HttpContext.ts @@ -1,29 +1,42 @@ -import { IApplication, type HttpContext as IHttpContext, IRequest, IResponse } from '@h3ravel/shared' +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 constructor( 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. * @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! + 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 new file mode 100644 index 00000000..b7dd9f20 --- /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 ??= {} + + 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/Middleware.ts b/packages/http/src/Middleware.ts index 158c0154..50627a57 100644 --- a/packages/http/src/Middleware.ts +++ b/packages/http/src/Middleware.ts @@ -1,6 +1,10 @@ -import { HttpContext } from './HttpContext' -import { IMiddleware } from '@h3ravel/shared' +import { IApplication, IMiddleware } from '@h3ravel/contracts' -export abstract class Middleware implements IMiddleware { - 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 new file mode 100644 index 00000000..2f56cd4a --- /dev/null +++ b/packages/http/src/Middleware/FlashDataMiddleware.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@h3ravel/foundation' +import { Middleware } from '../Middleware' +import { Request } from '..' + +export class FlashDataMiddleware extends Middleware { + @Injectable() + async handle (request: Request, next: (request: Request) => Promise): Promise { + const _next = await next(request) + + request.session().ageFlashData() + + return _next + } +} diff --git a/packages/http/src/Middleware/LogRequests.ts b/packages/http/src/Middleware/LogRequests.ts index e235fc4c..483421f1 100644 --- a/packages/http/src/Middleware/LogRequests.ts +++ b/packages/http/src/Middleware/LogRequests.ts @@ -1,10 +1,34 @@ -import { HttpContext } from '../HttpContext' +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 { - async handle ({ request }: HttpContext, next: () => Promise): Promise { - Logger.log([[` ${request.method()} `, 'bgBlue'], [request.fullUrl(), 'white']], ' ') - return next() + @Injectable() + async handle (request: IRequest, next: (request: IRequest) => Promise) { + const _next = await next(request) + + 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 = 'bgYellow' + + 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 } } 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..e33fb917 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' import { FireCommand } from '../Commands/FireCommand' @@ -17,19 +16,22 @@ 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 */ - 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([ + [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 3d9df8cc..aa7780b7 100644 --- a/packages/http/src/Request.ts +++ b/packages/http/src/Request.ts @@ -1,16 +1,18 @@ 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 { 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' -export class Request extends HttpRequest implements IRequest { +export class Request< + D extends Record = Record, + R extends RulesForData = RulesForData, + U extends Record = Record +> extends HttpRequest implements IRequest { /** * The decoded JSON content for the request. */ @@ -21,6 +23,16 @@ export class Request extends HttpRequest implements IRequest { */ 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 @@ -29,7 +41,7 @@ export class Request extends HttpRequest implements IRequest { /** * The current app instance */ - app: Application + app: IApplication ) { if (Request.httpMethodParameterOverride) { HttpRequest.enableHttpMethodParameterOverride() @@ -48,12 +60,31 @@ export class Request extends HttpRequest implements IRequest { /** * The current app instance */ - app: Application + app: IApplication ) { const instance = new Request(event, app) await instance.setBody() - await instance.initialize() - globalThis.request = () => instance + instance.initialize() + 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() return instance } @@ -100,10 +131,28 @@ 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 } = await import('@h3ravel/validation') + + const validator = new Validator(this.all(), rules, messages) + + return await validator.validate() as D + } + /** * 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) { @@ -127,11 +176,11 @@ export class Request extends HttpRequest implements IRequest { * @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']) } @@ -149,13 +198,11 @@ export class Request extends HttpRequest implements IRequest { * @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 { + 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 @@ -170,13 +217,40 @@ export class Request extends HttpRequest implements IRequest { return files as any } + /** + * Get the user making the request. + * + * @param guard + */ + user (guard?: string): U | undefined { + return Reflect.apply(this.getUserResolver(), this, [guard]) + } + + /** + * Get the route handling the request. + * + * @param param + * @param defaultRoute + */ + 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) { + return route + } + + return route.parameter(param, defaultParam) + } + /** * Determine if the uploaded data contains a file. * * @param key * @return boolean */ - public hasFile (key: string): boolean { + hasFile (key: string): boolean { let files = this.file(key, undefined, true) if (!Array.isArray(files)) { @@ -198,7 +272,7 @@ export class Request extends HttpRequest implements IRequest { /** * Get an object with all the files on the request. */ - public allFiles () { + allFiles () { if (this.convertedFiles) return this.convertedFiles const entries = Object @@ -213,7 +287,7 @@ export class Request extends HttpRequest implements IRequest { /** * Extract and convert uploaded files from FormData. */ - public convertUploadedFiles ( + convertUploadedFiles ( files: Record ): Record { if (!this.formData) @@ -240,20 +314,31 @@ export class Request extends HttpRequest implements IRequest { return files } + /** + * Get the current decoded path info for the request. + */ + decodedPath () { + try { + return decodeURIComponent(this.path()) + } catch { + return this.path() + } + } + /** * Determine if the data contains a given key. * * @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) @@ -265,19 +350,26 @@ export class Request extends HttpRequest implements IRequest { * @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 } + /** + * Determine if the request is over HTTPS. + */ + secure () { + return this.isSecure() + } + /** * Get all of the data except for a specified array of items. * * @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 @@ -289,7 +381,7 @@ export class Request extends HttpRequest implements IRequest { * @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)) { @@ -304,7 +396,7 @@ export class Request extends HttpRequest implements IRequest { * * @param input */ - public mergeIfMissing (input: Record) { + mergeIfMissing (input: Record) { return this.merge( Object.fromEntries(Object.entries(input).filter(([key]) => this.missing(key))) ) @@ -313,16 +405,62 @@ export class Request extends HttpRequest implements IRequest { /** * Get the keys for all of the input and files. */ - public keys (): string[] { + keys (): string[] { 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. + */ + session | undefined = undefined> (key?: K, defaultValue?: any): K extends undefined + ? ISessionManager + : K extends string + ? any : void | Promise { + this.sessionManager ??= this.app.make('session') + + if (typeof key === 'string') { + return this.sessionManager.get(key, defaultValue) + } else if (typeof key === 'object') { + for (const [k, val] of Object.entries(key)) { + this.sessionManager.put(k, val) + } + return undefined as any + } + + return this.sessionManager as any + } + + /** + * Get the host name. + */ + host () { + return this.getHost() + } + + /** + * Get the HTTP host being requested. + */ + httpHost () { + return this.getHttpHost() + } + + /** + * Get the scheme and HTTP host. + */ + schemeAndHttpHost () { + return this.getSchemeAndHttpHost() + } + /** * Determine if the request is sending JSON. * * @return bool */ - public isJson () { + isJson () { return Str.contains(this.getHeader('CONTENT_TYPE') ?? '', ['/json', '+json']) } @@ -331,7 +469,7 @@ export class Request extends HttpRequest implements IRequest { * * @returns */ - public expectsJson (): boolean { + expectsJson (): boolean { return Str.contains(this.getHeader('Accept') ?? '', 'application/json') } @@ -341,7 +479,7 @@ export class Request extends HttpRequest implements IRequest { * * @returns */ - public wantsJson (): boolean { + wantsJson (): boolean { const acceptable = this.getAcceptableContentTypes() return !!acceptable[0] && Str.contains(acceptable[0].toLowerCase(), ['/json', '+json']) @@ -352,7 +490,7 @@ export class Request extends HttpRequest implements IRequest { * * @return bool */ - public pjax () { + pjax () { return this.headers.get('X-PJAX') == true } @@ -362,42 +500,87 @@ export class Request extends HttpRequest implements IRequest { * @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) } + /** + * Get the flashed input from previous request + * + * @param key + * @param defaultValue + * @returns + */ + 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 + return payload + // new MessageBag(instance.errors().all()) + } + /** * Get a URI instance for the request. */ - public uri (): Url { - return this.getUriInstance() + 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 + */ + root (): string { + return Str.rtrim(this.getSchemeAndHttpHost() + this.getBaseUrl(), '/') + } + + /** + * Get the URL (no query string) for the request. + * + * @return string + */ + 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. + */ + path (): string { + const pattern = (this.getPathInfo() ?? '').replace(/^\/+|\/+$/g, '') + return pattern === '' ? '/' : pattern + } + /** * Return the Request instance. */ - public instance (): this { + instance (): this { return this } /** * Get the request method. */ - public method (): RequestMethod { + method (): RequestMethod { return this.getMethod() } @@ -408,7 +591,7 @@ export class Request extends HttpRequest implements IRequest { * @param defaultValue * @return {InputBag} */ - public json ( + json ( key?: string, defaultValue?: any ): K extends undefined ? InputBag : any { @@ -428,6 +611,140 @@ export class Request extends HttpRequest implements IRequest { return Obj.get(this.#json.all(), key, defaultValue) } + /** + * Get the user resolver callback. + */ + getUserResolver (): ((gaurd?: string) => U | undefined) { + return this.userResolver ?? (() => undefined) + } + + /** + * Set the user resolver callback. + * + * @param callback + */ + setUserResolver (callback: (gaurd?: string) => U) { + this.userResolver = callback + + return this + } + + /** + * Get the route resolver callback. + */ + getRouteResolver (): () => IRoute | undefined { + return this.routeResolver ?? (() => undefined) + } + + /** + * Set the route resolver callback. + * + * @param callback + */ + 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. * @@ -438,7 +755,30 @@ export class Request extends HttpRequest implements IRequest { 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) } /** @@ -446,7 +786,7 @@ export class Request extends HttpRequest implements IRequest { * * @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/Response.ts b/packages/http/src/Response.ts index 950d47f4..725f4bf9 100644 --- a/packages/http/src/Response.ts +++ b/packages/http/src/Response.ts @@ -1,33 +1,61 @@ -import type { DotNestedKeys, DotNestedValue } from '@h3ravel/shared' -import { type H3Event, HTTPResponse } from 'h3' -import { redirect, } 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 '@h3ravel/foundation' export class Response extends HttpResponse implements IResponse { - constructor( - /** - * The current H3 H3Event instance - */ - event: H3Event, - /** - * The current app instance - */ - public app: Application - ) { + static codes = ResponseCodes + + private initializationData = {} as { + app: IApplication + content?: string + event: string | H3Event + status: ResponseCodes + headers: Record + } + + /** + * The current Http Context + */ + 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.getHttpContext('event') + super(event) - globalThis.response = () => this + + if (content || status !== 200 || hasHeaders) { + this.setContent(content) + .setStatusCode(status) + + if (hasHeaders) + this.withHeaders(headers) + } + + this.initializationData = { app, event, status, headers, content } } /** * 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) { - return this.text(this.content, parse!) + type = Str.detectContentType(this.content) } return this[type].call(this, this.content, parse!) @@ -40,6 +68,34 @@ 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 { + const base = this.html(await this.app.make('edge').render(viewPath, data), parse!) + return new Responsable(base.body!, base) + } + + /** + * + * 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!) + } + /** * * @param content The content to serve @@ -47,31 +103,35 @@ 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!) + if (base instanceof Response) { + return new Responsable(base.content, { status: base.statusCode, statusText: base.statusText, headers: base.headers }) + } + return new Responsable(base.body!, base) } /** * Send a JSON response. */ json (data?: T): this - json (data: T, parse: boolean): T - json (data?: T, parse?: boolean): HTTPResponse | this { + 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 + ) } /** * 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 } @@ -79,7 +139,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 } @@ -91,11 +151,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, @@ -117,9 +177,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 + }) } /** @@ -145,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/HeaderBag.ts b/packages/http/src/Utilities/HeaderBag.ts index a4bbff03..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,9 +103,9 @@ export class HeaderBag implements Iterable<[string, (string | null)[]]> { */ public get ( key: string, - defaultValue: string | null = null - ): R extends undefined ? string | null : R { - const headers = this.all(key) as (string | null)[] + 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 768f7dc9..aa839669 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 { 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,33 +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 { 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 @@ -46,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 @@ -70,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. @@ -100,18 +114,23 @@ 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 */ public cookies!: InputBag + /** + * The current Http Context + */ + context!: IHttpContext + /** * The request attributes (parameters parsed from the PATH_INFO, ...). */ @@ -131,6 +150,9 @@ export class HttpRequest { protected static httpMethodParameterOverride: boolean = false + protected sessionManager!: ISessionManager + protected sessionManagerClass!: typeof ISessionManager + /** * List of Acceptable Content Types */ @@ -146,7 +168,7 @@ export class HttpRequest { /** * The current app instance */ - public app: Application + public app: IApplication ) { } /** @@ -160,26 +182,30 @@ export class HttpRequest { * @param server The SERVER parameters * @param content The raw body data */ - public async initialize (): Promise { + public initialize (): void { + this.buildRequirements() + } + + 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 // 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.#uri = Url.of(getRequestURL(this.event).toString(), this.app) } /** @@ -207,10 +233,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. * @@ -220,18 +545,36 @@ 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()) } - const https = this.server.get('HTTPS') + const https = this._server.get('HTTPS') return !!https && 'off' !== https.toLowerCase() } + /** + * 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. */ @@ -281,6 +624,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. */ @@ -326,7 +698,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') { @@ -536,8 +908,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)) { @@ -554,12 +926,12 @@ 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) } /** * 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[] { @@ -569,7 +941,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] @@ -582,18 +954,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] @@ -605,7 +977,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' } @@ -642,11 +1014,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 [] @@ -677,7 +1055,7 @@ export class HttpRequest { } // Validate IP format - if (isIP(clientIp) > 0) { + if (Str.validateIp(clientIp)) { clientIps.splice(i, 1) i-- continue @@ -695,6 +1073,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..15b585e8 100644 --- a/packages/http/src/Utilities/HttpResponse.ts +++ b/packages/http/src/Utilities/HttpResponse.ts @@ -1,16 +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 { 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 { Request } from '..' import { ResponseHeaderBag } from '../Utilities/ResponseHeaderBag' -import type { ResponseObject } from '@h3ravel/shared' -export class HttpResponse { +export class HttpResponse extends IHttpResponse { protected statusCode: number = 200 protected headers: ResponseHeaderBag protected content!: any @@ -50,6 +48,7 @@ export class HttpResponse { */ protected readonly event: H3Event, ) { + super() this.headers = new ResponseHeaderBag(this.event) this.setContent() this.setProtocolVersion('1.0') @@ -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() @@ -743,7 +742,7 @@ export class HttpResponse { } // 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') } @@ -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/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/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 deleted file mode 100644 index d942b25b..00000000 --- a/packages/http/src/app.globals.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Request, Response } from '.' - -export { } - -declare global { - /** - * @returns a global instance of the Request class. - */ - function request (): Request - /** - * @returns a global instance of the Response class. - */ - function response (): Response -} 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/http/src/index.ts b/packages/http/src/index.ts index a67dcee7..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,8 +6,11 @@ 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' +export * from './Middleware/TrustHosts' export * from './Providers/HttpServiceProvider' export * from './Request' export * from './Resources/ApiResource' @@ -24,6 +26,6 @@ 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 4d3d7e9e..a3fb5046 100644 --- a/packages/http/tests/Request.spec.ts +++ b/packages/http/tests/Request.spec.ts @@ -1,10 +1,12 @@ +import { Application, h3ravel } from '@h3ravel/core' // if this exists import { 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 +52,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 +67,7 @@ function makeEvent (overrides: Partial = {}) { const event: any = { req, + res: { headers: new Headers() }, context: { params: overrides.params || {}, } @@ -82,24 +79,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 }) @@ -193,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' })) }) @@ -206,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 @@ -375,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') @@ -395,5 +398,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(app, event), + // }, 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/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/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/mail/package.json b/packages/mail/package.json index 39ae5c3c..c091e129 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": [ diff --git a/packages/queue/package.json b/packages/queue/package.json index 0eccc823..764f9ec1 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": [ @@ -56,7 +56,8 @@ "version-patch": "pnpm version patch" }, "peerDependencies": { - "@h3ravel/core": "workspace:^" + "@h3ravel/core": "workspace:^", + "@h3ravel/contracts": "workspace:^" }, "devDependencies": { "typescript": "^5.4.0" 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 e5ee8fba..2eab7d37 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,11 +1,10 @@ { "name": "@h3ravel/router", - "version": "1.13.6", + "version": "1.15.0", "description": "Route facade, decorators and controller system for H3ravel.", "h3ravel": { "providers": [ - "RouteServiceProvider", - "AssetsServiceProvider" + "RoutingServiceProvider" ] }, "type": "module", @@ -56,14 +55,18 @@ "test": "jest --passWithNoTests", "version-patch": "pnpm version patch" }, + "peerDependencies": { + "@h3ravel/database": "workspace:^" + }, "dependencies": { - "@h3ravel/core": "workspace:^", + "h3": "catalog:prod", + "@h3ravel/contracts": "workspace:^", + "@h3ravel/events": "workspace:^", "@h3ravel/musket": "catalog:prod", - "@h3ravel/database": "workspace:^", "@h3ravel/http": "workspace:^", "@h3ravel/shared": "workspace:^", "@h3ravel/support": "workspace:^", - "h3": "catalog:prod", + "@h3ravel/foundation": "workspace:^", "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..5cee6f84 --- /dev/null +++ b/packages/router/src/AbstractRouteCollection.ts @@ -0,0 +1,112 @@ +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' + +/* + * 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 (): 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, + req: Request, + includingMethod = true + ): Route | undefined { + + const [fallbacks, routeList] = (new Collection(routes)).partition(function (route) { + return route.isFallback + }) + + return new Collection({ ...routeList.all(), ...fallbacks.all() }).first( + (route) => route.matches(req, includingMethod) + ) + } + + /** + * 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) { + return route.bind(req) + } + + throw new NotFoundHttpException(`The route "${req.path()}" was not 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() + } + + /** + * Count the number of items in the collection. + */ + count (): number { + return this.getRoutes().length + } +} diff --git a/packages/router/src/CallableDispatcher.ts b/packages/router/src/CallableDispatcher.ts new file mode 100644 index 00000000..8b1934b7 --- /dev/null +++ b/packages/router/src/CallableDispatcher.ts @@ -0,0 +1,39 @@ +import { CallableConstructor, IApplication, ICallableDispatcher } from '@h3ravel/contracts' + +import { Route } from './Route' +import { RouteDependencyResolver } from './Traits/RouteDependencyResolver' +import { mix } from '@h3ravel/shared' + +export class CallableDispatcher extends mix(ICallableDispatcher, RouteDependencyResolver) { + + /** + * + * @param container The container instance. + */ + public constructor(protected container: IApplication) { + super(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.resolveMethodDependencies( + route.parametersWithoutNulls() + ) + } +} diff --git a/packages/router/src/Commands/RouteListCommand.ts b/packages/router/src/Commands/RouteListCommand.ts index 2b475ac0..34d60ee1 100644 --- a/packages/router/src/Commands/RouteListCommand.ts +++ b/packages/router/src/Commands/RouteListCommand.ts @@ -1,8 +1,9 @@ -import { Logger, LoggerChalk, RouteDefinition, RouteMethod } from '@h3ravel/shared' +import { IApplication, IRoute, RouteMethod } from '@h3ravel/contracts' +import { Logger, LoggerChalk } from '@h3ravel/shared' import { Command } from '@h3ravel/musket' -export class RouteListCommand extends Command { +export class RouteListCommand extends Command { /** * The name and signature of the console command. @@ -13,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} } ` @@ -26,46 +32,132 @@ export class RouteListCommand extends Command { /** * Execute the console command. */ - public async handle (this: any) { - console.log('') - const command = (this.dictionary.baseCommand ?? this.dictionary.name) + public async handle () { - await this[command]() + this.newLine() + + 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 */ - const list = [...(this.app.make('app.routes') as RouteDefinition[])].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)) + }) + + 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)) + } + 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 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 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 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 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, ' ') + } + + 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 + } } /** @@ -96,9 +188,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..0f9b97dc --- /dev/null +++ b/packages/router/src/CompiledRoute.ts @@ -0,0 +1,149 @@ +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, optionalParams: Record, hostPattern?: string) { + this.path = path + 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) + + // 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 list of all path variables + */ + public getVariables (): string[] { + return this.variables + } + + /** + * Returns list of all compiled tokens + */ + public getTokens () { + return this.tokens + } + + /** + * Returns optional params record + */ + public getOptionalParams (): Record { + return { ...this.optionalParams } + } + + /** + * Build the route params + * + * @returns + */ + 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/Utilities.ts b/packages/router/src/Contracts/Utilities.ts new file mode 100644 index 00000000..cf211e57 --- /dev/null +++ b/packages/router/src/Contracts/Utilities.ts @@ -0,0 +1,12 @@ +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]; + +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 new file mode 100644 index 00000000..df0ae6ea --- /dev/null +++ b/packages/router/src/ControllerDispatcher.ts @@ -0,0 +1,68 @@ +import { IApplication, IController, IControllerDispatcher, IMiddleware, ResourceMethod, RouteMethod } from '@h3ravel/contracts' + +import { Collection } from '@h3ravel/support' +import { FiltersControllerMiddleware } from './Traits/FiltersControllerMiddleware' +import { Route } from './Route' +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: IApplication) { + super(container) + } + + /** + * Dispatch a request to a given controller and method. + * + * @param route + * @param controller + * @param method + */ + public async dispatch (route: Route, controller: Required, method: ResourceMethod) { + 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: ResourceMethod) { + return this.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?.() ?? {} as IMiddleware)) + .reject((data) => ControllerDispatcher.methodExcludedByOptions(method, data.options)) + .pluck('middleware') + .all() as never + } +} 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/ImplicitRouteBinding.ts b/packages/router/src/ImplicitRouteBinding.ts new file mode 100644 index 00000000..a5cb8b5c --- /dev/null +++ b/packages/router/src/ImplicitRouteBinding.ts @@ -0,0 +1,96 @@ +import { GenericObject, IApplication, IModel, UrlRoutable } from '@h3ravel/contracts' + +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: IApplication, 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/Matchers/HostValidator.ts b/packages/router/src/Matchers/HostValidator.ts new file mode 100644 index 00000000..dc73e910 --- /dev/null +++ b/packages/router/src/Matchers/HostValidator.ts @@ -0,0 +1,20 @@ +import { IRouteValidator } from '../Contracts/IRouteValidator' +import { Request } from '@h3ravel/http' +import { Route } from '../Route' + +export class HostValidator extends IRouteValidator { + /** + * 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..4207435d --- /dev/null +++ b/packages/router/src/Matchers/MethodValidator.ts @@ -0,0 +1,15 @@ +import { IRouteValidator } from '../Contracts/IRouteValidator' +import { Request } from '@h3ravel/http' +import { Route } from '../Route' + +export class MethodValidator extends IRouteValidator { + /** + * 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() 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..9eaa616c --- /dev/null +++ b/packages/router/src/Matchers/SchemeValidator.ts @@ -0,0 +1,21 @@ +import { IRouteValidator } from '../Contracts/IRouteValidator' +import { Request } from '@h3ravel/http' +import { Route } from '../Route' + +export class SchemeValidator extends IRouteValidator { + /** + * 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..5f807cf1 --- /dev/null +++ b/packages/router/src/Matchers/UriValidator.ts @@ -0,0 +1,17 @@ +import { IRouteValidator } from '../Contracts/IRouteValidator' +import { Request } from '@h3ravel/http' +import { Route } from '../Route' +import { Str } from '@h3ravel/support' + +export class UriValidator extends IRouteValidator { + /** + * Validate a given rule against a route and request. + * + * @param route + * @param request + */ + public matches (route: Route, request: Request) { + 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/Middleware/SubstituteBindings.ts b/packages/router/src/Middleware/SubstituteBindings.ts new file mode 100644 index 00000000..de453d7f --- /dev/null +++ b/packages/router/src/Middleware/SubstituteBindings.ts @@ -0,0 +1,42 @@ +import { IApplication, IRouter } from '@h3ravel/contracts' +import { Injectable, ModelNotFoundException } from '@h3ravel/foundation' +import { Middleware, Request } from '@h3ravel/http' + +@Injectable() +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 + */ + @Injectable() + async handle (request: Request, next: (request: Request) => Promise) { + + const route = request.route() + + try { + await this.router.substituteBindings(route) + await this.router.substituteImplicitBindings(route) + } catch (e) { + 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..c2f2d412 --- /dev/null +++ b/packages/router/src/MiddlewareResolver.ts @@ -0,0 +1,92 @@ +import { IApplication, MiddlewareIdentifier, MiddlewareList } from '@h3ravel/contracts' + +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)) + const bound = this.app.boundMiddlewares(resolved) + if (bound) results.push(bound) + } else { + const bound = this.app.boundMiddlewares(middleware) + if (bound) results.push(bound) + } + } + + return results.filter(e => typeof e !== 'string') + } +} diff --git a/packages/router/src/PendingResourceRegistration.ts b/packages/router/src/PendingResourceRegistration.ts new file mode 100644 index 00000000..afe2cafb --- /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 './Traits/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..f7d6fd19 --- /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 './Traits/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 new file mode 100644 index 00000000..7106fc93 --- /dev/null +++ b/packages/router/src/Pipeline.ts @@ -0,0 +1,237 @@ +import { CallableConstructor, IContainer, IRequest } from '@h3ravel/contracts' +import { RuntimeException, isCallable } from '@h3ravel/support' + +import { Logger } from '@h3ravel/shared' +import { Pipe } from './Contracts/Utilities' + +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?: IContainer + + /** + * 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?: IContainer) { + 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) + } 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' && 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) + if (bound) { + instance = this.getContainer().make(bound as never) + parameters = [passable, stack, ...extras] + } else { + instance = async function (request: IRequest, next) { + Logger.error(`Error: Middleware [${name}] requested by [${request.getRequestUri()}] not bound: Skipping...`, false) + return next + } + } + + // 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 = Reflect.apply(handler, 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: IContainer) { + 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 deleted file mode 100644 index f7c9eee0..00000000 --- a/packages/router/src/Providers/RouteServiceProvider.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Logger } from '@h3ravel/shared' -import { RouteListCommand } from '../Commands/RouteListCommand' -import { Router } from '../Route' -import { ServiceProvider } from '@h3ravel/core' -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.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')) - 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.registerCommands([RouteListCommand]) - } - - /** - * Load routes from src/routes - */ - async boot () { - 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') - }) - - for (let i = 0; i < files.length; i++) { - const routesModule = await import(path.join(routePath, files[i])) - - if (typeof routesModule.default === 'function') { - const router = this.app.make('router') - routesModule.default(router) - } - } - } catch (e: any) { - Logger.log([['No auto discorvered routes.', 'white'], [e.message, ['grey', 'italic']]], '\n') - } - } -} 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/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 6d9a538a..596bce87 100644 --- a/packages/router/src/Route.ts +++ b/packages/router/src/Route.ts @@ -1,462 +1,953 @@ -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, IRouter, RouterEnd } from '@h3ravel/shared' -import { Helpers } from './Helpers' -import { Model } from '@h3ravel/database' -import { RouteDefinition, RouteMethod } from '@h3ravel/shared' - -export class Router implements IRouter { - private routes: RouteDefinition[] = [] - private nameMap: string[] = [] - private groupPrefix = '' - private middlewareMap: IMiddleware[] = [] - private groupMiddleware: EventHandler[] = [] - - constructor(protected h3App: H3, private app: Application) { } - - /** - * Route Resolver +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 { 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 { 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' +import { UriValidator } from './Matchers/UriValidator' + +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!: IApplication + + /** + * The fields that implicit binding should use for a given parameter. + */ + 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. + */ + isFallback: boolean = false + + /** + * 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?: MiddlewareList + + /** + * The controller instance. + */ + controller?: Required + + /** + * The validators used by the routes. + */ + static validators: IRouteValidator[] + + /** * - * @param handler - * @param middleware - * @returns - */ - 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 - 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(event, this.app), - }); - - (event as any)._h3ravelContext = ctx - return ctx - } + * @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']) - // 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)) - } - })) + if (this.methods.includes('GET') && !this.methods.includes('HEAD')) { + this.methods.push('HEAD') } + + this.prefix(Obj.isPlainObject(action) ? Obj.get(action as any, 'prefix') : '') } /** - * Add a route to the stack - * - * @param method - * @param path - * @param handler - * @param name - * @param middleware - */ - private addRoute ( - method: RouteMethod, - path: string, - handler: EventHandler, - name?: string, - middleware: IMiddleware[] = [], - signature: RouteDefinition['signature'] = ['', ''] - ) { - /** - * Join all defined route names to make a single route name - */ - if (this.nameMap.length > 0) { - name = this.nameMap.join('.') + * 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: IApplication) { + 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 } + } - /** - * Join all defined middlewares - */ - if (this.middlewareMap.length > 0) { - middleware = this.middlewareMap + /** + * 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) + } + + /** + * 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. + */ + httpOnly () { + 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. + * + * @param middleware + */ + 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) + + // 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 + + 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) } - 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.app.singleton('app.routes', () => this.routes) - } - - /** - * 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) => { - 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}`) - } - - /** - * Get param types for the controller method - */ - const paramTypes: [] = 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 controller[action](...args) + if (this.action.can) { + for (const can of this.action.can) { + this.can(can[0], can[1] ?? []) } } - return handler as EventHandler + return this } /** - * Registers a route that responds to HTTP GET requests. + * 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 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 request */ - get any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractControllerMethods>], - name?: string, - middleware: IMiddleware[] = [] - ): Omit { + bind (request: Request) { + this.compileRoute() - const handler = Array.isArray(definition) ? definition[0] : definition - const methodName = Array.isArray(definition) ? definition[1] : undefined + this.parameters = (new RouteParameterBinder(this)).parameters(request) - // Add the route to the route stack - this.addRoute( - 'get', - path, - this.resolveControllerOrHandler(handler, methodName, path), - name, - middleware, - [handler.name, methodName] - ) + this.#originalParameters = this.parameters return this } /** - * Registers a route that responds to HTTP POST requests. + * Get or set the domain for the route. + * + * @param domain * - * @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. + * @throws {InvalidArgumentException} */ - post any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractControllerMethods>], - name?: string, - middleware: IMiddleware[] = [] - ): Omit { + domain (domain?: D): D extends undefined ? string : this { + if (!domain) return this.getDomain() as never - const handler = Array.isArray(definition) ? definition[0] : definition - const methodName = Array.isArray(definition) ? definition[1] : undefined + const parsed = RouteUri.parse(domain) - // Add the route to the route stack - this.addRoute( - 'post', - path, - this.resolveControllerOrHandler(handler, methodName, path), - name, - middleware, - [handler.name, methodName] - ) + this.action.domain = parsed.uri ?? '' - return this + this.bindingFields = Object.assign({}, this.bindingFields, parsed.bindingFields) + + return this as never } /** - * Registers a route that responds to HTTP PUT requests. + * Parse the route action into a standard array. + * + * @param action * - * @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. + * @throws {UnexpectedValueException} + */ + protected parseAction (action: ActionInput) { + return RouteAction.parse(this.#uri, action) + } + + /** + * Run the route action and return the response. */ - put any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractControllerMethods>], - name?: string, - middleware: IMiddleware[] = [] - ): Omit { + async run (): Promise { + if (!this.container) { + const { Container } = await import('@h3ravel/core') - const handler = Array.isArray(definition) ? definition[0] : definition - const methodName = Array.isArray(definition) ? definition[1] : undefined + this.container = new Container() as never + } + + try { + if (this.isControllerAction()) { + return await this.runController() + } + + return this.runCallable() + } 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) + } + } + + /** + * 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 () { + if (typeof this.parameters !== 'undefined') { + return this.parameters + } + + throw new LogicException('Route is not bound.') + } + + /** + * 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 + } + + /** + * 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 - // Add the route to the route stack - this.addRoute( - 'put', - path, - this.resolveControllerOrHandler(handler, methodName, path), - name, - middleware, - [handler.name, methodName] - ) return this } /** - * Registers a route that responds to HTTP PATCH requests. + * 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. * - * @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 key + * @param value */ - patch any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractControllerMethods>], - name?: string, - middleware: IMiddleware[] = [] - ): Omit { + defaults (key: string, value: any) { + this._defaults[key] = value - const handler = Array.isArray(definition) ? definition[0] : definition - const methodName = Array.isArray(definition) ? definition[1] : undefined + return this + } - // Add the route to the route stack - this.addRoute( - 'patch', - path, - this.resolveControllerOrHandler(handler, methodName, path), - name, - middleware, - [handler.name, methodName] - ) + /** + * Set the default values for the route. + * + * @param defaults + */ + setDefaults (defaults: Record) { + this._defaults = defaults return this } /** - * Registers a route that responds to HTTP DELETE requests. + * Get the optional parameter names for the route. + */ + getOptionalParameterNames (): Record { + const pattern = /\{([\w]+)(?:[:][\w]+)?(\?)?\}/g + const matches = [...this.uri().matchAll(pattern)] + + const result: Record = {} + + for (const match of matches) { + const paramName = match[1] + const isOptional = !!match[2] // true if '?' exists + if (isOptional) { + result[paramName] = 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]+)(?:[:][\w]+)?\??\}/g + const fullUri = (this.getDomain() ?? '') + this.uri() + const matches = [...fullUri.matchAll(pattern)] + + return matches.map(m => m[1]) + } + + /** + * Get the parameters that are listed in the route / controller signature. * - * @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 conditions */ - delete any> ( - path: string, - definition: RouteEventHandler | [C, methodName: ExtractControllerMethods>], - name?: string, - middleware: IMiddleware[] = [] - ): Omit { + signatureParameters (conditions: ClassConstructor | RouteActionConditions) { + if (isClass(conditions)) { + conditions = { subClass: conditions } + } - const handler = Array.isArray(definition) ? definition[0] : definition - const methodName = Array.isArray(definition) ? definition[1] : undefined + return RouteSignatureParameters + .setRequirements(this.container, this) + .fromAction(this.action, conditions as RouteActionConditions) + } - // Add the route to the route stack - this.addRoute( - 'delete', - path, - this.resolveControllerOrHandler(handler, methodName, path), - name, - middleware, - [handler.name, methodName] - ) + /** + * Compile the route once, cache the result, return compiled data + */ + compileRoute (): CompiledRoute { + if (!this.compiled) { + const optionalParams = this.getOptionalParameterNames() + + this.compiled = new CompiledRoute(this.uri(), optionalParams) + } + + 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. + */ + 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 } /** - * API Resource support - * - * @param path - * @param controller - */ - 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) + * 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 } /** - * Named route URL generator - * - * @param name - * @param params - * @returns + * Get the middleware that should be removed from the route. */ - route (name: string, params: Record = {}): string | undefined { - const found = this.routes.find(r => r.name === name) - if (!found) return undefined + excludedMiddleware (): MiddlewareList { + return this.action.excluded_middleware ?? {} + } - let url = found.path - for (const [key, value] of Object.entries(params)) { - url = url.replace(`:${key}`, value) + /** + * Get all middleware, including the ones from the controller. + */ + gatherMiddleware (): MiddlewareList { + if (this.computedMiddleware) { + return this.computedMiddleware } - return url + + this.computedMiddleware = Router.uniqueMiddleware([...this.middleware(), ...this.controllerMiddleware()]) + + return this.computedMiddleware } /** - * Grouping - * - * @param options - * @param callback + * Indicate that the route should enforce scoping of multiple implicit Eloquent bindings. */ - group (options: { prefix?: string; middleware?: EventHandler[] }, callback: (_e: this) => void) { - const prevPrefix = this.groupPrefix - const prevMiddleware = [...this.groupMiddleware] + scopeBindings () { + this.action['scope_bindings'] = true - this.groupPrefix += options.prefix || '' - this.groupMiddleware.push(...(options.middleware || [])) + return this + } - callback(this) + /** + * Indicate that the route should not enforce scoping of multiple implicit Eloquent bindings. + */ + withoutScopedBindings (): this { + this.action['scope_bindings'] = false - /** - * Restore state after group - */ - this.groupPrefix = prevPrefix - this.groupMiddleware = prevMiddleware return this } /** - * Set the name of the current route + * 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 + } + + /** + * 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) + } + + /** + * 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. + * + * @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 this.container.make(ICallableDispatcher).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 + } + + /** + * Determine if the route matches a given request. * - * @param name + * @param request + * @param includingMethod */ - name (name: string) { - this.nameMap.push(name) - return this + 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 } /** - * Registers middleware for a specific path. - * @param path - The path to apply the middleware. - * @param handler - The middleware handler. - * @param opts - Optional middleware options. + * Get the controller class used for the route. */ - middleware (path: string | IMiddleware[] | Middleware, handler: Middleware | MiddlewareOptions, opts?: MiddlewareOptions) { - opts = typeof handler === 'object' ? handler : (typeof opts === 'function' ? opts : {}) - handler = typeof path === 'function' ? path : (typeof handler === 'function' ? handler : () => { }) + getControllerClass () { + return this.isControllerAction() ? this.action.uses : undefined + } + + /** + * Get the controller method used for the route. + */ + getControllerMethod (): ResourceMethod { + const holder = isClass(this.action.uses) && typeof this.action.controller === 'string' ? this.action.controller : 'index' + return Str.parseCallback(holder).at(1) as ResourceMethod + } + + /** + * Get the middleware for the route's controller. + * + * @return array + */ + controllerMiddleware () { + let controllerClass: string | undefined, ResourceMethod: string | undefined - if (Array.isArray(path)) { - this.middlewareMap.concat(path) - } else if (typeof path === 'function') { - this.h3App.use('/', () => { }).use(path) + if (!this.isControllerAction()) { + return [] + } + + if (typeof this.action.uses === 'string') { + [controllerClass, ResourceMethod] = [ + this.getControllerClass(), + this.getControllerMethod(), + ] + void controllerClass + void ResourceMethod } else { - this.h3App.use(path, handler, opts) + // } - return this + // console.log(controllerClass, ResourceMethod, this.action, 'controllerMiddleware') + // TODO: Let's finish the below + // if (is_a(controllerClass, HasMiddleware.lass, true)) { + // return this.staticallyProvidedControllerMiddleware( + // controllerClass, + // ResourceMethod + // ) + // } + + // if (method_exists(Object.prototype.hasOwnProperty.call(controllerClass, 'getMiddleware')) { + // return this.controllerDispatcher().getMiddleware( + // this.getController(), + // ResourceMethod + // ) + // } + + 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..4ea38826 --- /dev/null +++ b/packages/router/src/RouteAction.ts @@ -0,0 +1,150 @@ +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 { + /** + * 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 (isCallable(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 (isCallable(uses)) { + return { ...this.action, uses } + } + + /** + * uses: Controller + */ + if (this.isClass(uses)) { + return { + uses: this.action, + controller: this.action.name + '@index', + ...this.action, + } + } + + /** + * 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..ebed5833 --- /dev/null +++ b/packages/router/src/RouteCollection.ts @@ -0,0 +1,200 @@ +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 ?? undefined + + 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 ?? undefined + 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 routes = this.get(request.getMethod()) + + const route = this.matchAgainstRoutes(routes, 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 === undefined) { + 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) !== undefined + } + + /** + * Get a route instance by its name. + */ + public getByName (name: string): Route | undefined { + return this.nameList[name] ?? undefined + } + + /** + * Get a route instance by its controller action. + */ + public getByAction (action: string): Route | undefined { + return this.actionList[action] ?? undefined + } + + /** + * 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..c65c8485 --- /dev/null +++ b/packages/router/src/RouteGroup.ts @@ -0,0 +1,93 @@ +import { Arr, Obj, Str } from '@h3ravel/support' + +import { RouteActions } from '@h3ravel/contracts' + +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/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/RouteParameterBinder.ts b/packages/router/src/RouteParameterBinder.ts new file mode 100644 index 00000000..d566d26d --- /dev/null +++ b/packages/router/src/RouteParameterBinder.ts @@ -0,0 +1,113 @@ +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/RouteRegisterer.ts b/packages/router/src/RouteRegisterer.ts new file mode 100644 index 00000000..f6f324fa --- /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 './Traits/CreatesRegularExpressionRouteConstraints' +import { FRoute } from '@h3ravel/support/facades' +import { Injectable } from '@h3ravel/foundation' +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/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/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/RouteUrlGenerator.ts b/packages/router/src/RouteUrlGenerator.ts new file mode 100644 index 00000000..74430b18 --- /dev/null +++ b/packages/router/src/RouteUrlGenerator.ts @@ -0,0 +1,409 @@ +import { Collection, Obj, Str } from '@h3ravel/support' +import { IRequest, IRoute, IRouteUrlGenerator, RouteParams } from '@h3ravel/contracts' + +import { UrlGenerationException } from '@h3ravel/foundation' +import type { UrlGenerator } from './UrlGenerator' + +export class RouteUrlGenerator extends IRouteUrlGenerator { + /** + * The URL generator instance. + */ + protected url: UrlGenerator + + /** + * The request instance. + */ + protected request: IRequest + + /** + * The named parameter defaults. + */ + public defaultParameters: RouteParams = {} + + /** + * 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) { + super() + this.url = url + this.request = request + } + + /** + * Generate a URL for the given route. + * + * @param route + * @param parameters + * @param absolute + */ + 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(/\{([\w]+)(?:[:][\w]+)?\??\}/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: IRoute, parameters: RouteParams) { + 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: IRoute, parameters: RouteParams) { + void parameters + return this.addPortToDomain( + this.getRouteScheme(route) + route.getDomain() + ) + } + + /** + * Get the scheme for the given route. + * + * @param route + */ + protected getRouteScheme (route: IRoute) { + 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: IRoute, parameters: RouteParams) { + parameters = Obj.wrap(parameters) + this.defaultParameters = Obj.wrap(this.defaultParameters) + + 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 (!Str.isInteger(key)) { + namedQueryParameters[key] = value + delete parameters[key] + } + } + + if (Object.keys(parameters).length === requiredRouteParametersWithoutDefaultsOrNamedParameters.length) { + for (const name of [...requiredRouteParametersWithoutDefaultsOrNamedParameters].reverse()) { + if (Obj.isEmpty(parameters)) break + namedParameters[name] = Obj.pop(parameters) + } + } + + let offset = 0 + const emptyParameters = Object.fromEntries( + Object.entries(namedParameters).filter(([_, val]) => val === '') + ) + + 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 - Object.keys(parameters).length + if (remaining < 0) offset += remaining + if (offset < 0) offset = 0 + } 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]] === '') { + 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 + 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 === '' && Obj.isAssoc(this.defaultParameters) && this.defaultParameters[defaultParameterKey] !== undefined) { + namedParameters[key] = this.defaultParameters[defaultParameterKey] + } + } + + parameters = { ...namedParameters, ...namedQueryParameters, ...parameters } + + 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) + } + + + /** + * Replace the parameters on the root path. + * + * @param oute + * @param domain + * @param parameters + */ + protected replaceRootParameters (route: IRoute, domain: string | undefined, parameters: RouteParams) { + 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: RouteParams) { + path = this.replaceNamedParameters(path, parameters) + + path = path.replace(/\{.*?\}/g, (match): any => { + // Reset numeric keys + parameters = { ...parameters as Record } + + 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: 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 + } + + 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}}` + }) + } + + + /** + * Add a query string to the URI. + * + * @param uri + * @param parameters + */ + 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. + + 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: 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 (Obj.isEmpty(parameters)) { + 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 < Object.keys(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: RouteParams) { + return Object.fromEntries( + Object.entries(parameters).filter(([key]) => typeof key === 'string') + ) + } + + + /** + * Get the numeric parameters from a given list. + * + * @param parameters + */ + protected getNumericParameters (parameters: RouteParams) { + 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: 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 new file mode 100644 index 00000000..8439417a --- /dev/null +++ b/packages/router/src/Router.ts @@ -0,0 +1,869 @@ +import 'reflect-metadata' +import { Middleware, MiddlewareOptions, type H3 } from 'h3' +import { Request, Response, JsonResponse } from '@h3ravel/http' +import { Arr, Collection, isClass, MacroableClass, Str, Stringable, tap } from '@h3ravel/support' +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' +import { RouteMethod, IResponsable } from '@h3ravel/contracts' +import { internal } from '@h3ravel/shared' +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' +import { PendingSingletonResourceRegistration } from './PendingSingletonResourceRegistration' +import { ResourceRegistrar } from './ResourceRegistrar' +import { PendingResourceRegistration } from './PendingResourceRegistration' +import { RouteRegistrar } from './RouteRegisterer' +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 + private routes: RouteCollection + private routeNames: string[] = [] + private current?: Route + private currentRequest!: IRequest + + private middlewareMap: IMiddleware[] = [] + private groupMiddleware: EventHandler[] = [] + + /** + * The registered route value binders. + */ + protected binders: Record = {} + + /** + * All of the short-hand keys for middlewares. + */ + private middlewares: GenericObject = {} + + /** + * All of the middleware groups. + */ + protected middlewareGroups: GenericObject = {} + + /** + * The route group attribute stack. + */ + protected groupStack: GenericObject[] = [] + + /** + * The event dispatcher instance. + */ + protected events?: IDispatcher + + /** + * The priority-sorted list of middleware. + * + * Forces the listed middleware to always be in the given order. + */ + public middlewarePriority: MiddlewareList = [] + + /** + * The registered custom implicit binding callback. + */ + 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: IApplication) { + 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 underlying route collection. + * + * @param method + * @param uri + * @param action + */ + addRoute ( + methods: RouteMethod | RouteMethod[], + uri: string, + action: ActionInput + ): Route { + const route = this.routes.add(this.createRoute(methods, uri, action)) + return route + } + + /** + * Get the currently dispatched route instance. + */ + getCurrentRoute (): Route | undefined { + return this.current + } + + /** + * Check if a route with the given name exists. + * + * @param name + */ + has (...name: string[]): boolean { + for (const value of name) { + if (!this.routes.hasNamedRoute(value)) { + return false + } + } + + return true + } + + /** + * Get the current route name. + */ + currentRouteName (): string | undefined { + return this.current?.getName() + } + + /** + * Alias for the "currentRouteNamed" method. + * + * @param patterns + */ + is (...patterns: string[]): boolean { + return this.currentRouteNamed(...patterns) + } + + /** + * Determine if the current route matches a pattern. + * + * @param patterns + */ + currentRouteNamed (...patterns: string[]): boolean { + return !!this.current?.named(...patterns) + } + + /** + * Get the underlying route collection. + */ + getRoutes (): RouteCollection { + return this.routes + } + + /** + * 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 + */ + newRoute (methods: RouteMethod | RouteMethod[], uri: string, action: ActionInput) { + return new Route(methods, uri, action) + .setRouter(this) + .setContainer(this.app) + .setUri(uri) + // .sync(this.h3App) + } + + /** + * Dispatch the request to the application. + * + * @param request + */ + async dispatch (request: Request) { + this.currentRequest = request + return await this.dispatchToRoute(request) + } + + /** + * Dispatch the request to a route and return the response. + * + * @param request + */ + 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.routes.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)) + 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. + */ + getMiddleware (): GenericObject { + return this.middlewares + } + + /** + * Register a short-hand name for a middleware. + * + * @param name + * @param class + */ + aliasMiddleware (name: string, cls: IMiddleware): this { + this.middlewares[name] = cls + + return this + } + + /** + * Gather the middleware for the given route with resolved class names. + * + * @param route + */ + gatherRouteMiddleware (route: Route): MiddlewareList { + return this.resolveMiddleware( + route.gatherMiddleware(), + route.excludedMiddleware() + ) + } + + /** + * Resolve a flat array of middleware classes from the provided array. + * + * @param middleware + * @param excluded + */ + resolveMiddleware (middleware: MiddlewareList, excluded: MiddlewareList = []): any { + excluded = excluded.length === 0 + ? 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)) + .map((name) => MiddlewareResolver.setApp(this.app).resolve(name, this.middlewares, 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 middlewares + */ + 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): Promise { + 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): IResponse { + 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)) { + response = new JsonResponse(request.app, response) + } + + if (response.getStatusCode() === Response.codes.HTTP_NOT_MODIFIED) { + response.setNotModified() + } + + 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): MiddlewareIdentifier[] { + return Array.from(new Set(middleware)) + } + + /** + * Registers a route that responds to HTTP GET requests. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns + */ + get (uri: string, action: ActionInput): Route { + return this.addRoute(['GET'], uri, action) + } + + /** + * 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 uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns + */ + post (uri: string, action: ActionInput): Route { + return this.addRoute(['POST'], uri, action) + } + + /** + * Registers a route that responds to HTTP PUT requests. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns + */ + put (uri: string, action: ActionInput): Route { + return this.addRoute(['PUT'], uri, action) + } + + /** + * Registers a route that responds to HTTP PATCH requests. + * + * @param uri - The route uri. + * @param action - The handler function or [controller class, method] array. + * @returns + */ + patch (uri: string, action: ActionInput): Route { + return this.addRoute(['PATCH'], uri, action) + } + + /** + * 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 name + * @param controller + * @param options + */ + 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, + controller, + options + ).$finalize() + } + + /** + * 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) + } + } + + /** + * Route an API resource to a controller. + * + * @param name + * @param controller + * @param options + */ + apiResource (name: string, controller: C, options: ResourceOptions = {}): PendingResourceRegistration { + let only: ResourceMethod[] = ['index', 'show', 'store', 'update', 'destroy'] + + if (typeof options.except !== 'undefined') { + only = only.filter(value => !options.except?.includes(value)) + } + + return this.resource(name, controller, Object.assign({}, { only }, options)) + } + + /** + * Register an array of singleton resource controllers. + * + * @param singletons + * @param options + */ + singletons (singletons: GenericObject, options: ResourceOptions = {}): void { + for (const [name, controller] of Object.entries(singletons)) { + this.singleton(name, controller, options) + } + } + + /** + * Route a singleton resource to a controller. + * + * @param name + * @param controller + * @param options + */ + singleton (name: string, controller: C, options: ResourceOptions = {}): PendingSingletonResourceRegistration { + let registrar: ResourceRegistrar + + if (this.app && this.app.bound(ResourceRegistrar)) { + registrar = this.app.make(ResourceRegistrar) + } else { + registrar = new ResourceRegistrar(this) + } + + return new PendingSingletonResourceRegistration( + registrar, + name, + controller, + options + ).$finalize() + } + + /** + * Register an array of API singleton resource controllers. + * + * @param singletons + * @param options + */ + apiSingletons (singletons: GenericObject, options: ResourceOptions = {}): void { + for (const [name, controller] of Object.entries(singletons)) { + this.apiSingleton(name, controller, options) + } + } + + /** + * 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'] + + if (typeof options.except !== 'undefined') { + only = only.filter(v => !options.except?.includes(v)) + } + + return this.singleton(name, controller, Object.assign({ only }, options)) + } + + /** + * Create a route group with shared attributes. + * + * @param attributes + * @param routes + */ + 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() + } + + 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 + */ + 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)) { + const require = createRequire(import.meta.url) + if (typeof routes === 'function') { + routes(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. + */ + 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. + */ + hasGroupStack () { + return this.groupStack.length > 0 + } + + /** + * Set the name of the current route + * + * @param name + */ + name (name: string) { + this.routeNames.push(name) + return this + } + + /** + * Prefix the given URI with the last prefix. + * + * @param uri + */ + @internal + protected prefix (uri: string) { + return Str.trim(Str.trim(this.getLastGroupPrefix(), '/') + '/' + Str.trim(uri, '/'), '/') || '/' + } + + /** + * 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. + */ + h3middleware ( + 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 : () => { }) + + if (Array.isArray(path)) { + this.middlewareMap.concat(path) + } else if (typeof path === 'function') { + this.h3App.use('/', () => { }).use(path) + } else { + this.h3App.use(path, handler, opts) + } + + 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/Traits/CreatesRegularExpressionRouteConstraints.ts b/packages/router/src/Traits/CreatesRegularExpressionRouteConstraints.ts new file mode 100644 index 00000000..f355963b --- /dev/null +++ b/packages/router/src/Traits/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/Traits/FiltersControllerMiddleware.ts b/packages/router/src/Traits/FiltersControllerMiddleware.ts new file mode 100644 index 00000000..47f91c61 --- /dev/null +++ b/packages/router/src/Traits/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/Traits/RouteDependencyResolver.ts b/packages/router/src/Traits/RouteDependencyResolver.ts new file mode 100644 index 00000000..687e421a --- /dev/null +++ b/packages/router/src/Traits/RouteDependencyResolver.ts @@ -0,0 +1,69 @@ +import 'reflect-metadata' + +import { IApplication, IController, ResourceMethod } from '@h3ravel/contracts' + +import { RuntimeException } from '@h3ravel/support' + +export class RouteDependencyResolver { + constructor(protected container: IApplication) { } + + /** + * Resolve the object method's type-hinted dependencies. + * + * @param parameters + * @param instance + * @param method + */ + public async resolveClassMethodDependencies (parameters: Record, instance: IController, method: ResourceMethod) { + /** + * Ensure the method exists on the controller + */ + if (typeof instance[method] !== 'function') { + throw new RuntimeException(`[${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 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 and Application instances are always available + */ + if (args.length < 1) { + args = [this.container.getHttpContext(), this.container] + } + + /** + * Call the controller method, passing all resolved dependencies + */ + return this.resolveMethodDependencies([...args, ...Object.values(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/UrlGenerator.ts b/packages/router/src/UrlGenerator.ts new file mode 100644 index 00000000..14ad1371 --- /dev/null +++ b/packages/router/src/UrlGenerator.ts @@ -0,0 +1,517 @@ +import { CallableConstructor, GenericObject, IRequest, IRoute, IRouteCollection, IUrlGenerator, RouteParams, UrlRoutable } from '@h3ravel/contracts' +import { Obj, optional, tap } from '@h3ravel/support' + +import { RouteNotFoundException } from '@h3ravel/foundation' +import { RouteUrlGenerator } from './RouteUrlGenerator' +import crypto from 'crypto' + +export class UrlGenerator extends IUrlGenerator { + private routes: IRouteCollection + private 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: IRouteCollection, request: IRequest, assetRoot?: string) { + super() + 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: (string | number)[] = [], 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) + } + + /** + * Create a signed route URL for a named route. + * + * @param name + * @param parameters + * @param expiration + * @param absolute + * @returns + */ + 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) + } + + /** + * 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 + + 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') + ) + ) + ) + } + + /** + * Get the URL to a named route. + * + * @param name + * @param parameters + * @param absolute + * @returns + */ + route (name: string, parameters: RouteParams = {}, 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: IRoute, parameters: RouteParams = {}, 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?: IRoute): 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 | RouteParams): GenericObject { + parameters = Obj.wrap(parameters as never) + + for (const [key, parameter] of Object.entries(parameters)) { + if (Obj.isAssoc(parameter) && 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 (): IRequest { + 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: IRouteCollection) { + this.routes = routes + + return this + } + + /** + * Get the route collection. + */ + getRoutes (): IRouteCollection { + return this.routes + } + + /** + * 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 () { + // TODO: Implement session features to get previous URL + // return this.getSession()?.previousUrl() + return '' + } +} diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index ad0caf23..e3494c9d 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -1,6 +1,40 @@ +export * from './AbstractRouteCollection' +export * from './CallableDispatcher' export * from './Commands/RouteListCommand' +export * from './CompiledRoute' +export * from './Contracts/IRouteValidator' +export * from './Contracts/Utilities' 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 './Providers/AssetsServiceProvider' -export * from './Providers/RouteServiceProvider' +export * from './ImplicitRouteBinding' +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 './PendingResourceRegistration' +export * from './PendingSingletonResourceRegistration' +export * from './Pipeline' +export * from './Providers/RoutingServiceProvider' +export * from './ResourceRegistrar' 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/router/tests/router.test.ts b/packages/router/tests/router.test.ts new file mode 100644 index 00000000..a84e6c18 --- /dev/null +++ b/packages/router/tests/router.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, it } from 'vitest' + +import { IApplication } from '@h3ravel/contracts' +import { h3ravel } from '@h3ravel/core' + +let app: IApplication + +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/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..26926f4b --- /dev/null +++ b/packages/session/package.json @@ -0,0 +1,69 @@ +{ + "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/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/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/Encryption.ts b/packages/session/src/Encryption.ts new file mode 100644 index 00000000..f15bb2b7 --- /dev/null +++ b/packages/session/src/Encryption.ts @@ -0,0 +1,42 @@ +import crypto, { createHash } from 'crypto' + +import { ConfigException } from '@h3ravel/foundation' + +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/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/Providers/SessionServiceProvider.ts b/packages/session/src/Providers/SessionServiceProvider.ts new file mode 100644 index 00000000..69873e70 --- /dev/null +++ b/packages/session/src/Providers/SessionServiceProvider.ts @@ -0,0 +1,34 @@ +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 { + public static priority = 895 + public static order = 'before:HttpServiceProvider' + + register (): void { + /** + * Register default drivers. + */ + SessionStore.register('file', fileBuilder) + SessionStore.register('database', dbBuilder) + SessionStore.register('memory', memoryBuilder) + SessionStore.register('redis', redisBuilder) + + 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 new file mode 100644 index 00000000..468f8816 --- /dev/null +++ b/packages/session/src/SessionManager.ts @@ -0,0 +1,331 @@ +import { IApplication, IHttpContext, IRequest, ISessionDriver, ISessionManager, SessionDriverOption } from '@h3ravel/contracts' +import { createHash, createHmac, randomBytes } from 'crypto' +import { getCookie, setCookie } from 'h3' + +import { FlashBag } from './FlashBag' +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 extends ISessionManager { + private app: IApplication + private ctx: IHttpContext + private driver: ISessionDriver + private appKey: string + private sessionId: string + private request: IRequest + public 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 + */ + 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! + + 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. + */ + 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. + */ + id (): string { + return this.sessionId + } + + /** + * Get the current session driver + */ + getDriver (): ISessionDriver { + return this.driver + } + + /** + * Retrieve a value from the session + * + * @param key + * @param defaultValue + * @returns + */ + get (key: string, defaultValue?: any): Promise | any { + return this.driver.get(key, defaultValue) + } + + /** + * Store a value in the session + * + * @param key + * @param value + */ + set (value: Record): Promise | void { + return this.driver.set(value) + } + + /** + * Store multiple key/value pairs + * + * @param values + */ + put (key: string, value: any): void | Promise { + return this.driver.put(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) + } + + /** + * Remove a key from the session + * + * @param key + */ + forget (key: string) { + return this.driver.forget(key) + } + + /** + * Retrieve all session data + * + * @returns + */ + all () { + return this.driver.all() + } + + /** + * 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() + } + + /** + * Age flash data at the end of the request lifecycle. + * + * @returns + */ + ageFlashData () { + return this.driver.ageFlashData() + } +} diff --git a/packages/session/src/SessionStore.ts b/packages/session/src/SessionStore.ts new file mode 100644 index 00000000..eeef1445 --- /dev/null +++ b/packages/session/src/SessionStore.ts @@ -0,0 +1,29 @@ +import { SessionDriverBuilder, SessionDriverOption } from '@h3ravel/contracts' + +/** + * 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: SessionDriverBuilder) { + 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: 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 new file mode 100644 index 00000000..166ba6f6 --- /dev/null +++ b/packages/session/src/adapters.ts @@ -0,0 +1,43 @@ +import { SessionDriverBuilder, SessionDriverOption } from '@h3ravel/contracts' + +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: SessionDriverBuilder = (sessionId, options: SessionDriverOption = {}) => { + 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: SessionDriverBuilder = (sessionId, options: SessionDriverOption = {}) => { + const table = options.table ?? 'sessions' + return new DatabaseDriver(options.sessionId ?? sessionId, table) +} + +/** + * MemoryDriver builder + * constructor(sessionId: string) + */ +export const memoryBuilder: SessionDriverBuilder = (sessionId) => { + return new MemoryDriver(sessionId) +} + +/** + * RedisDriver builder + * constructor(sessionId: string, redisClient?: RedisClient, prefix?: string) + */ +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 new file mode 100644 index 00000000..fbe1503c --- /dev/null +++ b/packages/session/src/drivers/DatabaseDriver.ts @@ -0,0 +1,266 @@ +import { safeDot, setNested } from '@h3ravel/support' + +import { DB } from '@h3ravel/database' +import { Driver } from './Driver' +import { FlashBag } from '../FlashBag' +import { ISessionDriver } from '@h3ravel/contracts' + +/** + * 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 extends Driver implements ISessionDriver { + /** + * + * @param sessionId The current session ID + * @param table + */ + constructor(protected sessionId: string, private table: string = 'sessions') { + super() + } + + /** + * Get the query builder for this table + */ + private query () { + return DB.table(this.table).where('id', this.sessionId) + } + + /** + * Fetch the session payload + */ + protected async fetchPayload> (): Promise { + const row = await this.query().first() + if (!row) return {} as T + + try { + const decrypted = this.encryptor.decrypt(row.payload) + const payload = typeof decrypted === 'string' ? JSON.parse(decrypted) : decrypted + + // Merge flash data with payload + return payload + } catch { + return {} as T + } + } + + /** + * 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 }) + } else { + await DB.table(this.table).insert({ + id: this.sessionId, + payload: encrypted, + last_activity: now, + }) + } + } + + /** + * Retrieve all data from the session including flash + */ + 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 + } + + /** + * Set one or multiple session values + */ + async set (values: Record): Promise { + const payload = await this.fetchPayload() + Object.assign(payload, values) + await this.savePayload(payload) + } + + /** + * Store a single key/value pair + */ + async put (key: string, value: any): Promise { + const payload = await this.fetchPayload() + setNested(payload, key, value) + await this.savePayload(payload) + } + + /** + * Append a value to an array key + */ + 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) + } + + /** + * Forget a session key + */ + async forget (key: string): Promise { + const payload = await this.fetchPayload() + delete payload[key] + await this.savePayload(payload) + } + + /** + * Retrieve all session data (excluding flash) + */ + async all> (): Promise { + return this.fetchPayload() + } + + /** + * Determine if a key exists (even if null) + */ + 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 + */ + async has (key: string): Promise { + const data = await this.getAll() + return data[key] !== undefined && data[key] !== null + } + + /** + * Get only specific keys + */ + 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 as T + } + + /** + * Return all except specific keys + */ + async except> (keys: string[]): Promise { + const data = await this.fetchPayload() + keys.forEach(k => delete data[k]) + return data as T + } + + /** + * Retrieve and delete a value + */ + 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 + */ + 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 + */ + async decrement (key: string, amount = 1) { + return this.increment(key, -amount) + } + + /** + * Flash a value for next request only + */ + async flash (key: string, value: any) { + this.flashBag.flash(key, value) + } + + /** + * Reflash all flash data for one more cycle + */ + async reflash () { + this.flashBag.reflash() + } + + /** + * Keep only specific flash keys + */ + async keep (keys: string[]) { + this.flashBag.keep(keys) + } + + /** + * Store a temporary value (flash) for this request only (not persisted) + */ + async now (key: string, value: any) { + this.flashBag.now(key, value) + } + + /** + * Regenerate session ID with same data + */ + async regenerate () { + const oldData = await this.fetchPayload() + this.sessionId = crypto.randomUUID() + await this.savePayload(oldData) + } + + /** + * 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 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({}) + } + + /** + * 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 new file mode 100644 index 00000000..bedd7be1 --- /dev/null +++ b/packages/session/src/drivers/Driver.ts @@ -0,0 +1,289 @@ +import { safeDot, setNested } from '@h3ravel/support' + +import { Encryption } from '../Encryption' +import { FlashBag } from '../FlashBag' +import { ISessionDriver } from '@h3ravel/contracts' + +/** + * Driver + * + * Base Session driver. + */ +export abstract class Driver extends ISessionDriver { + protected encryptor = new Encryption() + protected sessionId!: string + public flashBag: FlashBag = new FlashBag() + + /** + * Invalidate session completely and regenerate empty session. + */ + public abstract invalidate (): void + + /** + * Fetch current payload + * + * @returns + */ + protected abstract fetchPayload> (loadFlash?: boolean): T | Promise + + /** + * Save updated payload + * + * @param payload + */ + 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 + * + * @param key + * @param defaultValue + * @returns + */ + get (key: string, defaultValue?: any): T | Promise { + const payload = this.getAll() as Record + return safeDot(payload, key) || defaultValue + } + + /** + * Store a value in the session + * + * @param key + * @param value + */ + set (value: Record): void | Promise { + 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 | Promise { + 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 | Promise { + const payload = this.fetchPayload() as Record + 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): void | Promise { + const payload = this.fetchPayload() as Record + delete payload[key] + return this.savePayload(payload) + } + + /** + * Retrieve all session data + * + * @returns + */ + all> (): T | Promise { + return this.fetchPayload() as T + } + + /** + * Determine if a key exists (even if null). + * + * @param key + * @returns + */ + exists (key: string): boolean | Promise { + const data = this.getAll() + return Object.prototype.hasOwnProperty.call(data, key) + } + + /** + * Determine if a key has a non-null value. + * + * @param key + * @returns + */ + has (key: string): boolean | Promise { + const data = this.getAll() as Record + return data[key] !== undefined && data[key] !== null + } + + /** + * Get only specific keys. + * + * @param keys + * @returns + */ + 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 as T + } + + /** + * Return all keys except the specified ones. + * + * @param keys + * @returns + */ + except> (keys: string[]): T | Promise { + const data = this.fetchPayload() as Record + keys.forEach(k => delete data[k]) + return data as T + } + + /** + * Return and delete a key from the session. + * + * @param key + * @param defaultValue + * @returns + */ + 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) + return value + } + + /** + * Increment a numeric value by amount (default 1). + * + * @param key + * @param amount + * @returns + */ + 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) + return newVal + } + + /** + * Decrement a numeric value by amount (default 1). + * + * @param key + * @param amount + * @returns + */ + decrement (key: string, amount = 1): number | Promise { + return this.increment(key, -amount) + } + + /** + * Flash a value for next request only. + * + * @param key + * @param value + */ + flash (key: string, value: any): void | Promise { + this.flashBag.flash(key, value) + this.saveRawPayload() + } + + /** + * Reflash all flash data for one more cycle. + * + * @returns + */ + reflash (): void | Promise { + this.flashBag.reflash() + this.saveRawPayload() + } + + /** + * Keep only selected flash data. + * + * @param keys + * @returns + */ + keep (keys: string[]): void | Promise { + this.flashBag.keep(keys) + this.saveRawPayload() + } + + /** + * Store a temporary value (flash) for this request only (not persisted) + * + * @param key + * @param 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 (): 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): boolean | Promise { + 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 new file mode 100644 index 00000000..8cab364e --- /dev/null +++ b/packages/session/src/drivers/FileDriver.ts @@ -0,0 +1,100 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' + +import { Driver } from './Driver' +import { FlashBag } from '../FlashBag' +import { ISessionDriver } from '@h3ravel/contracts' +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 extends Driver implements ISessionDriver { + constructor( + 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 + + 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.savePayload({}) + } + } + + /** + * Get the absolute path for the current session file. + */ + private sessionFilePath (): string { + return path.join(this.sessionDir, this.sessionId) + } + + /** + * Read raw decrypted payload (including _flash). + */ + private readRawPayload (): Record { + const file = this.sessionFilePath() + if (!existsSync(file)) return {} + const content = readFileSync(file, 'utf8') + 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 (payload: Record): void { + const file = this.sessionFilePath() + + // Remove flash data before saving + // const { _flash, ...persistentPayload } = payload + + const encrypted = this.encryptor.encrypt(payload) + writeFileSync(file, encrypted, 'utf8') + } + + /** + * Completely invalidate the current session and regenerate a new one. + */ + invalidate (): void { + const file = this.sessionFilePath() + 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 new file mode 100644 index 00000000..ab356352 --- /dev/null +++ b/packages/session/src/drivers/MemoryDriver.ts @@ -0,0 +1,56 @@ +import { Driver } from './Driver' +import { FlashBag } from '../FlashBag' +import { ISessionDriver } from '@h3ravel/contracts' +import crypto from 'crypto' + +/** + * MemoryDriver + * + * Lightweight, ephemeral session storage. + * Intended for tests, local development, or short-lived apps. + */ +export class MemoryDriver extends Driver implements ISessionDriver { + private static store: Record> = {} + + constructor(protected sessionId: string) { + super() + this.sessionId = sessionId + if (!MemoryDriver.store[this.sessionId]) { + MemoryDriver.store[this.sessionId] = {} + } + } + + /** + * Fetch and return session payload. + * + * @returns Decrypted and usable payload + */ + protected fetchPayload> (): T { + const payload = { ...MemoryDriver.store[this.sessionId] } + + // Merge flash data with payload + return payload as T + } + + /** + * Persist session payload and flash bag state. + * + * @param data + */ + protected savePayload (payload: Record): void { + // Remove flash data before saving + // const { _flash, ...persistentPayload } = payload + + MemoryDriver.store[this.sessionId] = { ...payload } + } + + /** + * Invalidate current session and regenerate new session ID. + */ + 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 new file mode 100644 index 00000000..296ba708 --- /dev/null +++ b/packages/session/src/drivers/RedisDriver.ts @@ -0,0 +1,46 @@ +import { Driver } from './Driver' +import { FlashBag } from '../FlashBag' +import { ISessionDriver } from '@h3ravel/contracts' + +/** + * RedisDriver (placeholder) + */ +export class RedisDriver extends Driver implements ISessionDriver { + private static store: Record> = {} + + constructor( + /** + * The current session ID + */ + protected sessionId: string, + protected redisClient?: 'RedisClient', + protected prefix?: string + ) { + super() + } + + /** + * Fetch and return session payload. + * + * @returns Decrypted and usable payload + */ + protected fetchPayload> (): T { + return {} as T + } + + /** + * Persist session payload and flash bag state. + * + * @param data + */ + protected savePayload (_payload: Record): void { + } + + /** + * 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 new file mode 100644 index 00000000..d169f740 --- /dev/null +++ b/packages/session/src/index.ts @@ -0,0 +1,12 @@ +export * from './adapters' +export * from './Commands/MakeSessionTableCommand' +export * from './drivers/DatabaseDriver' +export * from './drivers/Driver' +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/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/support/src/Facades/.gitkeep b/packages/session/tests/config/db.sqlite3 similarity index 100% rename from packages/support/src/Facades/.gitkeep rename to packages/session/tests/config/db.sqlite3 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..a28dd930 --- /dev/null +++ b/packages/session/tests/database.spec.ts @@ -0,0 +1,211 @@ +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') + expect(driver.flashBag.get('flashKey')).toBe('flashVal') + }) + + it('reflashes data', async () => { + await driver.flash('f1', 'val') + await driver.reflash() + 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']) + 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(driver.flashBag.get('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..16ead5ef --- /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 { 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: IHttpContext +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(app, event), + }, 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(session.flashBag.get('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..156328ef --- /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 { IHttpContext } from '@h3ravel/contracts' +import { SessionManager } from '../src/SessionManager' +import { SessionServiceProvider } from '../src/Providers/SessionServiceProvider' +import path from 'node:path' + +let ctx: IHttpContext +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(app, event), + }, 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(session.flashBag.get('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/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/package.json b/packages/shared/package.json index 02bb86a7..34be9e2c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/shared", - "version": "0.27.7", + "version": "0.29.0", "description": "Shared Utilities.", "type": "module", "main": "./dist/index.cjs", @@ -12,7 +12,7 @@ "require": "./dist/index.cjs" }, "./package.json": "./package.json", - "./tsconfig.json": "./tsconfig.json" + "./tsconfig.base.json": "./tsconfig.base.json" }, "typesVersions": { "*": { @@ -23,7 +23,7 @@ }, "files": [ "dist", - "tsconfig.json" + "tsconfig.base.json" ], "publishConfig": { "access": "public", @@ -70,6 +70,7 @@ "preferred-pm": "catalog:" }, "devDependencies": { + "@h3ravel/contracts": "workspace:^", "fetchdts": "^0.1.6", "pnpm": "^10.14.0" } diff --git a/packages/shared/src/Container.ts b/packages/shared/src/Container.ts new file mode 100644 index 00000000..9f56228b --- /dev/null +++ b/packages/shared/src/Container.ts @@ -0,0 +1,27 @@ +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/shared/src/Contracts/BindingsContract.ts b/packages/shared/src/Contracts/BindingsContract.ts deleted file mode 100644 index 5f966bbe..00000000 --- a/packages/shared/src/Contracts/BindingsContract.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { H3, HTTPResponse, serve } from 'h3' - -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 = { - [K in keyof T as string extends K - ? never - : number extends K - ? never - : K]: T[K] -} - -export type Bindings = { - [key: string]: any; - [key: `app.${string}`]: 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 - 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 - } - 'http.app': H3 - 'path.base': string - 'load.paths': PathLoader - 'http.serve': typeof serve - 'http.request': IRequest - 'http.response': IResponse -} - -export type UseKey = keyof RemoveIndexSignature 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 1ea9f660..00000000 --- a/packages/shared/src/Contracts/IContainer.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Bindings, UseKey } from './BindingsContract' - -/** - * Interface for the Container contract, defining methods for dependency injection and service resolution. - */ -export interface IContainer { - /** - * 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; - - /** - * 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] - ): 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 - - /** - * 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; -} diff --git a/packages/shared/src/Contracts/IHttp.ts b/packages/shared/src/Contracts/IHttp.ts deleted file mode 100644 index 11a8481f..00000000 --- a/packages/shared/src/Contracts/IHttp.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { 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 - 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/IRequest.ts b/packages/shared/src/Contracts/IRequest.ts deleted file mode 100644 index 2ea4d53e..00000000 --- a/packages/shared/src/Contracts/IRequest.ts +++ /dev/null @@ -1,308 +0,0 @@ -import type { DotNestedKeys, DotNestedValue } from './ObjContract' - -import type { H3Event } from 'h3' -import type { IApplication } from './IApplication' -import { IParamBag } from './IParamBag' -import { IUploadedFile } from './IUploadedFile' -import { RequestMethod } from './IHttp' - -type RequestObject = Record; - -/** - * Interface for the Request contract, defining methods for handling HTTP request data. - */ -export declare class IRequest { - /** - * The current app instance - */ - app: IApplication - /** - * Parsed request body - */ - body: unknown - /** - * Gets route parameters. - * @returns An object containing route parameters. - */ - params: NonNullable - /** - * Uploaded files (FILES). - */ - constructor( - /** - * The current H3 H3Event instance - */ - event: H3Event, - /** - * The current app instance - */ - app: IApplication); - /** - * Factory method to create a Request instance from an H3Event. - */ - static create ( - /** - * The current H3 H3Event instance - */ - event: H3Event, - /** - * The current app instance - */ - app: IApplication): Promise; - /** - * 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 - */ - initialize (): Promise; - /** - * Retrieve all data from the instance (query + body). - */ - all> (keys?: string | string[]): T; - /** - * Retrieve an input item from the request. - * - * @param key - * @param defaultValue - * @returns - */ - input (key?: K, defaultValue?: any): K extends undefined ? RequestObject : any; - /** - * Retrieve a file from the request. - * - * By default a single `UploadedFile` instance will always be returned by - * the method (first file in property when there are multiple), unless - * the `expectArray` parameter is set to true, in which case, the method - * returns an `UploadedFile[]` array. - * - * @param key - * @param defaultValue - * @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; - /** - * Determine if the uploaded data contains a file. - * - * @param key - * @return boolean - */ - hasFile (key: string): boolean; - /** - * Get an object with all the files on the request. - */ - allFiles (): Record; - /** - * Extract and convert uploaded files from FormData. - */ - convertUploadedFiles (files: Record): Record; - /** - * Determine if the data contains a given key. - * - * @param keys - * @returns - */ - has (keys: string[] | string): boolean; - /** - * Determine if the instance is missing a given key. - */ - 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; - /** - * Get all of the data except for a specified array of items. - * - * @param keys - * @returns - */ - 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; - /** - * Merge new input into the request's input, but only when that key is missing from the request. - * - * @param input - */ - mergeIfMissing (input: Record): this; - /** - * Get the keys for all of the input and files. - */ - keys (): string[]; - /** - * Determine if the request is sending JSON. - * - * @return bool - */ - isJson (): boolean; - /** - * Determine if the current request probably expects a JSON response. - * - * @returns - */ - expectsJson (): boolean; - /** - * Determine if the current request is asking for JSON. - * - * @returns - */ - wantsJson (): boolean; - /** - * Gets a list of content types acceptable by the client browser in preferable order. - * @returns {string[]} - */ - getAcceptableContentTypes (): string[]; - /** - * Determine if the request is the result of a PJAX call. - * - * @return bool - */ - pjax (): boolean; - /** - * Returns true if the request is an XMLHttpRequest (AJAX). - * - * @alias isXmlHttpRequest() - * @returns {boolean} - */ - ajax (): boolean; - /** - * Returns true if the request is an XMLHttpRequest (AJAX). - */ - isXmlHttpRequest (): boolean; - /** - * Returns the value of the requested header. - */ - 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; - /** - * Checks whether or not the method is safe. - * - * @see https://tools.ietf.org/html/rfc7231#section-4.2.1 - */ - isMethodSafe (): boolean; - /** - * Checks whether or not the method is idempotent. - */ - isMethodIdempotent (): boolean; - /** - * Checks whether the method is cacheable or not. - * - * @see https://tools.ietf.org/html/rfc7231#section-4.2.3 - */ - isMethodCacheable (): 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() - */ - getMethod (): RequestMethod; - /** - * Gets the "real" request method. - * - * @see getMethod() - */ - getRealMethod (): RequestMethod; - /** - * Get the client IP address. - */ - ip (): string | undefined; - /** - * Get a URI instance for the request. - */ - uri (): unknown; - /** - * Get the full URL for the request. - */ - fullUrl (): string; - /** - * Return the Request instance. - */ - instance (): this; - /** - * Get the request method. - */ - method (): RequestMethod; - /** - * Get the JSON payload for the request. - * - * @param key - * @param defaultValue - * @return {InputBag} - */ - json (key?: string, defaultValue?: any): K extends undefined ? IParamBag : any; - /** - * 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; - /** - * 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 - */ - get (key: string, defaultValue?: any): any; - /** - * Enables support for the _method request parameter to determine the intended HTTP method. - * - * Be warned that enabling this feature might lead to CSRF issues in your code. - * Check that you are using CSRF tokens when required. - * If the HTTP method parameter override is enabled, an html-form with method "POST" can be altered - * and used to send a "PUT" or "DELETE" request via the _method request parameter. - * If these methods are not protected against CSRF, this presents a possible vulnerability. - * - * The HTTP method can only be overridden when the real HTTP method is POST. - */ - static enableHttpMethodParameterOverride (): void; - /** - * Checks whether support for the _method request parameter is enabled. - */ - static getHttpMethodParameterOverride (): boolean; - /** - * Dump the items. - * - * @param keys - * @return this - */ - dump (...keys: any[]): this; - /** - * Get the base event - */ - getEvent (): H3Event; - getEvent> (key: K): DotNestedValue; -} diff --git a/packages/shared/src/Contracts/IResponse.ts b/packages/shared/src/Contracts/IResponse.ts deleted file mode 100644 index cc2c70f2..00000000 --- a/packages/shared/src/Contracts/IResponse.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { DotNestedKeys, DotNestedValue } from '@h3ravel/shared' -import type { H3Event, HTTPResponse } from 'h3' - -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; - /** - * 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; - /** - * - * @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): HTTPResponse; - /** - * 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/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/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/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/shared/src/Utils/Logger.ts b/packages/shared/src/Utils/Logger.ts index acf08768..ec68bd10 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 { /** @@ -77,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) @@ -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,14 +279,25 @@ 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) - } 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 }) as LoggerLog + + /** + * A simple console like output logger + * + * @returns + */ + static console () { + return Console + } } diff --git a/packages/shared/src/Utils/PathLoader.ts b/packages/shared/src/Utils/PathLoader.ts index 0e29110d..0c667e7c 100644 --- a/packages/shared/src/Utils/PathLoader.ts +++ b/packages/shared/src/Utils/PathLoader.ts @@ -1,8 +1,10 @@ -import { IPathName } from '../Contracts/IApplication' +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,6 +13,7 @@ export class PathLoader { public: '/public', storage: '/storage', database: '/src/database', + commands: '/src/app/Console/Commands' } /** @@ -34,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) @@ -54,4 +57,14 @@ export class PathLoader { this.paths[name] = path } + + distPath (path: string, skipExt = false) { + path = path.replace('/src/', `/${process.env.DIST_DIR ?? '.h3ravel/serve'}/`.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..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', @@ -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 bdbe8aa6..8bc61f05 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,17 +1,12 @@ -export * from './Contracts/BindingsContract' -export * from './Contracts/IApplication' -export * from './Contracts/IContainer' -export * from './Contracts/IHttp' -export * from './Contracts/IHttpResponse' -export * from './Contracts/IParamBag' -export * from './Contracts/IRequest' -export * from './Contracts/IResponse' -export * from './Contracts/IServiceProvider' -export * from './Contracts/IUploadedFile' +export * from './Container' export * from './Contracts/ObjContract' export * from './Contracts/PromptsContract' -export * from './Contracts/Router' 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' export * from './Utils/Logger' 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.base.json b/packages/shared/tsconfig.base.json new file mode 100644 index 00000000..1dcb8687 --- /dev/null +++ b/packages/shared/tsconfig.base.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 1dcb8687..05757340 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true, diff --git a/packages/shared/tsdown.config.ts b/packages/shared/tsdown.config.ts index 81b28e3d..edde6cb1 100644 --- a/packages/shared/tsdown.config.ts +++ b/packages/shared/tsdown.config.ts @@ -6,12 +6,12 @@ export default defineConfig([ ...baseConfig, exports: { customExports (exports) { - return Object.assign({}, exports, { './tsconfig.json': './tsconfig.json' }) + return Object.assign({}, exports, { './tsconfig.base.json': './tsconfig.base.json' }) }, }, format: ['esm', 'cjs'], entry: ['src/index.ts'], - sourcemap: true, + sourcemap: false, target: 'node22', platform: 'node', }, diff --git a/packages/support/package.json b/packages/support/package.json index 45eb584a..a63ed68f 100644 --- a/packages/support/package.json +++ b/packages/support/package.json @@ -1,6 +1,6 @@ { "name": "@h3ravel/support", - "version": "0.15.6", + "version": "0.17.0", "description": "Shared helpers, facades and utilities for H3ravel.", "type": "module", "main": "./dist/index.cjs", @@ -11,20 +11,21 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, + "./facades": { + "import": "./dist/facades.js", + "require": "./dist/facades.cjs" + }, + "./traits": { + "import": "./dist/traits.js", + "require": "./dist/traits.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": { @@ -51,11 +52,13 @@ "version-patch": "pnpm version patch" }, "devDependencies": { + "@h3ravel/collect.js": "catalog:prod", "@h3ravel/shared": "workspace:^", "@types/luxon": "catalog:", "typescript": "^5.4.0" }, "dependencies": { + "@h3ravel/contracts": "workspace:^", "dayjs": "catalog:", "luxon": "catalog:" } diff --git a/packages/support/src/Collection.ts b/packages/support/src/Collection.ts new file mode 100644 index 00000000..8114ca45 --- /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 | Record) { + super(collection) + } +} + +/** + * + * @param collection + * @returns + */ +export const collect = (collection?: T | T[] | Record | undefined): Collection => { + return new Collection(collection) +} \ No newline at end of file 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/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/Facades/Facades.ts b/packages/support/src/Facades/Facades.ts new file mode 100644 index 00000000..ce85ba01 --- /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/shared' + +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/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/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/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 new file mode 100644 index 00000000..469ff0e9 --- /dev/null +++ b/packages/support/src/Facades/index.ts @@ -0,0 +1,6 @@ +export * from './Facades' +export * from './HashFacade' +export * from './RequestFacade' +export * from './ResponseFacade' +export * from './RouteFacade' +export * from './URLFacade' 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.ts b/packages/support/src/Helpers.ts new file mode 100644 index 00000000..bb84f149 --- /dev/null +++ b/packages/support/src/Helpers.ts @@ -0,0 +1,111 @@ +import { Nullable, OptionalFn, OptionalProxy, Tap } from './Contracts/Helpers' + +import { HigherOrderTapProxy } from './HigherOrderTapProxy' + +/** + * Call the given Closure with the given value then return the value. + * + * @param value + * @param callback + */ +export const tap: Tap = (value: any, callback?: (val: X) => void) => { + if (!callback) { + return new HigherOrderTapProxy(value) + } + + callback(value) + + 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/Arr.ts b/packages/support/src/Helpers/Arr.ts index cd6cad1a..2e92d5f3 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)) } @@ -726,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] } @@ -878,4 +883,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/support/src/Helpers/Obj.ts b/packages/support/src/Helpers/Obj.ts index e955b0e6..0085b65e 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 * }) @@ -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: unknown, 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, @@ -516,16 +595,98 @@ 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))) } + /** + * Checks if a value is a plain object (not array, function, etc.) + * + * @param value + * @param allowArray + * @returns + */ + 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. + * + * @param obj + * @param prefix + * @returns */ static prependKeysWith> (obj: T, prefix: string): Record { if (!Obj.accessible(obj)) return {} @@ -540,6 +701,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 +722,47 @@ export class Obj { Object.entries(obj).forEach(([k, v]) => build(k, v)) 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 + * + * 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 + */ + 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/Str.ts b/packages/support/src/Helpers/Str.ts index aa61fcd4..863307a6 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. * @@ -551,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') @@ -724,6 +770,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 +2652,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 +2688,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 +2717,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 +3172,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 +3657,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 +3681,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 +4619,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/Helpers/Time.ts b/packages/support/src/Helpers/Time.ts index 3e0904e4..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 } 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,15 +32,21 @@ 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 | DateTime) + constructor(config?: ConfigType | DateTime, format?: OptionType, locale?: boolean) + constructor(config?: ConfigType | DateTime, format?: OptionType, locale?: string | boolean, strict?: boolean) { super(config) - this.instance = dayjs(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) => { if (prop in target) return Reflect.get(target, prop, receiver) @@ -76,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. * @@ -85,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 * @@ -98,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 * @@ -196,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/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/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/support/src/Providers/ServiceProvider.ts b/packages/support/src/Providers/ServiceProvider.ts new file mode 100644 index 00000000..3f211d5d --- /dev/null +++ b/packages/support/src/Providers/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?: boolean = false + /** + * Indicate that this service provider only runs in console + */ + console?: boolean = 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/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/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/src/index.ts b/packages/support/src/index.ts index 27c6ef78..e16188d2 100644 --- a/packages/support/src/index.ts +++ b/packages/support/src/index.ts @@ -1,9 +1,13 @@ +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' +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 +17,9 @@ 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' +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 new file mode 100644 index 00000000..a53f10c9 --- /dev/null +++ b/packages/support/tests/collection.test.ts @@ -0,0 +1,12 @@ +import { Collection, collect } 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(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..4ffcc4c8 --- /dev/null +++ b/packages/support/tsdown.config.ts @@ -0,0 +1,12 @@ +import { baseConfig } from '../../tsdown.config' +import { defineConfig } from 'tsdown' + +export default defineConfig({ + ...baseConfig, + clean: true, + entry: { + index: 'src/index.ts', + traits: 'src/Traits/index.ts', + facades: 'src/Facades/index.ts', + }, +}) diff --git a/packages/url/package.json b/packages/url/package.json index 14dfc28d..7caa4c11 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": [ @@ -60,18 +60,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..baa0daad 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/support' 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..9a02653e 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 {/** */ } @@ -96,13 +94,13 @@ export class RequestAwareHelpers { */ query (): RouteParams { const request = this.getCurrentRequest() - return request.query || {} + return request._query || {} } } /** * 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..b9896ed2 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') @@ -78,27 +77,17 @@ 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, - app?: Application + app?: IApplication ): Url { if (!app) { 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 || typeof router.route !== 'function') { - throw new Error('Router not available or does not support route generation') - } - - if (typeof router.route !== 'function') { - throw new Error('Router does not support route generation') - } + const routeUrl = app.make('url').route(name, params) - const routeUrl = router.route(name, params) if (!routeUrl) { throw new Error(`Route "${name}" not found`) } @@ -112,10 +101,9 @@ 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) + return Url.route(name, params, app).withSignature(app) } /** @@ -125,19 +113,18 @@ 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) + return Url.route(name, params, app).withSignature(app, expiration) } /** * 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 +134,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 +268,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 +303,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..8e23e002 100644 --- a/packages/url/src/app.globals.d.ts +++ b/packages/url/src/app.globals.d.ts @@ -1,25 +1,13 @@ -import { ExtractControllerMethods } from '@h3ravel/shared' -import { RequestAwareHelpers, Url } from '.' +import { ExtractClassMethods } from '@h3ravel/shared' export { } declare global { - /** - * Create a URL from a named route - */ - function route (name: string, params?: Record): string; - /** * Create a URL from a controller action */ function action any> ( - controller: string | [C, methodName: ExtractControllerMethods>], + controller: string | [C, methodName: ExtractClassMethods>], params?: Record ): string; - - /** - * Get request-aware URL helpers - */ - function url (): RequestAwareHelpers; - function url (path: string): 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..35f37363 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,13 +40,17 @@ 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') - 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/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..95163c4d --- /dev/null +++ b/packages/validation/README.md @@ -0,0 +1,74 @@ +

+ + 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. + +- TypeScript-first design — Full type inference for rules, messages, and validated data. +- Chainable API — Optional fluent syntax for building validators. + +## 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/[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 new file mode 100644 index 00000000..1635ebc2 --- /dev/null +++ b/packages/validation/package.json @@ -0,0 +1,89 @@ +{ + "name": "@h3ravel/validation", + "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": [ + "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": { + "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/shared": "workspace:^", + "@h3ravel/support": "workspace:^", + "simple-body-validator": "catalog:", + "@h3ravel/foundation": "workspace:^" + }, + "peerDependencies": { + "@h3ravel/core": "workspace:^", + "@h3ravel/config": "workspace:^", + "@h3ravel/database": "workspace:^", + "@h3ravel/contracts": "workspace:^" + }, + "devDependencies": { + "typescript": "^5.4.0" + }, + "peerDependenciesMeta": { + "@h3ravel/core": { + "optional": true + }, + "@h3ravel/config": { + "optional": true + }, + "@h3ravel/database": { + "optional": true + } + } +} \ No newline at end of file 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/ImplicitRule.ts b/packages/validation/src/ImplicitRule.ts new file mode 100644 index 00000000..06631fb8 --- /dev/null +++ b/packages/validation/src/ImplicitRule.ts @@ -0,0 +1,16 @@ +import { ImplicitRule as Rule } from 'simple-body-validator' +import type { ValidationRuleCallable } from '@h3ravel/contracts' +import type { Validator } from './Validator' + +export abstract class ImplicitRule extends Rule { + rules: ValidationRuleCallable[] = [] + + /** + * 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/Providers/ValidationServiceProvider.ts b/packages/validation/src/Providers/ValidationServiceProvider.ts new file mode 100644 index 00000000..80066324 --- /dev/null +++ b/packages/validation/src/Providers/ValidationServiceProvider.ts @@ -0,0 +1,21 @@ +/** + * Service provider for Validation utilities + */ +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 + */ + register (): void { + } + + /** + * Boot URL services + */ + boot (): void { + } +} diff --git a/packages/validation/src/Rules/ExtendedRules.ts b/packages/validation/src/Rules/ExtendedRules.ts new file mode 100644 index 00000000..dd83bf5c --- /dev/null +++ b/packages/validation/src/Rules/ExtendedRules.ts @@ -0,0 +1,119 @@ +import { DateTime } from '@h3ravel/support' +import { ValidationRule } from '../ValidationRule' +import type { ValidationRuleCallable } from '@h3ravel/contracts' +import type { Validator } from '../Validator' + +export class ExtendedRules extends ValidationRule { + /** + * The validator instance. + */ + protected validator!: Validator + + public setValidator (validator: Validator): this { + this.validator = validator + return this + } + + rules: ValidationRuleCallable[] = [ + { + + 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) => { + 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.' + }, + ] + validate () { } +} \ 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..7341c1e8 --- /dev/null +++ b/packages/validation/src/ValidationException.ts @@ -0,0 +1,125 @@ +import { IHttpResponse, IRequest } from '@h3ravel/contracts' + +import { MessageBag } from './utilities/MessageBag' +import { Str } from '@h3ravel/support' +import { UnprocessableEntityHttpException } from '@h3ravel/foundation' +import { Validator } from './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') { + super(ValidationException.summarize(validator)) + + this.name = 'ValidationException' + this.validator = validator + this.response = response + this.errorBag = errorBag + Object.setPrototypeOf(this, ValidationException.prototype) + } + + /** + * Send a custom response body for this exception + * + * @param request + * @returns + */ + 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) + } + + return { + message: this.message, + errors: this.errors(), + } + } + + /** + * 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/ValidationRule.ts b/packages/validation/src/ValidationRule.ts new file mode 100644 index 00000000..f0928c11 --- /dev/null +++ b/packages/validation/src/ValidationRule.ts @@ -0,0 +1,34 @@ +import type { IValidationRule, RulesForData, ValidationRuleCallable } from '@h3ravel/contracts' + +import { Rule } from 'simple-body-validator' +import type { Validator } from './Validator' + +export abstract class ValidationRule< + D extends Record = any, + R extends RulesForData = any +> extends Rule implements IValidationRule { + rules: ValidationRuleCallable[] = [] + 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 new file mode 100644 index 00000000..a1953295 --- /dev/null +++ b/packages/validation/src/Validator.ts @@ -0,0 +1,285 @@ +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 { ValidationException } from './ValidationException' +import { ValidationRule } from './ValidationRule' + +register('telephone', function (value) { + return /^\d{3}-\d{3}-\d{4}$/.test(value) +}) + +export class Validator< + D extends Record = any, + R extends RulesForData = RulesForData +> implements IValidator { + #messages: Partial, string>> + #after: (() => void)[] = [] + + private data: D + private rules: R + private _errors: MessageBag + private passing: boolean = false + private executed: boolean = false + private instance?: SimpleBodyValidator + private errorBagName = 'default' + private registeredCustomRules: CustomValidationRules[] = [ + new ExtendedRules() + ] + private shouldStopOnFirstFailure = false + + constructor( + data: D, + rules: R, + messages: Partial, string>> = {} + ) { + this.data = data + this.rules = rules + this.#messages = messages + this._errors = new MessageBag() + this.bindServices() + } + + /** + * 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() + + 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 + } + + /** + * Opposite of passes() + */ + public async fails (): Promise { + return !(await this.passes()) + } + + /** + * Throw if validation fails, else return executed data + * + * @throws ValidationException if validation fails + */ + 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 + } + + /** + * Add an after validation callback. + * + * @param callback + */ + public after) => void) | BaseValidationRuleClass> (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. + */ + 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: ValidationRuleSet): 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 + } + + /** + * Bind all required services here. + */ + private bindServices () { + /** + * Register all custom rules + */ + for (const reged of this.registeredCustomRules) { + 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) { + setTranslationObject({ + en: { + [rule.name]: rule.message, + } + }) + } + } + } + } + return this + } + + 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/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/validation/src/index.ts b/packages/validation/src/index.ts new file mode 100644 index 00000000..0dd2398a --- /dev/null +++ b/packages/validation/src/index.ts @@ -0,0 +1,8 @@ +export * from './Contracts/Exports' +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/validation/src/utilities/MessageBag.ts b/packages/validation/src/utilities/MessageBag.ts new file mode 100644 index 00000000..25a6b061 --- /dev/null +++ b/packages/validation/src/utilities/MessageBag.ts @@ -0,0 +1,263 @@ +import type { IMessageBag, ValidationMessageProvider } from '@h3ravel/contracts' + +export class MessageBag implements IMessageBag { + /** + * 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 | ValidationMessageProvider): this { + const incoming = + (messages as ValidationMessageProvider).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/config/database.ts b/packages/validation/tests/config/database.ts new file mode 100644 index 00000000..b93e8070 --- /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: '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/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.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..65881141 --- /dev/null +++ b/packages/validation/tests/validator.spec.ts @@ -0,0 +1,339 @@ +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', () => { + describe('basic rules', () => { + + it('throws ValidationException for invalid email', async () => { + const v = new Validator( + { email: 'invalid-email' }, + { email: 'required|email' } + ) + + 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' }, + { email: 'required|email', name: 'required|min:2|max:10' } + ) + + const result = await v.passes() + expect(result).toBe(true) + expect(v.errors().isEmpty()).toBe(true) + }) + + it('fails validation when required field is missing', async () => { + const v = new Validator({}, { name: 'required' }) + + await expect(v.validate()).rejects.toThrowError(ValidationException) + }) + + it('applies multiple rules correctly', async () => { + const v = new Validator( + { name: 'A' }, + { name: 'required|min:2|max:5' } + ) + + await expect(v.validate()).rejects.toThrowError(ValidationException) + }) + }) + + describe('advanced rules', () => { + + it('validates numeric and min/max values', async () => { + const v = new Validator( + { age: 15 }, + { age: 'required|numeric|min:18|max:60' } + ) + + await expect(v.validate()).rejects.toThrowError(ValidationException) + }) + + it('passes when numeric is in valid range', async () => { + const v = new Validator( + { age: 25 }, + { age: 'required|numeric|min:18|max:60' } + ) + + const result = await v.passes() + expect(result).toBe(true) + }) + + it('validates boolean values', async () => { + const v = new Validator( + { active: 'yes' }, + { active: 'boolean' }, + ) + + await expect(v.validate()).rejects.toThrowError(ValidationException) + }) + }) + + describe('arrays and nested data', () => { + + it('validates wildcard array elements', async () => { + const v = new Validator( + { users: [{ email: 'bad' }, { email: 'good@example.com' }] }, + { 'users.*.email': 'required|email', }, + { 'users.*.email.required': 'Hello' } + ) + + await expect(v.validate()).rejects.toThrowError(ValidationException) + }) + + it('validates nested object fields', async () => { + const v = new Validator( + { user: { name: { first: '', last: 'Doe' } } }, + { 'user.name.first': 'required|min:2', 'user.name.last': 'required|min:2' } + ) + + await expect(v.validate()).rejects.toThrowError(ValidationException) + }) + + it('passes with valid nested fields', async () => { + 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 v.passes() + expect(result).toBe(true) + }) + }) + + describe('custom messages', () => { + it('uses custom error messages', async () => { + const v = new Validator( + { email: '' }, + { email: 'required|email' }, + { 'email.required': 'Email is required!' } + ) + + await expect(v.validate()).rejects.toThrowError(ValidationException) + try { + await v.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 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 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 v = new Validator({ email: '' }, { email: 'required|email' }) + try { + await v.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 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 v = new Validator( + { email: 'invalid' }, + { email: 'required|email' } + ) + + await expect(v.validate()).rejects.toThrowError(ValidationException) + + v.setData({ email: 'valid@example.com' }) + const result = await v.passes() + expect(result).toBe(true) + }) + }) + + describe('extended rules', () => { + beforeAll(async () => { + 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' + } + }) + + 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' }) + }) + + 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) + }) + + 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', () => { + 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 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"] +} diff --git a/packages/view/package.json b/packages/view/package.json index b4fe4778..df0777ac 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": [ @@ -69,6 +69,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 a2df7e96..6a1cd9f6 100644 --- a/packages/view/src/Providers/ViewServiceProvider.ts +++ b/packages/view/src/Providers/ViewServiceProvider.ts @@ -1,5 +1,6 @@ import { EdgeViewEngine } from '../EdgeViewEngine' -import { ServiceProvider } from '@h3ravel/core' +import { Responsable } from '@h3ravel/http' +import { ServiceProvider } from '@h3ravel/support' /** * View Service Provider @@ -17,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() @@ -36,15 +37,15 @@ 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') - return response.html(await this.app.make('edge').render(template, data)) - } + if (response instanceof Responsable) { + response = response.toResponse(request) + } - /** - * Bind the view method to the global variable space - */ - globalThis.view = view + return response.html(await this.app.make('edge').render(template, data), true) + } /** * Dynamically bind the view renderer to the service container. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e841c9ed..84885938 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,7 +17,7 @@ catalogs: version: 3.1.0 '@swc/core': specifier: ^1.15.0 - version: 1.14.0 + version: 1.15.0 '@types/luxon': specifier: ^3.7.1 version: 3.7.1 @@ -50,7 +50,7 @@ catalogs: version: 10.1.0 dayjs: specifier: ^1.11.18 - version: 1.11.18 + version: 1.11.19 detect-port: specifier: ^2.1.0 version: 2.1.0 @@ -84,6 +84,9 @@ catalogs: 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 @@ -102,6 +105,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 @@ -114,12 +120,15 @@ 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 tsx: specifier: ^4.20.6 - version: 4.20.5 + version: 4.20.6 typescript-eslint: specifier: ^8.46.3 version: 8.46.3 @@ -131,11 +140,14 @@ catalogs: version: 5.1.4 prod: '@h3ravel/arquebus': - specifier: ^0.6.16 - version: 0.6.16 + specifier: ^0.7.6 + version: 0.7.6 + '@h3ravel/collect.js': + specifier: ^5.3.3 + version: 5.3.3 '@h3ravel/musket': - specifier: ^0.3.11 - version: 0.3.11 + specifier: ^0.6.10 + version: 0.6.10 h3: specifier: 2.0.1-rc.5 version: 2.0.1-rc.5 @@ -155,7 +167,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 @@ -200,10 +212,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 @@ -224,13 +239,13 @@ 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 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 @@ -251,7 +266,7 @@ importers: dependencies: '@h3ravel/arquebus': specifier: catalog:prod - version: 0.6.16(@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 @@ -267,9 +282,15 @@ 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 + '@h3ravel/foundation': + specifier: workspace:^ + version: link:../../packages/foundation '@h3ravel/hashing': specifier: workspace:^ version: link:../../packages/hashing @@ -281,13 +302,16 @@ 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.6.10(@h3ravel/support@packages+support)(@types/node@24.9.2) '@h3ravel/queue': specifier: workspace:^ version: link:../../packages/queue '@h3ravel/router': specifier: workspace:^ version: link:../../packages/router + '@h3ravel/session': + specifier: workspace:^ + version: link:../../packages/session '@h3ravel/shared': specifier: workspace:^ version: link:../../packages/shared @@ -297,6 +321,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 @@ -321,25 +348,25 @@ 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 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.5 + version: 4.20.6 typescript: specifier: ^5.9.3 version: 5.9.3 packages/cache: dependencies: - '@h3ravel/core': + '@h3ravel/support': specifier: workspace:^ - version: link:../core + version: link:../support devDependencies: typescript: specifier: ^5.4.0 @@ -349,7 +376,7 @@ importers: dependencies: '@h3ravel/musket': specifier: catalog:prod - version: 0.3.11(@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 @@ -357,12 +384,15 @@ importers: specifier: workspace:^ version: link:../support devDependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts '@h3ravel/core': specifier: workspace:^ version: link:../core tsx: specifier: 'catalog:' - version: 4.20.5 + version: 4.20.6 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -372,15 +402,18 @@ importers: '@h3ravel/core': specifier: workspace:^ version: link:../core + '@h3ravel/foundation': + specifier: workspace:^ + version: link:../foundation '@h3ravel/musket': specifier: catalog:prod - version: 0.3.11(@h3ravel/support@packages+support)(@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 '@h3ravel/support': specifier: workspace:^ - version: link:../support + version: 0.15.6 chalk: specifier: ^5.6.2 version: 5.6.2 @@ -389,7 +422,7 @@ importers: version: 14.0.1 dayjs: specifier: 'catalog:' - version: 1.11.18 + version: 1.11.19 dotenv: specifier: 'catalog:' version: 17.2.3 @@ -407,14 +440,36 @@ importers: version: 5.0.0 tsx: specifier: 'catalog:' - version: 4.20.5 + version: 4.20.6 devDependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts typescript: specifier: ^5.9.2 version: 5.9.2 + packages/contracts: + dependencies: + h3: + specifier: catalog:prod + version: 2.0.1-rc.5 + devDependencies: + '@h3ravel/musket': + specifier: catalog:prod + version: 0.6.10(@h3ravel/support@0.16.1)(@types/node@24.10.0) + edge.js: + specifier: 'catalog:' + version: 6.3.0 + simple-body-validator: + specifier: 'catalog:' + version: 1.3.9 + packages/core: dependencies: + '@h3ravel/foundation': + specifier: workspace:^ + version: link:../foundation '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -455,6 +510,9 @@ importers: specifier: 'catalog:' version: 2.8.1 devDependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts '@types/semver': specifier: 'catalog:' version: 7.7.1 @@ -466,7 +524,7 @@ importers: dependencies: '@h3ravel/arquebus': specifier: catalog:prod - version: 0.6.16(@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 @@ -475,7 +533,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.6.10(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -490,6 +548,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': @@ -497,7 +565,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.6.10(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -509,11 +577,45 @@ importers: specifier: ^5.4.0 version: 5.9.2 + packages/foundation: + dependencies: + '@h3ravel/musket': + specifier: catalog:prod + version: 0.6.10(@h3ravel/support@packages+support)(@types/node@24.10.0) + '@h3ravel/shared': + specifier: workspace:^ + version: link:../shared + '@h3ravel/support': + specifier: workspace:^ + version: link:../support + 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/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 @@ -530,21 +632,27 @@ 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.11(@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 '@h3ravel/shared': specifier: workspace:^ version: link:../shared '@h3ravel/support': specifier: workspace:^ version: link:../support - '@h3ravel/url': + '@h3ravel/validation': specifier: workspace:^ - version: link:../url + version: link:../validation h3: specifier: catalog:prod version: 2.0.1-rc.5 @@ -577,6 +685,9 @@ importers: packages/queue: dependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts '@h3ravel/core': specifier: workspace:^ version: link:../core @@ -587,18 +698,24 @@ importers: packages/router: dependencies: - '@h3ravel/core': + '@h3ravel/contracts': specifier: workspace:^ - version: link:../core + version: link:../contracts '@h3ravel/database': specifier: workspace:^ version: link:../database + '@h3ravel/events': + specifier: workspace:^ + version: link:../events + '@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.6.10(@h3ravel/support@packages+support)(@types/node@24.10.0) '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -612,6 +729,25 @@ importers: specifier: 'catalog:' version: 0.2.2 + packages/session: + dependencies: + '@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 + packages/shared: dependencies: '@inquirer/prompts': @@ -636,6 +772,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 @@ -645,13 +784,19 @@ importers: packages/support: dependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts dayjs: specifier: 'catalog:' - version: 1.11.18 + version: 1.11.19 luxon: specifier: 'catalog:' version: 3.7.2 devDependencies: + '@h3ravel/collect.js': + specifier: catalog:prod + version: 5.3.3 '@h3ravel/shared': specifier: workspace:^ version: link:../shared @@ -663,23 +808,54 @@ importers: version: 5.8.3 packages/url: + dependencies: + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts + '@h3ravel/foundation': + specifier: workspace:^ + version: link:../foundation + '@h3ravel/shared': + specifier: workspace:^ + version: link:../shared + '@h3ravel/support': + specifier: workspace:^ + version: link:../support + devDependencies: + typescript: + specifier: ^5.4.0 + version: 5.9.2 + + packages/validation: dependencies: '@h3ravel/config': specifier: workspace:^ version: link:../config + '@h3ravel/contracts': + specifier: workspace:^ + version: link:../contracts '@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: 'catalog:' + version: 1.3.9 devDependencies: typescript: specifier: ^5.4.0 - version: 5.9.2 + version: 5.9.3 packages/view: dependencies: @@ -691,10 +867,13 @@ 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.6.10(@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 @@ -1119,12 +1298,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'} @@ -1137,12 +1310,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'} @@ -1155,12 +1322,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'} @@ -1173,12 +1334,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'} @@ -1191,12 +1346,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'} @@ -1209,12 +1358,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'} @@ -1227,12 +1370,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'} @@ -1245,12 +1382,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'} @@ -1263,12 +1394,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'} @@ -1281,12 +1406,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'} @@ -1299,12 +1418,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'} @@ -1317,12 +1430,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'} @@ -1335,12 +1442,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'} @@ -1353,12 +1454,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'} @@ -1371,12 +1466,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'} @@ -1389,12 +1478,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'} @@ -1407,24 +1490,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'} @@ -1437,24 +1508,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'} @@ -1467,24 +1526,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'} @@ -1497,12 +1544,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'} @@ -1515,12 +1556,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'} @@ -1533,12 +1568,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'} @@ -1551,12 +1580,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'} @@ -1604,23 +1627,35 @@ 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.7.6': + resolution: {integrity: sha512-nBwW/47UBFQX+8qVuCEWA/BxvZBZSnI6oX6Yw5nmts8ddnFS/AMjgfVj5FwfK0tPt2v/An5u1PNL6wfh6LBqbQ==} engines: {node: '>=14', pnpm: '>=4'} hasBin: true - '@h3ravel/musket@0.3.11': - resolution: {integrity: sha512-pm8L3iyuvjyDFipOVOWRFmqReMcZ7JjeZJKGYUqfxlY3oquvt9L4/5tagkb4rM9onchKWOE8KjnIC5daxDJICw==} + '@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.10': + resolution: {integrity: sha512-+An+HX3fM853f8ZCVE2CBwJ0oM+2jH/xUimx0EW7l05fNo8F9ltxrrl5997DLtCzn+GWsf6em++iHxDHkOiQDA==} 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==} + '@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'} @@ -1827,6 +1862,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'} @@ -1847,11 +1886,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==} @@ -1884,177 +1927,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==} + '@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-android-arm64@1.0.0-beta.46': - resolution: {integrity: sha512-1nfXUqZ227uKuLw9S12OQZU5z+h+cUOXLW5orntWVxHWvt20pt1PGUcVoIU8ssngKABu0vzHY268kAxuYX24BQ==} + '@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: [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==} - 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==} + '@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-freebsd-x64@1.0.0-beta.46': - resolution: {integrity: sha512-Cuk5opdEMb+Evi7QcGArc4hWVoHSGz/qyUUWLTpFJWjylb8wH1u4f+HZE6gVGACuf4w/5P/VhAIamHyweAbBVQ==} - 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==} + '@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-arm-gnueabihf@1.0.0-beta.46': - resolution: {integrity: sha512-BPWDxEnxb4JNMXrSmPuc5ywI6cHOELofmT0e/WGkbL1MwKYRVvqTf+gMcGLF6zAV+OF5hLYMAEk8XKfao6xmDQ==} - 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==} + '@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.45': - resolution: {integrity: sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA==} + '@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-linux-x64-musl@1.0.0-beta.46': - resolution: {integrity: sha512-vGUXKuHGUlG2XBwvN4A8KIegeaVVxN2ZxdGG9thycwRkzUvZ9ccKvqUVZM8cVRyNRWgVgsGCS18qLUefVplwKw==} - 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==} + '@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-wasm32-wasi@1.0.0-beta.46': - resolution: {integrity: sha512-peWDGp8YUAbTw5RJzr9AuPlTuf2adr+TBNIGF6ysMbobBKuQL41wYfGQlcerXJfLmjnQLf6DU2zTPBTfrS2Y8A==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': - resolution: {integrity: sha512-zyzAjItHPUmxg6Z8SyRhLdXlJn3/D9KL5b9mObUrBHhWS/GwRH4665xCiFqeuktAhhWutqfc+rOV2LjK4VYQGQ==} + '@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-arm64-msvc@1.0.0-beta.46': - resolution: {integrity: sha512-Ydbwg1JCnVbTAuDyKtu3dOuBLgZ6iZsy8p1jMPX/r7LMPnpXnS15GNcmMwa11nyl/M2VjGE1i/MORUTMt8mnRQ==} - 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==} - 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==} + '@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-x64-msvc@1.0.0-beta.45': - resolution: {integrity: sha512-wiU40G1nQo9rtfvF9jLbl79lUgjfaD/LTyUEw2Wg/gdF5OhjzpKMVugZQngO+RNdwYaNj+Fs+kWBWfp4VXPMHA==} + '@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/binding-win32-x64-msvc@1.0.0-beta.46': - resolution: {integrity: sha512-VPC+F9S6nllv02aGG+gxHRgpOaOlYBPn94kDe9DCFSLOztf4uYIAkN+tLDlg5OcsOC8XNR5rP49zOfI0PfnHYw==} - 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==} @@ -2620,68 +2577,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' @@ -2736,6 +2693,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==} @@ -2748,6 +2708,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==} @@ -2778,6 +2741,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==} @@ -3036,12 +3005,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: @@ -3055,6 +3027,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'} @@ -3080,8 +3055,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==} @@ -3133,6 +3108,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'} @@ -3164,6 +3147,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==} @@ -3226,6 +3213,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'} @@ -3249,6 +3240,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==} @@ -3258,6 +3252,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==} @@ -3270,9 +3267,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - dayjs@1.11.18: - resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} - dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} @@ -3324,8 +3318,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==} @@ -3395,6 +3390,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'} @@ -3419,15 +3417,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==} @@ -3492,19 +3494,33 @@ packages: err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + 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'} 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'} @@ -3631,6 +3647,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 @@ -3711,6 +3730,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==} @@ -3756,6 +3783,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==} @@ -3763,6 +3794,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'} @@ -3813,6 +3848,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==} @@ -3832,6 +3871,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==} @@ -4266,14 +4313,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'} @@ -4372,8 +4440,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: @@ -4448,6 +4516,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==} @@ -4726,6 +4801,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==} @@ -4763,6 +4842,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'} @@ -4828,13 +4911,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: @@ -4847,13 +4930,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 @@ -4918,6 +4996,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==} @@ -4928,6 +5022,13 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + signale@1.4.0: + 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==} @@ -5085,6 +5186,18 @@ 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'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -5211,43 +5324,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 @@ -5261,17 +5348,10 @@ packages: optional: true unplugin-unused: optional: true - unrun: - optional: true 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'} @@ -5310,8 +5390,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==} @@ -5333,6 +5413,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==} @@ -6665,225 +6755,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 @@ -6936,7 +6948,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.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 @@ -6950,9 +6962,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 @@ -6967,7 +6979,7 @@ snapshots: - sqlite3 - supports-color - '@h3ravel/arquebus@0.6.16(@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 @@ -6981,9 +6993,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 @@ -6998,9 +7010,17 @@ snapshots: - sqlite3 - supports-color - '@h3ravel/musket@0.3.11(@h3ravel/support@0.15.6)(@types/node@24.10.0)': + '@h3ravel/collect.js@5.3.3': {} + + '@h3ravel/contracts@0.28.1': dependencies: - '@h3ravel/shared': 0.27.7(@types/node@24.10.0) + h3: 2.0.1-rc.5 + transitivePeerDependencies: + - crossws + + '@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 chalk: 5.6.2 commander: 14.0.2 @@ -7015,9 +7035,26 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.3.11(@h3ravel/support@packages+support)(@types/node@24.10.0)': + '@h3ravel/musket@0.6.10(@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 + 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.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 chalk: 5.6.2 commander: 14.0.2 @@ -7032,9 +7069,9 @@ snapshots: - '@types/node' - crossws - '@h3ravel/musket@0.3.11(@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.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 @@ -7075,11 +7112,45 @@ 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 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': @@ -7420,6 +7491,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 @@ -7444,9 +7517,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': {} @@ -7485,97 +7562,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': + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.52': optional: true - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.52': optional: true - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.46': + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.52': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45': - 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: @@ -8251,51 +8282,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': {} @@ -8341,6 +8372,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': {} @@ -8349,9 +8382,11 @@ snapshots: '@types/luxon@3.7.1': {} + '@types/methods@1.1.4': {} + '@types/mute-stream@0.0.1': dependencies: - '@types/node': 20.19.24 + '@types/node': 24.10.0 '@types/node@12.20.55': {} @@ -8381,6 +8416,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': {} @@ -8717,9 +8764,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 @@ -8734,6 +8783,8 @@ snapshots: astring@1.9.0: {} + asynckit@0.4.0: {} + aws-ssl-profiles@1.1.2: {} balanced-match@1.0.2: {} @@ -8752,7 +8803,7 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 - birpc@2.7.0: {} + birpc@2.8.0: {} bl@4.1.0: dependencies: @@ -8830,6 +8881,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: {} @@ -8857,6 +8918,10 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + chownr@1.1.4: {} chownr@2.0.0: {} @@ -8902,6 +8967,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: {} @@ -8914,6 +8983,8 @@ snapshots: commondir@1.0.1: {} + component-emitter@1.3.1: {} + concat-map@0.0.1: {} console-control-strings@1.1.0: @@ -8921,6 +8992,8 @@ snapshots: cookie-es@2.0.0: {} + cookiejar@2.1.4: {} + create-require@1.1.1: {} cross-env@10.1.0: @@ -8934,8 +9007,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - dayjs@1.11.18: {} - dayjs@1.11.19: {} debug@4.3.4: @@ -8969,7 +9040,7 @@ snapshots: define-lazy-prop@3.0.0: {} - defu@6.1.4: {} + delayed-stream@1.0.0: {} delegates@1.0.0: optional: true @@ -9049,6 +9120,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: {} @@ -9065,7 +9141,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: {} @@ -9139,8 +9221,27 @@ snapshots: err-code@2.0.3: optional: true + error-ex@1.3.4: + 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 @@ -9167,35 +9268,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 @@ -9362,6 +9434,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fast-xml-parser@5.2.5: dependencies: strnum: 2.1.1 @@ -9446,6 +9520,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: @@ -9496,10 +9584,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 @@ -9565,6 +9671,8 @@ snapshots: dependencies: minimist: 1.2.8 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} graphemer@1.4.0: {} @@ -9576,6 +9684,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 @@ -9776,7 +9890,8 @@ snapshots: dependencies: '@isaacs/cliui': 8.0.2 - jiti@2.6.1: {} + jiti@2.6.1: + optional: true js-md4@0.3.2: {} @@ -9835,7 +9950,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 @@ -9852,7 +9967,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 @@ -10012,13 +10127,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: {} @@ -10107,7 +10234,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 @@ -10189,6 +10316,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 @@ -10460,6 +10591,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: {} @@ -10500,6 +10635,8 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.0.0: {} + rechoir@0.8.0: dependencies: resolve: 1.22.11 @@ -10554,81 +10691,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): - 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.45 - 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-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.46 + obug: 2.1.1 + rolldown: 1.0.0-beta.52 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver - - supports-color - rolldown@1.0.0-beta.45: + rolldown@1.0.0-beta.52: 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 + '@oxc-project/types': 0.99.0 + '@rolldown/pluginutils': 1.0.0-beta.52 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: @@ -10722,12 +10820,48 @@ 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: {} signal-exit@4.1.0: {} + signale@1.4.0: + dependencies: + chalk: 2.4.2 + 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: @@ -10887,6 +11021,31 @@ 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 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -10986,7 +11145,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 @@ -11004,7 +11163,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: @@ -11016,65 +11175,34 @@ 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 - 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) - 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 - - 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 + chokidar: 5.0.0 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) + 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 + - synckit - vue-tsc 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 @@ -11109,11 +11237,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: {} @@ -11134,6 +11260,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 15d9408c..b2fd0415 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 @@ -14,6 +15,10 @@ packages: - packages/console - packages/view - packages/url + - packages/session + - packages/validation + - packages/foundation + - packages/contracts - examples/* - docs @@ -44,16 +49,19 @@ 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 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 @@ -62,18 +70,21 @@ catalog: catalogs: prod: - '@h3ravel/arquebus': ^0.6.16 - '@h3ravel/musket': ^0.3.11 + '@h3ravel/arquebus': ^0.7.6 + '@h3ravel/collect.js': ^5.3.3 + '@h3ravel/musket': ^0.6.10 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/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 58ba146c..abe3acaf 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"], @@ -15,8 +16,14 @@ "@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/support/traits": ["packages/support/src/Traits/index.ts"], "@h3ravel/url": ["packages/url/src/index.ts"], - "@h3ravel/view": ["packages/view/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"], + "@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", diff --git a/tsdown.config.ts b/tsdown.config.ts index 149669d9..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' @@ -8,6 +9,7 @@ export const baseConfig: UserConfig = { dts: true, clean: true, shims: true, + unbundle: false, entry: ['src/index.ts'], format: ['esm', 'cjs'], sourcemap: false, @@ -31,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))) @@ -40,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')
+ + H3ravel Logo + +

H3ravel Events

+ +[![Framework][ix]][lx] +[![Events Package Version][i1]][l1] +[![Downloads][d1]][d1] +[![Tests][tei]][tel] +[![License][lini]][linl] + +