Переглянути джерело

Extract server package

Define dedicated package for HTTP server.
master
TheoryOfNekomata 6 місяці тому
джерело
коміт
4cdc74f597
35 змінених файлів з 911 додано та 72 видалено
  1. +0
    -0
      packages/core/LICENSE
  2. +0
    -2
      packages/core/package.json
  3. +2
    -2
      packages/core/src/backend/core.ts
  4. +0
    -3
      packages/core/src/backend/index.ts
  5. +10
    -0
      packages/core/src/common/media-type.ts
  6. +1
    -1
      packages/data-sources/duckdb/package.json
  7. +1
    -1
      packages/data-sources/file-jsonl/package.json
  8. +3
    -2
      packages/examples/cms-web-api/package.json
  9. +1
    -1
      packages/examples/cms-web-api/src/index.ts
  10. +3
    -2
      packages/examples/duckdb/package.json
  11. +3
    -2
      packages/examples/duckdb/src/index.ts
  12. +107
    -0
      packages/servers/http/.gitignore
  13. +7
    -0
      packages/servers/http/LICENSE
  14. +68
    -0
      packages/servers/http/package.json
  15. +3
    -0
      packages/servers/http/pridepack.json
  16. +7
    -8
      packages/servers/http/src/core.ts
  17. +3
    -3
      packages/servers/http/src/decorators/backend/content-negotiation.ts
  18. +2
    -2
      packages/servers/http/src/decorators/backend/index.ts
  19. +3
    -3
      packages/servers/http/src/decorators/backend/resource.ts
  20. +1
    -1
      packages/servers/http/src/decorators/method/index.ts
  21. +2
    -2
      packages/servers/http/src/decorators/url/base-path.ts
  22. +2
    -2
      packages/servers/http/src/decorators/url/host.ts
  23. +2
    -2
      packages/servers/http/src/decorators/url/index.ts
  24. +2
    -2
      packages/servers/http/src/decorators/url/scheme.ts
  25. +2
    -2
      packages/servers/http/src/handlers/default.ts
  26. +3
    -3
      packages/servers/http/src/handlers/resource.ts
  27. +0
    -0
      packages/servers/http/src/index.ts
  28. +2
    -2
      packages/servers/http/src/response.ts
  29. +1
    -1
      packages/servers/http/src/utils.ts
  30. +3
    -3
      packages/servers/http/test/features/decorators.test.ts
  31. +4
    -4
      packages/servers/http/test/handlers/default.test.ts
  32. +4
    -4
      packages/servers/http/test/handlers/error-handling.test.ts
  33. +480
    -0
      packages/servers/http/test/utils.ts
  34. +23
    -0
      packages/servers/http/tsconfig.json
  35. +156
    -12
      pnpm-lock.yaml

LICENSE → packages/core/LICENSE Переглянути файл


+ 0
- 2
packages/core/package.json Переглянути файл

@@ -13,7 +13,6 @@
"pridepack"
],
"devDependencies": {
"@types/negotiator": "^0.6.3",
"@types/node": "^20.11.30",
"pridepack": "2.6.0",
"tslib": "^2.6.2",
@@ -45,7 +44,6 @@
"access": "public"
},
"dependencies": {
"negotiator": "^0.6.3",
"tsx": "^4.7.1",
"valibot": "^0.30.0"
},


+ 2
- 2
packages/core/src/backend/core.ts Переглянути файл

@@ -43,12 +43,12 @@ export const createBackend = (params: CreateBackendParams) => {
backendState.checksSerializersOnDelete = b;
return this;
},
createServer(_type: string, _options = {}): Server {
createServer<T extends Server = Server>(_type: string, _options = {}) {
return {
requestDecorator() {
return this;
},
} satisfies Server;
} as unknown as T;
},
use(extender) {
return extender(backendState, this);


+ 0
- 3
packages/core/src/backend/index.ts Переглянути файл

@@ -1,6 +1,3 @@
export * from './core';
export * from './common';
export * from './data-source';

// where to put these? we should be publishing them as a separate entry point
export * as http from '../servers/http';

+ 10
- 0
packages/core/src/common/media-type.ts Переглянути файл

@@ -25,3 +25,13 @@ export type PatchContentType = typeof PATCH_CONTENT_TYPES[number];
export const getAcceptPostString = (mediaTypes: Map<string, MediaType>) => Array.from(mediaTypes.keys())
.filter((t) => !PATCH_CONTENT_TYPES.includes(t as PatchContentType))
.join(',');

export const isTextMediaType = (mediaType: string) => (
mediaType.startsWith('text/')
|| [
'application/json',
'application/xml',
'application/x-www-form-urlencoded',
...PATCH_CONTENT_TYPES,
].includes(mediaType)
);

+ 1
- 1
packages/data-sources/duckdb/package.json Переглянути файл

@@ -20,7 +20,7 @@
"vitest": "^1.2.0"
},
"dependencies": {
"@modal-sh/yasumi": "*",
"@modal-sh/yasumi": "workspace:*",
"duckdb-async": "^0.10.0"
},
"scripts": {


+ 1
- 1
packages/data-sources/file-jsonl/package.json Переглянути файл

@@ -30,7 +30,7 @@
"test": "vitest"
},
"dependencies": {
"@modal-sh/yasumi": "*"
"@modal-sh/yasumi": "workspace:*"
},
"private": false,
"description": "JSON lines file data source for yasumi.",


+ 3
- 2
packages/examples/cms-web-api/package.json Переглянути файл

@@ -19,8 +19,9 @@
"vitest": "^1.2.0"
},
"dependencies": {
"@modal-sh/yasumi": "*",
"@modal-sh/yasumi-data-source-file-jsonl": "*",
"@modal-sh/yasumi": "workspace:*",
"@modal-sh/yasumi-server-http": "workspace:*",
"@modal-sh/yasumi-data-source-file-jsonl": "workspace:*",
"tsx": "^4.7.1"
},
"scripts": {


+ 1
- 1
packages/examples/cms-web-api/src/index.ts Переглянути файл

@@ -1,5 +1,5 @@
import { application, resource, validation as v } from '@modal-sh/yasumi';
import { http } from '@modal-sh/yasumi/backend';
import * as http from '@modal-sh/yasumi-server-http';
import { randomUUID } from 'crypto';
import { JsonLinesDataSource } from '@modal-sh/yasumi-data-source-file-jsonl';
import { constants } from 'http2';


+ 3
- 2
packages/examples/duckdb/package.json Переглянути файл

@@ -19,8 +19,9 @@
"vitest": "^1.2.0"
},
"dependencies": {
"@modal-sh/yasumi": "*",
"@modal-sh/yasumi-data-source-duckdb": "*",
"@modal-sh/yasumi": "workspace:*",
"@modal-sh/yasumi-server-http": "workspace:*",
"@modal-sh/yasumi-data-source-duckdb": "workspace:*",
"tsx": "^4.7.1"
},
"scripts": {


+ 3
- 2
packages/examples/duckdb/src/index.ts Переглянути файл

@@ -1,5 +1,5 @@
import { resource, application, validation as v } from '@modal-sh/yasumi';
import { http } from '@modal-sh/yasumi/backend';
import * as http from '@modal-sh/yasumi-server-http';
import { DuckDbDataSource, AutoincrementIdConfig } from '@modal-sh/yasumi-data-source-duckdb';
import { constants } from 'http2';

@@ -29,10 +29,11 @@ const app = application({
const backend = app.createBackend({
dataSource: new DuckDbDataSource('test.db'),
})
.use(http.httpExtender)
.showTotalItemCountOnGetCollection()
.throwsErrorOnDeletingNotFound();

const server = backend.createHttpServer({
const server = backend.createServer('http', {
basePath: '/api',
})
.defaultErrorHandler((_req, res) => () => {


+ 107
- 0
packages/servers/http/.gitignore Переглянути файл

@@ -0,0 +1,107 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# TypeScript v1 declaration files
typings/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env
.env.production
.env.development

# parcel-bundler cache (https://parceljs.org/)
.cache

# Next.js build output
.next

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port

.npmrc

+ 7
- 0
packages/servers/http/LICENSE Переглянути файл

@@ -0,0 +1,7 @@
MIT License Copyright (c) 2024 TheoryOfNekomata <allan.crisostomo@outlook.com>

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 68
- 0
packages/servers/http/package.json Переглянути файл

@@ -0,0 +1,68 @@
{
"name": "@modal-sh/yasumi-server-http",
"version": "0.0.0",
"files": [
"dist",
"src"
],
"engines": {
"node": ">=16"
},
"license": "MIT",
"keywords": [
"pridepack"
],
"devDependencies": {
"@types/negotiator": "^0.6.3",
"@types/node": "^20.11.0",
"pridepack": "2.6.0",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vitest": "^1.2.0"
},
"dependencies": {
"@modal-sh/yasumi": "workspace:*",
"negotiator": "^0.6.3"
},
"scripts": {
"prepublishOnly": "pridepack clean && pridepack build",
"build": "pridepack build",
"type-check": "pridepack check",
"clean": "pridepack clean",
"watch": "pridepack watch",
"start": "pridepack start",
"dev": "pridepack dev",
"test": "vitest"
},
"private": false,
"description": "HTTP server for Yasumi backend.",
"repository": {
"url": "",
"type": "git"
},
"homepage": "",
"bugs": {
"url": ""
},
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>",
"publishConfig": {
"access": "public"
},
"types": "./dist/types/index.d.ts",
"main": "./dist/cjs/production/index.js",
"module": "./dist/esm/production/index.js",
"exports": {
".": {
"development": {
"require": "./dist/cjs/development/index.js",
"import": "./dist/esm/development/index.js"
},
"require": "./dist/cjs/production/index.js",
"import": "./dist/esm/production/index.js",
"types": "./dist/types/index.d.ts"
}
},
"typesVersions": {
"*": {}
}
}

+ 3
- 0
packages/servers/http/pridepack.json Переглянути файл

@@ -0,0 +1,3 @@
{
"target": "es2018"
}

packages/core/src/servers/http/core.ts → packages/servers/http/src/core.ts Переглянути файл

@@ -1,7 +1,6 @@
import http, { createServer as httpCreateServer } from 'http';
import { createServer as httpCreateSecureServer } from 'https';
import {constants,} from 'http2';
import * as v from 'valibot';
import EventEmitter from 'events';
import {
AllowedMiddlewareSpecification,
@@ -12,10 +11,8 @@ import {
RequestDecorator,
Response,
Server,
} from '../../backend/common';
import {
DataSource
} from '../../backend/data-source';
DataSource,
} from '@modal-sh/yasumi/backend';
import {
BaseResourceType,
CanPatchSpec,
@@ -26,7 +23,9 @@ import {
PATCH_CONTENT_MAP_TYPE, PATCH_CONTENT_TYPES,
PatchContentType, queryMediaTypes,
Resource,
} from '../../common';
validation as v,
isTextMediaType,
} from '@modal-sh/yasumi';
import {
handleGetRoot, handleOptions,
} from './handlers/default';
@@ -39,7 +38,7 @@ import {
handlePatchItem,
handleQueryCollection,
} from './handlers/resource';
import {getBody, isTextMediaType} from './utils';
import {getBody} from './utils';
import {decorateRequestWithBackend} from './decorators/backend';
import {decorateRequestWithMethod} from './decorators/method';
import {decorateRequestWithUrl} from './decorators/url';
@@ -63,7 +62,7 @@ export interface HttpServer extends Server {
defaultErrorHandler(errorHandler: ErrorHandler): this;
}

declare module '../../backend' {
declare module '@modal-sh/yasumi/backend' {
interface RequestContext extends http.IncomingMessage {
body?: unknown;
}

packages/core/src/servers/http/decorators/backend/content-negotiation.ts → packages/servers/http/src/decorators/backend/content-negotiation.ts Переглянути файл

@@ -1,8 +1,8 @@
import {ContentNegotiation} from '../../../../common';
import {RequestDecorator} from '../../../../backend/common';
import {ContentNegotiation} from '@modal-sh/yasumi';
import {RequestDecorator} from '@modal-sh/yasumi/backend';
import Negotiator from 'negotiator';

declare module '../../../../backend/common' {
declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
cn: Partial<ContentNegotiation>;
}

packages/core/src/servers/http/decorators/backend/index.ts → packages/servers/http/src/decorators/backend/index.ts Переглянути файл

@@ -1,8 +1,8 @@
import {BackendState, ParamRequestDecorator} from '../../../../backend/common';
import {BackendState, ParamRequestDecorator} from '@modal-sh/yasumi/backend';
import {decorateRequestWithContentNegotiation} from './content-negotiation';
import {decorateRequestWithResource} from './resource';

declare module '../../../../backend/common' {
declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
backend: BackendState;
}

packages/core/src/servers/http/decorators/backend/resource.ts → packages/servers/http/src/decorators/backend/resource.ts Переглянути файл

@@ -1,7 +1,7 @@
import {Resource} from '../../../../common';
import {RequestDecorator} from '../../../../backend/common';
import {Resource} from '@modal-sh/yasumi';
import {RequestDecorator} from '@modal-sh/yasumi/backend';

declare module '../../../../backend/common' {
declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
resource?: Resource;
resourceId?: string;

packages/core/src/servers/http/decorators/method/index.ts → packages/servers/http/src/decorators/method/index.ts Переглянути файл

@@ -1,4 +1,4 @@
import {RequestDecorator} from '../../../../backend/common';
import {RequestDecorator} from '@modal-sh/yasumi/backend';

const METHOD_SPOOF_HEADER_NAME = 'x-original-method' as const;
const METHOD_SPOOF_ORIGINAL_METHOD = 'POST' as const;

packages/core/src/servers/http/decorators/url/base-path.ts → packages/servers/http/src/decorators/url/base-path.ts Переглянути файл

@@ -1,6 +1,6 @@
import {ParamRequestDecorator} from '../../../../backend/common';
import {ParamRequestDecorator} from '@modal-sh/yasumi/backend';

declare module '../../../../backend/common' {
declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
basePath: string;
}

packages/core/src/servers/http/decorators/url/host.ts → packages/servers/http/src/decorators/url/host.ts Переглянути файл

@@ -1,6 +1,6 @@
import {ParamRequestDecorator} from '../../../../backend/common';
import {ParamRequestDecorator} from '@modal-sh/yasumi/backend';

declare module '../../../../backend/common' {
declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
host: string;
}

packages/core/src/servers/http/decorators/url/index.ts → packages/servers/http/src/decorators/url/index.ts Переглянути файл

@@ -1,10 +1,10 @@
import {ParamRequestDecorator} from '../../../../backend/common';
import {ParamRequestDecorator} from '@modal-sh/yasumi/backend';
import {CreateServerParams} from '../../core';
import {decorateRequestWithScheme} from './scheme';
import {decorateRequestWithHost} from './host';
import {decorateRequestWithBasePath} from './base-path';

declare module '../../../../backend/common' {
declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
rawUrl?: string;
query: URLSearchParams;

packages/core/src/servers/http/decorators/url/scheme.ts → packages/servers/http/src/decorators/url/scheme.ts Переглянути файл

@@ -1,6 +1,6 @@
import {ParamRequestDecorator} from '../../../../backend/common';
import {ParamRequestDecorator} from '@modal-sh/yasumi/backend';

declare module '../../../../backend/common' {
declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
scheme: string;
}

packages/core/src/servers/http/handlers/default.ts → packages/servers/http/src/handlers/default.ts Переглянути файл

@@ -1,8 +1,8 @@
import {constants} from 'http2';
import {AllowedMiddlewareSpecification, getAllowString, Middleware} from '../../../backend/common';
import {AllowedMiddlewareSpecification, getAllowString, Middleware} from '@modal-sh/yasumi/backend';
import {LinkMap} from '../utils';
import {PlainResponse, ErrorPlainResponse} from '../response';
import {getAcceptPatchString, getAcceptPostString} from '../../../common';
import {getAcceptPatchString, getAcceptPostString} from '@modal-sh/yasumi';

export const handleGetRoot: Middleware = (req, res) => {
const { backend, basePath } = req;

packages/core/src/servers/http/handlers/resource.ts → packages/servers/http/src/handlers/resource.ts Переглянути файл

@@ -1,14 +1,14 @@
import { constants } from 'http2';
import * as v from 'valibot';
import assert from 'assert';
import {Middleware} from '../../../backend/common';
import {Middleware} from '@modal-sh/yasumi/backend';
import {
applyDelta, Query,
Delta,
PATCH_CONTENT_MAP_TYPE,
PatchContentType,
queryMediaTypes,
} from '../../../common';
validation as v,
} from '@modal-sh/yasumi';
import {ErrorPlainResponse, PlainResponse} from '../response';

// TODO add handleQueryCollection()

packages/core/src/servers/http/index.ts → packages/servers/http/src/index.ts Переглянути файл


packages/core/src/servers/http/response.ts → packages/servers/http/src/response.ts Переглянути файл

@@ -1,5 +1,5 @@
import {Language, LanguageStatusMessageMap} from '../../common';
import {MiddlewareResponseError, Response} from '../../backend/common';
import {Language, LanguageStatusMessageMap} from '@modal-sh/yasumi';
import {MiddlewareResponseError, Response} from '@modal-sh/yasumi/backend';

interface PlainResponseParams<T = unknown, U extends NodeJS.EventEmitter = NodeJS.EventEmitter> extends Response {
body?: T;

packages/core/src/servers/http/utils.ts → packages/servers/http/src/utils.ts Переглянути файл

@@ -1,5 +1,5 @@
import {IncomingMessage} from 'http';
import {PATCH_CONTENT_TYPES} from '../../common';
import {PATCH_CONTENT_TYPES} from '@modal-sh/yasumi';

export const isTextMediaType = (mediaType: string) => (
mediaType.startsWith('text/')

packages/core/test/features/decorators.test.ts → packages/servers/http/test/features/decorators.test.ts Переглянути файл

@@ -1,8 +1,8 @@
import {describe, afterAll, beforeAll, it} from 'vitest';
import {Application, application, resource, Resource, validation as v} from '../../src/common';
import {Backend, DataSource, RequestContext} from '../../src/backend';
import {Application, application, resource, Resource, validation as v} from '@modal-sh/yasumi';
import {Backend, DataSource, RequestContext} from '@modal-sh/yasumi/backend';
import {httpExtender, HttpServer} from '../../src';
import {createTestClient, DummyDataSource, dummyGenerationStrategy, TEST_LANGUAGE, TestClient} from '../utils';
import {httpExtender, HttpServer} from '../../src/servers/http';

const PORT = 3001;
const HOST = '127.0.0.1';

packages/core/test/handlers/http/default.test.ts → packages/servers/http/test/handlers/default.test.ts Переглянути файл

@@ -9,16 +9,16 @@ import {
vi,
} from 'vitest';
import {constants} from 'http2';
import {Backend, DataSource} from '../../../src/backend';
import {Backend, DataSource} from '@modal-sh/yasumi/backend';
import {
application,
resource,
validation as v,
Resource,
Application,
} from '../../../src/common';
import {createTestClient, DummyDataSource, dummyGenerationStrategy, TEST_LANGUAGE, TestClient} from '../../utils';
import {httpExtender, HttpServer} from '../../../src/servers/http';
} from '@modal-sh/yasumi';
import {createTestClient, DummyDataSource, dummyGenerationStrategy, TEST_LANGUAGE, TestClient} from '../utils';
import {httpExtender, HttpServer} from '../../src';

const PORT = 3000;
const HOST = '127.0.0.1';

packages/core/test/handlers/http/error-handling.test.ts → packages/servers/http/test/handlers/error-handling.test.ts Переглянути файл

@@ -9,8 +9,8 @@ import {
vi,
} from 'vitest';
import {constants} from 'http2';
import {Backend, DataSource} from '../../../src/backend';
import {application, resource, validation as v, Resource, Application, Delta} from '../../../src/common';
import {Backend, DataSource} from '@modal-sh/yasumi/backend';
import {application, resource, validation as v, Resource, Application, Delta} from '@modal-sh/yasumi';
import {
createTestClient,
TestClient,
@@ -18,8 +18,8 @@ import {
DummyError,
TEST_LANGUAGE,
dummyGenerationStrategy,
} from '../../utils';
import {httpExtender, HttpServer} from '../../../src/servers/http';
} from '../utils';
import {httpExtender, HttpServer} from '../../src';

const PORT = 3001;
const HOST = '127.0.0.1';

+ 480
- 0
packages/servers/http/test/utils.ts Переглянути файл

@@ -0,0 +1,480 @@
import {IncomingHttpHeaders, IncomingMessage, OutgoingHttpHeaders, request, RequestOptions} from 'http';
import {Method, DataSource} from '@modal-sh/yasumi/backend';
import {FALLBACK_LANGUAGE, Language} from '@modal-sh/yasumi';

interface ClientParams {
method: Method;
path: string;
headers?: IncomingHttpHeaders;
body?: unknown;
}

type ResponseBody = Buffer | string | object;

export interface TestClient {
(params: ClientParams): Promise<[IncomingMessage, ResponseBody?]>;
acceptMediaType(mediaType: string): this;
acceptLanguage(language: string): this;
acceptCharset(charset: string): this;
contentType(mediaType: string): this;
contentCharset(charset: string): this;
}

export const createTestClient = (options: Omit<RequestOptions, 'method' | 'path'>): TestClient => {
const additionalHeaders: OutgoingHttpHeaders = {};
const client = (params: ClientParams) => new Promise<[IncomingMessage, ResponseBody?]>((resolve, reject) => {
const {
...etcAdditionalHeaders
} = additionalHeaders;

// odd that request() uses OutgoingHttpHeaders instead of IncomingHttpHeaders...
const headers: OutgoingHttpHeaders = {
...(options.headers ?? {}),
...etcAdditionalHeaders,
...(params.headers ?? {}),
};

let contentTypeHeader: string | undefined;
if (typeof params.body !== 'undefined') {
contentTypeHeader = headers['content-type'] = params.headers?.['content-type'] ?? 'application/json';
}

const req = request({
...options,
method: params.method,
path: params.path,
headers,
});

req.on('response', (res) => {
// if (req.method.toUpperCase() === 'QUERY') {
// res.statusMessage = '';
// res.statusCode = 200;
// }

res.on('error', (err) => {
reject(err);
});

let resBuffer: Buffer | undefined;
res.on('data', (c) => {
resBuffer = (
typeof resBuffer === 'undefined'
? Buffer.from(c)
: Buffer.concat([resBuffer, c])
);
});

res.on('close', () => {
const acceptHeader = Array.isArray(headers['accept']) ? headers['accept'].join('; ') : headers['accept'];
const contentTypeBase = acceptHeader ?? 'application/octet-stream';
const [type, subtype] = contentTypeBase.split('/');
const allSubtypes = subtype.split('+');
if (typeof resBuffer !== 'undefined') {
if (allSubtypes.includes('json')) {
const acceptCharset = (
Array.isArray(headers['accept-charset'])
? headers['accept-charset'].join('; ')
: headers['accept-charset']
) as BufferEncoding | undefined;
resolve([res, JSON.parse(resBuffer.toString(acceptCharset ?? 'utf-8'))]);
return;
}

if (type === 'text') {
const acceptCharset = (
Array.isArray(headers['accept-charset'])
? headers['accept-charset'].join('; ')
: headers['accept-charset']
) as BufferEncoding | undefined;
resolve([res, resBuffer.toString(acceptCharset ?? 'utf-8')]);
return;
}

resolve([res, resBuffer]);
return;
}

resolve([res]);
});
});

req.on('error', (err) => {
reject(err);
})

if (typeof params.body !== 'undefined') {
const theContentTypeHeader = Array.isArray(contentTypeHeader) ? contentTypeHeader.join('; ') : contentTypeHeader?.toString();
const contentTypeAll = theContentTypeHeader ?? 'application/octet-stream';
const [contentTypeBase, ...contentTypeParams] = contentTypeAll.split(';').map((s) => s.replace(/\s+/g, '').trim());
const charsetParam = contentTypeParams.find((s) => s.startsWith('charset='));
const charset = charsetParam?.split('=')?.[1] as BufferEncoding | undefined;
const [, subtype] = contentTypeBase.split('/');
const allSubtypes = subtype.split('+');
req.write(
allSubtypes.includes('json')
? JSON.stringify(params.body)
: Buffer.from(params.body?.toString() ?? '', contentTypeBase === 'text' ? charset : undefined)
);
}

req.end();
});

client.acceptMediaType = function acceptMediaType(mediaType: string) {
additionalHeaders['accept'] = mediaType;
return this;
};

client.acceptLanguage = function acceptLanguage(language: string) {
additionalHeaders['accept-language'] = language;
return this;
};

client.acceptCharset = function acceptCharset(charset: string) {
additionalHeaders['accept-charset'] = charset;
return this;
};

client.contentType = function contentType(mediaType: string) {
additionalHeaders['content-type'] = mediaType;
return this;
};

client.contentCharset = function contentCharset(charset: string) {
additionalHeaders['content-type'] = `${additionalHeaders['content-type']}; charset="${charset}"`;
return this;
};

return client;
};

export const dummyGenerationStrategy = () => Promise.resolve();

export class DummyError extends Error {}

export class DummyDataSource implements DataSource {
private resource?: { dataSource?: unknown };

async create(): Promise<object> {
return {};
}

async delete(): Promise<void> {}

async emplace(): Promise<[object, boolean]> {
return [{}, false];
}

async getById(): Promise<object> {
return {};
}

async newId(): Promise<string> {
return '';
}

async getMultiple(): Promise<object[]> {
return [];
}

async getSingle(): Promise<object> {
return {};
}

async getTotalCount(): Promise<number> {
return 0;
}

async initialize(): Promise<void> {}

async patch(): Promise<object> {
return {};
}

prepareResource(rr: unknown) {
this.resource = rr as unknown as { dataSource: DummyDataSource };
this.resource.dataSource = this;
}
}

export const TEST_LANGUAGE: Language = {
name: FALLBACK_LANGUAGE.name,
statusMessages: {
resourceCollectionQueried: '$Resource Collection Queried',
unableToSerializeResponse: 'Unable To Serialize Response',
unableToEncodeResponse: 'Unable To Encode Response',
unableToBindResourceDataSource: 'Unable To Bind $RESOURCE Data Source',
unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source',
unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection',
unableToFetchResource: 'Unable To Fetch $RESOURCE',
unableToDeleteResource: 'Unable To Delete $RESOURCE',
languageNotAcceptable: 'Language Not Acceptable',
characterSetNotAcceptable: 'Character Set Not Acceptable',
unableToDeserializeResource: 'Unable To Deserialize $RESOURCE',
unableToDecodeResource: 'Unable To Decode $RESOURCE',
mediaTypeNotAcceptable: 'Media Type Not Acceptable',
methodNotAllowed: 'Method Not Allowed',
urlNotFound: 'URL Not Found',
badRequest: 'Bad Request',
ok: 'OK',
provideOptions: 'Provide Options',
resourceCollectionFetched: '$RESOURCE Collection Fetched',
resourceFetched: '$RESOURCE Fetched',
resourceNotFound: '$RESOURCE Not Found',
deleteNonExistingResource: 'Delete Non-Existing $RESOURCE',
resourceDeleted: '$RESOURCE Deleted',
unableToDeserializeRequest: 'Unable To Deserialize Request',
patchNonExistingResource: 'Patch Non-Existing $RESOURCE',
unableToPatchResource: 'Unable To Patch $RESOURCE',
invalidResourcePatch: 'Invalid $RESOURCE Patch',
invalidResourcePatchType: 'Invalid $RESOURCE Patch Type',
invalidResource: 'Invalid $RESOURCE',
resourcePatched: '$RESOURCE Patched',
resourceCreated: '$RESOURCE Created',
resourceReplaced: '$RESOURCE Replaced',
unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source',
unableToAssignIdFromResourceDataSource: 'Unable To Assign ID From $RESOURCE Data Source',
unableToEmplaceResource: 'Unable To Emplace $RESOURCE',
resourceIdNotGiven: '$RESOURCE ID Not Given',
unableToCreateResource: 'Unable To Create $RESOURCE',
notImplemented: 'Not Implemented',
internalServerError: 'Internal Server Error',
},
bodies: {
badRequest: [
'An invalid request has been made.',
[
'Check if the request body has all the required attributes for this endpoint.',
'Check if the request body has only the valid attributes for this endpoint.',
'Check if the request body matches the schema for the resource associated with this endpoint.',
'Check if the request is appropriate for this endpoint.',
],
],
languageNotAcceptable: [
'The server could not process a response suitable for the client\'s provided language requirement.',
[
'Choose from the available languages on this service.',
'Contact the administrator to provide localization for the client\'s given requirements.',
],
],
characterSetNotAcceptable: [
'The server could not process a response suitable for the client\'s provided character set requirement.',
[
'Choose from the available character sets on this service.',
'Contact the administrator to provide localization for the client\'s given requirements.',
],
],
mediaTypeNotAcceptable: [
'The server could not process a response suitable for the client\'s provided media type requirement.',
[
'Choose from the available media types on this service.',
'Contact the administrator to provide localization for the client\'s given requirements.',
],
],
deleteNonExistingResource: [
'The client has attempted to delete a resource that does not exist.',
[
'Ensure that the resource still exists.',
'Ensure that the correct method is provided.',
],
],
internalServerError: [
'An unknown error has occurred within the service.',
[
'Try the request again at a later time.',
'Contact the administrator if the service remains in a degraded or non-functional state.',
],
],
invalidResource: [
'The request has an invalid structure or is missing some attributes.',
[
'Check if the request body has all the required attributes for this endpoint.',
'Check if the request body has only the valid attributes for this endpoint.',
'Check if the request body matches the schema for the resource associated with this endpoint.',
],
],
invalidResourcePatch: [
'The request has an invalid patch data.',
[
'Check if the appropriate patch type is specified on the request data.',
'Check if the request body has all the required attributes for this endpoint.',
'Check if the request body has only the valid attributes for this endpoint.',
'Check if the request body matches the schema for the resource associated with this endpoint.',
],
],
invalidResourcePatchType: [
'The request has an invalid or unsupported kind of patch data.',
[
'Check if the appropriate patch type is specified on the request data.',
'Check if the request body has all the required attributes for this endpoint.',
'Check if the request body has only the valid attributes for this endpoint.',
'Check if the request body matches the schema for the resource associated with this endpoint.',
],
],
methodNotAllowed: [
'A request with an invalid or unsupported method has been made.',
[
'Check if the request method is appropriate for this endpoint.',
'Check if the client is authorized to perform the method on this endpoint.',
]
],
notImplemented: [
'The service does not have any implementation for the accessed endpoint.',
[
'Try the request again at a later time.',
'Contact the administrator if the service remains in a degraded or non-functional state.',
],
],
patchNonExistingResource: [
'The client has attempted to patch a resource that does not exist.',
[
'Ensure that the resource still exists.',
'Ensure that the correct method is provided.',
],
],
resourceIdNotGiven: [
'The resource ID is not provided for the accessed endpoint.',
[
'Check if the resource ID is provided and valid in the URL.',
'Check if the request method is appropriate for this endpoint.',
],
],
unableToAssignIdFromResourceDataSource: [
'The resource could not be assigned an ID from the associated data source.',
[
'Try the request again at a later time.',
'Contact the administrator regarding missing configuration or unavailability of dependencies.',
],
],
unableToBindResourceDataSource: [
'The resource could not be associated from the data source.',
[
'Try the request again at a later time.',
'Contact the administrator regarding missing configuration or unavailability of dependencies.',
],
],
unableToCreateResource: [
'An error has occurred on creating the resource.',
[
'Check if the request method is appropriate for this endpoint.',
'Check if the request body has all the required attributes for this endpoint.',
'Check if the request body has only the valid attributes for this endpoint.',
'Check if the request body matches the schema for the resource associated with this endpoint.',
'Try the request again at a later time.',
'Contact the administrator regarding missing configuration or unavailability of dependencies.',
],
],
unableToDecodeResource: [
'The resource byte array could not be decoded for the provided character set.',
[
'Choose from the available character sets on this service.',
'Contact the administrator to provide localization for the client\'s given requirements.',
],
],
unableToDeleteResource: [
'An error has occurred on deleting the resource.',
[
'Check if the request method is appropriate for this endpoint.',
'Try the request again at a later time.',
'Contact the administrator regarding missing configuration or unavailability of dependencies.',
],
],
unableToDeserializeRequest: [
'The decoded request byte array could not be deserialized for the provided media type.',
[
'Choose from the available media types on this service.',
'Contact the administrator to provide localization for the client\'s given requirements.',
],
],
unableToDeserializeResource: [
'The decoded resource could not be deserialized for the provided media type.',
[
'Choose from the available media types on this service.',
'Contact the administrator to provide localization for the client\'s given requirements.',
],
],
unableToEmplaceResource: [
'An error has occurred on emplacing the resource.',
[
'Check if the request method is appropriate for this endpoint.',
'Check if the request body has all the required attributes for this endpoint.',
'Check if the request body has only the valid attributes for this endpoint.',
'Check if the request body matches the schema for the resource associated with this endpoint.',
'Try the request again at a later time.',
'Contact the administrator regarding missing configuration or unavailability of dependencies.',
],
],
unableToEncodeResponse: [
'The response data could not be encoded for the provided character set.',
[
'Choose from the available character sets on this service.',
'Contact the administrator to provide localization for the client\'s given requirements.',
],
],
unableToFetchResource: [
'An error has occurred on fetching the resource.',
[
'Check if the request method is appropriate for this endpoint.',
'Try the request again at a later time.',
'Contact the administrator regarding missing configuration or unavailability of dependencies.',
],
],
unableToFetchResourceCollection: [
'An error has occurred on fetching the resource collection.',
[
'Check if the request method is appropriate for this endpoint.',
'Try the request again at a later time.',
'Contact the administrator regarding missing configuration or unavailability of dependencies.',
],
],
unableToGenerateIdFromResourceDataSource: [
'The associated data source for the resource could not produce an ID.',
[
'Try the request again at a later time.',
'Contact the administrator regarding missing configuration or unavailability of dependencies.',
],
],
unableToInitializeResourceDataSource: [
'The associated data source for the resource could not be connected for usage.',
[
'Try the request again at a later time.',
'Contact the administrator regarding missing configuration or unavailability of dependencies.',
],
],
unableToPatchResource: [
'An error has occurred on patching the resource.',
[
'Check if the request method is appropriate for this endpoint.',
'Check if the request body has all the required attributes for this endpoint.',
'Check if the request body has only the valid attributes for this endpoint.',
'Check if the request body matches the schema for the resource associated with this endpoint.',
'Try the request again at a later time.',
'Contact the administrator regarding missing configuration or unavailability of dependencies.',
],
],
unableToSerializeResponse: [
'The response data could not be serialized for the provided media type.',
[
'Choose from the available media types on this service.',
'Contact the administrator to provide localization for the client\'s given requirements.',
],
],
urlNotFound: [
'An endpoint in the provided URL could not be found.',
[
'Check if the request URL is correct.',
'Try the request again at a later time.',
'Contact the administrator regarding missing configuration or unavailability of dependencies.',
],
],
resourceNotFound: [
'The resource in the provided URL could not be found.',
[
'Check if the request URL is correct.',
'Try the request again at a later time.',
'Contact the administrator regarding missing configuration or unavailability of dependencies.',
],
],
},
};

+ 23
- 0
packages/servers/http/tsconfig.json Переглянути файл

@@ -0,0 +1,23 @@
{
"exclude": ["node_modules"],
"include": ["src", "types"],
"compilerOptions": {
"module": "ESNext",
"lib": ["ESNext"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
"jsx": "react",
"esModuleInterop": true,
"target": "es2018",
"useDefineForClassFields": false,
"declarationMap": true
}
}

+ 156
- 12
pnpm-lock.yaml Переглянути файл

@@ -8,9 +8,6 @@ importers:

packages/core:
dependencies:
negotiator:
specifier: ^0.6.3
version: 0.6.3
tsx:
specifier: ^4.7.1
version: 4.7.2
@@ -18,9 +15,6 @@ importers:
specifier: ^0.30.0
version: 0.30.0
devDependencies:
'@types/negotiator':
specifier: ^0.6.3
version: 0.6.3
'@types/node':
specifier: ^20.11.30
version: 20.12.7
@@ -40,7 +34,7 @@ importers:
packages/data-sources/duckdb:
dependencies:
'@modal-sh/yasumi':
specifier: '*'
specifier: workspace:*
version: link:../../core
duckdb-async:
specifier: ^0.10.0
@@ -65,7 +59,7 @@ importers:
packages/data-sources/file-jsonl:
dependencies:
'@modal-sh/yasumi':
specifier: '*'
specifier: workspace:*
version: link:../../core
devDependencies:
'@types/node':
@@ -87,11 +81,14 @@ importers:
packages/examples/cms-web-api:
dependencies:
'@modal-sh/yasumi':
specifier: '*'
specifier: workspace:*
version: link:../../core
'@modal-sh/yasumi-data-source-file-jsonl':
specifier: '*'
specifier: workspace:*
version: link:../../data-sources/file-jsonl
'@modal-sh/yasumi-server-http':
specifier: workspace:*
version: link:../../servers/http
tsx:
specifier: ^4.7.1
version: 4.7.2
@@ -115,11 +112,14 @@ importers:
packages/examples/duckdb:
dependencies:
'@modal-sh/yasumi':
specifier: '*'
specifier: workspace:*
version: link:../../core
'@modal-sh/yasumi-data-source-duckdb':
specifier: '*'
specifier: workspace:*
version: link:../../data-sources/duckdb
'@modal-sh/yasumi-server-http':
specifier: workspace:*
version: link:../../servers/http
tsx:
specifier: ^4.7.1
version: 4.7.2
@@ -140,6 +140,34 @@ importers:
specifier: ^1.2.0
version: 1.5.0(@types/node@20.12.7)

packages/servers/http:
dependencies:
'@modal-sh/yasumi':
specifier: workspace:*
version: link:../../core
negotiator:
specifier: ^0.6.3
version: 0.6.3
devDependencies:
'@types/negotiator':
specifier: ^0.6.3
version: 0.6.3
'@types/node':
specifier: ^20.11.0
version: 20.12.7
pridepack:
specifier: 2.6.0
version: 2.6.0(tslib@2.6.2)(typescript@5.4.5)
tslib:
specifier: ^2.6.2
version: 2.6.2
typescript:
specifier: ^5.3.3
version: 5.4.5
vitest:
specifier: ^1.2.0
version: 1.4.0(@types/node@20.12.7)

packages:

/@esbuild/aix-ppc64@0.19.12:
@@ -739,6 +767,14 @@ packages:
undici-types: 5.26.5
dev: true

/@vitest/expect@1.4.0:
resolution: {integrity: sha512-Jths0sWCJZ8BxjKe+p+eKsoqev1/T8lYcrjavEaz8auEJ4jAVY0GwW3JKmdVU4mmNPLPHixh4GNXP7GFtAiDHA==}
dependencies:
'@vitest/spy': 1.4.0
'@vitest/utils': 1.4.0
chai: 4.4.1
dev: true

/@vitest/expect@1.5.0:
resolution: {integrity: sha512-0pzuCI6KYi2SIC3LQezmxujU9RK/vwC1U9R0rLuGlNGcOuDWxqWKu6nUdFsX9tH1WU0SXtAxToOsEjeUn1s3hA==}
dependencies:
@@ -747,6 +783,14 @@ packages:
chai: 4.4.1
dev: true

/@vitest/runner@1.4.0:
resolution: {integrity: sha512-EDYVSmesqlQ4RD2VvWo3hQgTJ7ZrFQ2VSJdfiJiArkCerDAGeyF1i6dHkmySqk573jLp6d/cfqCN+7wUB5tLgg==}
dependencies:
'@vitest/utils': 1.4.0
p-limit: 5.0.0
pathe: 1.1.2
dev: true

/@vitest/runner@1.5.0:
resolution: {integrity: sha512-7HWwdxXP5yDoe7DTpbif9l6ZmDwCzcSIK38kTSIt6CFEpMjX4EpCgT6wUmS0xTXqMI6E/ONmfgRKmaujpabjZQ==}
dependencies:
@@ -755,6 +799,14 @@ packages:
pathe: 1.1.2
dev: true

/@vitest/snapshot@1.4.0:
resolution: {integrity: sha512-saAFnt5pPIA5qDGxOHxJ/XxhMFKkUSBJmVt5VgDsAqPTX6JP326r5C/c9UuCMPoXNzuudTPsYDZCoJ5ilpqG2A==}
dependencies:
magic-string: 0.30.9
pathe: 1.1.2
pretty-format: 29.7.0
dev: true

/@vitest/snapshot@1.5.0:
resolution: {integrity: sha512-qpv3fSEuNrhAO3FpH6YYRdaECnnRjg9VxbhdtPwPRnzSfHVXnNzzrpX4cJxqiwgRMo7uRMWDFBlsBq4Cr+rO3A==}
dependencies:
@@ -763,12 +815,27 @@ packages:
pretty-format: 29.7.0
dev: true

/@vitest/spy@1.4.0:
resolution: {integrity: sha512-Ywau/Qs1DzM/8Uc+yA77CwSegizMlcgTJuYGAi0jujOteJOUf1ujunHThYo243KG9nAyWT3L9ifPYZ5+As/+6Q==}
dependencies:
tinyspy: 2.2.1
dev: true

/@vitest/spy@1.5.0:
resolution: {integrity: sha512-vu6vi6ew5N5MMHJjD5PoakMRKYdmIrNJmyfkhRpQt5d9Ewhw9nZ5Aqynbi3N61bvk9UvZ5UysMT6ayIrZ8GA9w==}
dependencies:
tinyspy: 2.2.1
dev: true

/@vitest/utils@1.4.0:
resolution: {integrity: sha512-mx3Yd1/6e2Vt/PUC98DcqTirtfxUyAZ32uK82r8rZzbtBeBo+nqgnjx/LvqQdWsrvNtm14VmurNgcf4nqY5gJg==}
dependencies:
diff-sequences: 29.6.3
estree-walker: 3.0.3
loupe: 2.3.7
pretty-format: 29.7.0
dev: true

/@vitest/utils@1.5.0:
resolution: {integrity: sha512-BDU0GNL8MWkRkSRdNFvCUCAVOeHaUlVJ9Tx0TYBZyXaaOTmGtUFObzchCivIBrIwKzvZA7A9sCejVhXM2aY98A==}
dependencies:
@@ -2305,6 +2372,27 @@ packages:
resolution: {integrity: sha512-5POBdbSkM+3nvJ6ZlyQHsggisfRtyT4tVTo1EIIShs6qCdXJnyWU5TJ68vr8iTg5zpOLjXLRiBqNx+9zwZz/rA==}
dev: false

/vite-node@1.4.0(@types/node@20.12.7):
resolution: {integrity: sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
dependencies:
cac: 6.7.14
debug: 4.3.4
pathe: 1.1.2
picocolors: 1.0.0
vite: 5.2.8(@types/node@20.12.7)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- stylus
- sugarss
- supports-color
- terser
dev: true

/vite-node@1.5.0(@types/node@20.12.7):
resolution: {integrity: sha512-tV8h6gMj6vPzVCa7l+VGq9lwoJjW8Y79vst8QZZGiuRAfijU+EEWuc0kFpmndQrWhMMhet1jdSF+40KSZUqIIw==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -2362,6 +2450,62 @@ packages:
fsevents: 2.3.3
dev: true

/vitest@1.4.0(@types/node@20.12.7):
resolution: {integrity: sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/node': ^18.0.0 || >=20.0.0
'@vitest/browser': 1.4.0
'@vitest/ui': 1.4.0
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
dependencies:
'@types/node': 20.12.7
'@vitest/expect': 1.4.0
'@vitest/runner': 1.4.0
'@vitest/snapshot': 1.4.0
'@vitest/spy': 1.4.0
'@vitest/utils': 1.4.0
acorn-walk: 8.3.2
chai: 4.4.1
debug: 4.3.4
execa: 8.0.1
local-pkg: 0.5.0
magic-string: 0.30.9
pathe: 1.1.2
picocolors: 1.0.0
std-env: 3.7.0
strip-literal: 2.1.0
tinybench: 2.7.0
tinypool: 0.8.4
vite: 5.2.8(@types/node@20.12.7)
vite-node: 1.4.0(@types/node@20.12.7)
why-is-node-running: 2.2.2
transitivePeerDependencies:
- less
- lightningcss
- sass
- stylus
- sugarss
- supports-color
- terser
dev: true

/vitest@1.5.0(@types/node@20.12.7):
resolution: {integrity: sha512-d8UKgR0m2kjdxDWX6911uwxout6GHS0XaGH1cksSIVVG8kRlE7G7aBw7myKQCvDI5dT4j7ZMa+l706BIORMDLw==}
engines: {node: ^18.0.0 || >=20.0.0}


Завантаження…
Відмінити
Зберегти