@c9up/ream 0.2.13 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +6 -1
- package/dist/Application.js.map +1 -1
- package/dist/GracefulShutdown.js +6 -5
- package/dist/GracefulShutdown.js.map +1 -1
- package/dist/Provider.d.ts +20 -2
- package/dist/Provider.d.ts.map +1 -1
- package/dist/Provider.js.map +1 -1
- package/dist/bodyparser/MultipartFile.d.ts +33 -7
- package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
- package/dist/bodyparser/MultipartFile.js +61 -12
- package/dist/bodyparser/MultipartFile.js.map +1 -1
- package/dist/bodyparser/parseSize.d.ts.map +1 -1
- package/dist/bodyparser/parseSize.js +3 -2
- package/dist/bodyparser/parseSize.js.map +1 -1
- package/dist/commands/MigrateBase.d.ts.map +1 -1
- package/dist/commands/MigrateBase.js +4 -1
- package/dist/commands/MigrateBase.js.map +1 -1
- package/dist/console/Kernel.d.ts.map +1 -1
- package/dist/console/Kernel.js +7 -0
- package/dist/console/Kernel.js.map +1 -1
- package/dist/console/types.d.ts +14 -0
- package/dist/console/types.d.ts.map +1 -1
- package/dist/console/types.js.map +1 -1
- package/dist/container/Container.d.ts +19 -3
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +89 -31
- package/dist/container/Container.js.map +1 -1
- package/dist/container/ContainerResolver.js.map +1 -1
- package/dist/container/types.d.ts +11 -1
- package/dist/container/types.d.ts.map +1 -1
- package/dist/decorators/Service.d.ts +1 -1
- package/dist/decorators/Service.d.ts.map +1 -1
- package/dist/env/Env.d.ts.map +1 -1
- package/dist/env/Env.js +4 -2
- package/dist/env/Env.js.map +1 -1
- package/dist/env/interpolate.d.ts.map +1 -1
- package/dist/env/interpolate.js +5 -3
- package/dist/env/interpolate.js.map +1 -1
- package/dist/errors/FuzzyMatcher.d.ts.map +1 -1
- package/dist/errors/FuzzyMatcher.js +30 -13
- package/dist/errors/FuzzyMatcher.js.map +1 -1
- package/dist/errors/PipelineStageError.d.ts.map +1 -1
- package/dist/errors/PipelineStageError.js +4 -2
- package/dist/errors/PipelineStageError.js.map +1 -1
- package/dist/events/Emitter.d.ts +21 -4
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +6 -2
- package/dist/events/Emitter.js.map +1 -1
- package/dist/events/testing/FakeBus.d.ts +1 -1
- package/dist/events/testing/FakeBus.d.ts.map +1 -1
- package/dist/events/testing/FakeBus.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts +2 -2
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +4 -2
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/helpers/bytes.d.ts.map +1 -1
- package/dist/helpers/bytes.js +13 -2
- package/dist/helpers/bytes.js.map +1 -1
- package/dist/helpers/string.js +3 -3
- package/dist/helpers/string.js.map +1 -1
- package/dist/http/Exception.d.ts +8 -4
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +14 -8
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +10 -7
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +4 -9
- package/dist/http/Response.js.map +1 -1
- package/dist/http/SseStream.d.ts.map +1 -1
- package/dist/http/SseStream.js +10 -1
- package/dist/http/SseStream.js.map +1 -1
- package/dist/http/etag.d.ts +44 -0
- package/dist/http/etag.d.ts.map +1 -0
- package/dist/http/etag.js +65 -0
- package/dist/http/etag.js.map +1 -0
- package/dist/http/mime.d.ts +33 -0
- package/dist/http/mime.d.ts.map +1 -0
- package/dist/http/mime.js +1347 -0
- package/dist/http/mime.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/native/generated.d.ts +12 -8
- package/dist/native/generated.d.ts.map +1 -1
- package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
- package/dist/openapi/OpenApiGenerator.js +6 -4
- package/dist/openapi/OpenApiGenerator.js.map +1 -1
- package/dist/router/Router.d.ts +10 -1
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +22 -10
- package/dist/router/Router.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts +16 -3
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +34 -3
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/Schedule.d.ts +6 -1
- package/dist/scheduler/Schedule.d.ts.map +1 -1
- package/dist/scheduler/Schedule.js +5 -0
- package/dist/scheduler/Schedule.js.map +1 -1
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +12 -5
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/security/SignedUrl.js +3 -2
- package/dist/security/SignedUrl.js.map +1 -1
- package/dist/session/drivers/DatabaseDriver.d.ts +13 -1
- package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
- package/dist/session/drivers/DatabaseDriver.js +23 -3
- package/dist/session/drivers/DatabaseDriver.js.map +1 -1
- package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/session/drivers/RedisDriver.js +10 -2
- package/dist/session/drivers/RedisDriver.js.map +1 -1
- package/dist/storage/StaticMiddleware.d.ts.map +1 -1
- package/dist/storage/StaticMiddleware.js +78 -59
- package/dist/storage/StaticMiddleware.js.map +1 -1
- package/dist/testing/ApiResponse.d.ts.map +1 -1
- package/dist/testing/ApiResponse.js +10 -11
- package/dist/testing/ApiResponse.js.map +1 -1
- package/dist/testing/TestClient.js +6 -6
- package/dist/testing/TestClient.js.map +1 -1
- package/dist/utils/Macroable.d.ts +6 -2
- package/dist/utils/Macroable.d.ts.map +1 -1
- package/dist/utils/Macroable.js +4 -0
- package/dist/utils/Macroable.js.map +1 -1
- package/dist/utils/objectPath.d.ts.map +1 -1
- package/dist/utils/objectPath.js +14 -6
- package/dist/utils/objectPath.js.map +1 -1
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +5 -7
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/scripts/build-napi-types.mjs +4 -3
- package/scripts/generate-mime.mjs +119 -0
- package/scripts/generate-napi-types.mjs +28 -12
- package/src/Application.ts +6 -1
- package/src/GracefulShutdown.ts +5 -5
- package/src/Provider.ts +21 -2
- package/src/bodyparser/MultipartFile.ts +94 -16
- package/src/bodyparser/parseSize.ts +3 -2
- package/src/commands/MigrateBase.ts +4 -1
- package/src/console/BaseCommand.ts +1 -1
- package/src/console/Kernel.ts +8 -0
- package/src/console/types.ts +15 -0
- package/src/container/Container.ts +120 -57
- package/src/container/ContainerResolver.ts +1 -1
- package/src/container/types.ts +11 -1
- package/src/decorators/Service.ts +1 -1
- package/src/env/Env.ts +4 -2
- package/src/env/interpolate.ts +5 -3
- package/src/errors/FuzzyMatcher.ts +34 -13
- package/src/errors/PipelineStageError.ts +4 -2
- package/src/events/Emitter.ts +34 -8
- package/src/events/testing/FakeBus.ts +12 -5
- package/src/graphql/GraphQLEngine.ts +11 -7
- package/src/helpers/bytes.ts +13 -2
- package/src/helpers/string.ts +3 -3
- package/src/http/Exception.ts +14 -8
- package/src/http/Request.ts +8 -5
- package/src/http/Response.ts +4 -8
- package/src/http/SseStream.ts +16 -7
- package/src/http/etag.ts +68 -0
- package/src/http/mime.ts +1347 -0
- package/src/index.ts +3 -3
- package/src/native/generated.ts +12 -8
- package/src/openapi/OpenApiGenerator.ts +6 -4
- package/src/router/Router.ts +31 -11
- package/src/rpc/RpcRouter.ts +38 -8
- package/src/scheduler/Schedule.ts +8 -1
- package/src/security/CookieSigner.ts +15 -5
- package/src/security/SignedUrl.ts +3 -2
- package/src/session/drivers/DatabaseDriver.ts +36 -8
- package/src/session/drivers/RedisDriver.ts +13 -5
- package/src/storage/StaticMiddleware.ts +81 -66
- package/src/testing/ApiResponse.ts +9 -10
- package/src/testing/TestClient.ts +6 -6
- package/src/utils/Macroable.ts +6 -2
- package/src/utils/objectPath.ts +12 -6
package/src/index.ts
CHANGED
|
@@ -101,7 +101,7 @@ export { default as SessionMiddleware } from './session/SessionMiddleware.js'
|
|
|
101
101
|
|
|
102
102
|
export type { BodyParserConfig } from './bodyparser/BodyParserMiddleware.js'
|
|
103
103
|
export { default as BodyParserMiddleware } from './bodyparser/BodyParserMiddleware.js'
|
|
104
|
-
export type { FileValidationOptions } from './bodyparser/MultipartFile.js'
|
|
104
|
+
export type { FileValidationOptions, MimeType } from './bodyparser/MultipartFile.js'
|
|
105
105
|
export { MultipartFile } from './bodyparser/MultipartFile.js'
|
|
106
106
|
|
|
107
107
|
// ─── JSON-RPC ───────────────────────────────────────────────
|
|
@@ -254,7 +254,7 @@ export { ConsoleKernel, defineConfig, Ignitor, prettyPrintError } from './Ignito
|
|
|
254
254
|
// ─── Providers ──────────────────────────────────────────────
|
|
255
255
|
|
|
256
256
|
export { errors } from './errors/aggregate.js'
|
|
257
|
-
export type { AppContext, ProviderContract } from './Provider.js'
|
|
257
|
+
export type { AppContext, ConfigReader, ProviderContract } from './Provider.js'
|
|
258
258
|
export { Provider } from './Provider.js'
|
|
259
259
|
export type {
|
|
260
260
|
AuthenticatorName,
|
|
@@ -378,4 +378,4 @@ export {
|
|
|
378
378
|
StatsTracker,
|
|
379
379
|
} from './scheduler/index.js'
|
|
380
380
|
|
|
381
|
-
export const VERSION = '0.2.
|
|
381
|
+
export const VERSION = '0.2.15'
|
package/src/native/generated.ts
CHANGED
|
@@ -87,8 +87,14 @@ export declare class HyperServer {
|
|
|
87
87
|
* the safe default for a dev machine.
|
|
88
88
|
*/
|
|
89
89
|
constructor(port?: number | undefined | null, host?: string | undefined | null)
|
|
90
|
-
/**
|
|
91
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Register the request handler.
|
|
92
|
+
*
|
|
93
|
+
* The request arrives as an OBJECT (`serde_json::Value` → `JsObject`) and
|
|
94
|
+
* the handler answers with a `NapiResponse` object — not the JSON strings
|
|
95
|
+
* this comment described until the boundary stopped carrying them.
|
|
96
|
+
*/
|
|
97
|
+
onRequest(callback: (request: Record<string, unknown>) => Promise<NapiResponse>): void
|
|
92
98
|
/**
|
|
93
99
|
* Configure the trusted-proxy CIDR list used by `request.ip` resolution.
|
|
94
100
|
* Must be called BEFORE `listen()`. Empty list = legacy permissive mode
|
|
@@ -155,12 +161,10 @@ export declare class HyperServer {
|
|
|
155
161
|
*/
|
|
156
162
|
writeStreamBytes(streamId: string, chunk: Uint8Array): Promise<boolean>
|
|
157
163
|
/**
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
* HTTP/1.1 chunked encoding). Idempotent.
|
|
163
|
-
*/
|
|
164
|
+
* Close a stream from the server side. The matching response body
|
|
165
|
+
* finishes cleanly (hyper writes the final chunk + `0\r\n\r\n` for
|
|
166
|
+
* HTTP/1.1 chunked encoding). Idempotent.
|
|
167
|
+
*/
|
|
164
168
|
closeStream(streamId: string): Promise<boolean>
|
|
165
169
|
/**
|
|
166
170
|
* Install a one-shot callback fired the moment the matching stream
|
|
@@ -170,9 +170,9 @@ export class OpenApiGenerator {
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
if (route.validators.length > 0 && ['post', 'put', 'patch'].includes(method)) {
|
|
173
|
-
const validatorName = route.validators
|
|
174
|
-
const schema = this.#runeSchemas.get(validatorName)
|
|
175
|
-
if (schema) {
|
|
173
|
+
const [validatorName] = route.validators
|
|
174
|
+
const schema = validatorName === undefined ? undefined : this.#runeSchemas.get(validatorName)
|
|
175
|
+
if (schema && validatorName !== undefined) {
|
|
176
176
|
operation.requestBody = {
|
|
177
177
|
required: true,
|
|
178
178
|
content: { 'application/json': { schema: this.#runeToJsonSchema(schema) } },
|
|
@@ -197,7 +197,9 @@ export class OpenApiGenerator {
|
|
|
197
197
|
/** Extract parameter names from path. */
|
|
198
198
|
#extractPathParams(path: string): string[] {
|
|
199
199
|
const matches = path.matchAll(/:(\w+)\??/g)
|
|
200
|
-
|
|
200
|
+
// Group 1 is required by the pattern, so every match carries it; the
|
|
201
|
+
// filter is how that is stated instead of asserted.
|
|
202
|
+
return [...matches].map((m) => m[1]).filter((name) => name !== undefined)
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
/** Generate a summary from route metadata. */
|
package/src/router/Router.ts
CHANGED
|
@@ -25,7 +25,16 @@ export interface SignedUrlOptions {
|
|
|
25
25
|
|
|
26
26
|
// ─── Types ──────────────────────────────────────────────────
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* A route handler. Whatever it returns is awaited and dropped — the response
|
|
30
|
+
* is written through `ctx.response`.
|
|
31
|
+
*
|
|
32
|
+
* `unknown`, not `Promise<void> | void`: a bare `void` return type accepts a
|
|
33
|
+
* function returning anything, but a UNION containing it does not, so
|
|
34
|
+
* `router.get('/x', () => 'ok')` was a type error for returning a value the
|
|
35
|
+
* kernel already ignores.
|
|
36
|
+
*/
|
|
37
|
+
export type RouteHandlerFunction = (ctx: HttpContext) => unknown
|
|
29
38
|
|
|
30
39
|
/**
|
|
31
40
|
* Controller tuple: [ControllerClass, 'methodName'].
|
|
@@ -267,7 +276,13 @@ export class RouteBuilder extends Macroable {
|
|
|
267
276
|
|
|
268
277
|
/** The primary definition — accessors read from it (all verbs share pattern/name). */
|
|
269
278
|
#primary(): RouteDefinition {
|
|
270
|
-
|
|
279
|
+
const [first] = this.#routes
|
|
280
|
+
if (first === undefined) {
|
|
281
|
+
// A route is constructed with at least one verb, so this cannot happen —
|
|
282
|
+
// and this is where that is stated rather than asserted past.
|
|
283
|
+
throw new Error('Route has no definitions')
|
|
284
|
+
}
|
|
285
|
+
return first
|
|
271
286
|
}
|
|
272
287
|
|
|
273
288
|
/** Name this route (for URL generation and redirects). */
|
|
@@ -1557,9 +1572,13 @@ function matchPath(pattern: string, actual: string): MatchedParams | null {
|
|
|
1557
1572
|
const params: MatchedParams = {}
|
|
1558
1573
|
for (let i = 0; i < wildcardIdx; i++) {
|
|
1559
1574
|
const part = patternParts[i]
|
|
1575
|
+
const actual = actualParts[i]
|
|
1576
|
+
// Both are read out of arrays whose length was checked above; naming
|
|
1577
|
+
// them is what turns that check into something the compiler shares.
|
|
1578
|
+
if (part === undefined || actual === undefined) return null
|
|
1560
1579
|
if (part.startsWith(':')) {
|
|
1561
|
-
params[part.substring(1)] = safeDecodeURIComponent(
|
|
1562
|
-
} else if (part !==
|
|
1580
|
+
params[part.substring(1)] = safeDecodeURIComponent(actual)
|
|
1581
|
+
} else if (part !== actual) {
|
|
1563
1582
|
return null
|
|
1564
1583
|
}
|
|
1565
1584
|
}
|
|
@@ -1579,22 +1598,22 @@ function matchPath(pattern: string, actual: string): MatchedParams | null {
|
|
|
1579
1598
|
|
|
1580
1599
|
const params: MatchedParams = {}
|
|
1581
1600
|
|
|
1582
|
-
for (
|
|
1583
|
-
const part = patternParts[i]
|
|
1601
|
+
for (const [i, part] of patternParts.entries()) {
|
|
1584
1602
|
const isOptional = part.endsWith('?')
|
|
1603
|
+
const actual = actualParts[i]
|
|
1585
1604
|
|
|
1586
1605
|
if (part.startsWith(':')) {
|
|
1587
1606
|
const paramName = isOptional ? part.slice(1, -1) : part.substring(1)
|
|
1588
|
-
if (
|
|
1607
|
+
if (actual !== undefined) {
|
|
1589
1608
|
// Percent-decode the captured segment (query strings already are) so
|
|
1590
1609
|
// `/users/Jos%C3%A9` yields `José` and matchers test the real value.
|
|
1591
1610
|
// safeDecode falls back to the raw segment on malformed input.
|
|
1592
|
-
params[paramName] = safeDecodeURIComponent(
|
|
1611
|
+
params[paramName] = safeDecodeURIComponent(actual)
|
|
1593
1612
|
}
|
|
1594
1613
|
// Optional param with no actual part — skip (param not set)
|
|
1595
|
-
} else if (
|
|
1614
|
+
} else if (actual !== undefined && part !== actual) {
|
|
1596
1615
|
return null
|
|
1597
|
-
} else if (
|
|
1616
|
+
} else if (actual === undefined && !isOptional) {
|
|
1598
1617
|
return null
|
|
1599
1618
|
}
|
|
1600
1619
|
}
|
|
@@ -1612,7 +1631,8 @@ function matchPath(pattern: string, actual: string): MatchedParams | null {
|
|
|
1612
1631
|
* Comparing them verbatim sends that request to a fallback, or to a 404.
|
|
1613
1632
|
*/
|
|
1614
1633
|
function matchDomain(pattern: string, host: string): boolean {
|
|
1615
|
-
const
|
|
1634
|
+
const [hostname = ''] = host.split(':') // strip port
|
|
1635
|
+
const actualHost = hostname.toLowerCase()
|
|
1616
1636
|
const wanted = pattern.toLowerCase()
|
|
1617
1637
|
if (wanted === actualHost) return true
|
|
1618
1638
|
if (wanted.startsWith('*.')) {
|
package/src/rpc/RpcRouter.ts
CHANGED
|
@@ -30,7 +30,7 @@ export type RpcHandler = (ctx: HttpContext, params: unknown) => Promise<unknown>
|
|
|
30
30
|
* the shared `'middleware'` registry, and `validator:<name>` schemas.
|
|
31
31
|
*/
|
|
32
32
|
interface RpcContainer {
|
|
33
|
-
make<T>(target: new (...args:
|
|
33
|
+
make<T>(target: abstract new (...args: never[]) => T): Promise<T>
|
|
34
34
|
resolve<T>(token: string): Promise<T>
|
|
35
35
|
has(token: string): boolean
|
|
36
36
|
}
|
|
@@ -151,11 +151,21 @@ export class RpcRouter {
|
|
|
151
151
|
return new RpcMethodBuilder(def)
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Register methods from a controller — auto-registers all public methods.
|
|
156
|
+
*
|
|
157
|
+
* The parameter used to demand
|
|
158
|
+
* `new (...args: unknown[]) => Record<string, (...args: unknown[]) => unknown>`,
|
|
159
|
+
* which NO real controller satisfies: a class with any non-method property
|
|
160
|
+
* fails the `Record`, and a method declared `bump(): number` fails the
|
|
161
|
+
* `unknown[]` parameters, which are compared contravariantly. So the
|
|
162
|
+
* documented call — `rpc.namespace('task', TaskController)` — did not
|
|
163
|
+
* typecheck, and nothing noticed because the tests were outside `tsc`.
|
|
164
|
+
*
|
|
165
|
+
* Any class, then. The body already checks that what it found is callable
|
|
166
|
+
* and names the method when it is not.
|
|
167
|
+
*/
|
|
168
|
+
namespace(prefix: string, controller: abstract new (...args: never[]) => object): void {
|
|
159
169
|
const proto = controller.prototype
|
|
160
170
|
const methods = Object.getOwnPropertyNames(proto).filter(
|
|
161
171
|
(m) => m !== 'constructor' && typeof proto[m] === 'function',
|
|
@@ -164,8 +174,18 @@ export class RpcRouter {
|
|
|
164
174
|
this.method(`${prefix}.${methodName}`, async (ctx, params) => {
|
|
165
175
|
// Resolve through the container on every call (fresh DI per request,
|
|
166
176
|
// like GraphQLEngine), falling back to a bare `new` when unset.
|
|
167
|
-
|
|
168
|
-
|
|
177
|
+
// `Reflect.construct`, not `new controller()`: the token may be an
|
|
178
|
+
// abstract class, which the runtime builds happily and `new` refuses.
|
|
179
|
+
const instance: object = this.#container
|
|
180
|
+
? await this.#container.make(controller)
|
|
181
|
+
: Reflect.construct(controller, [])
|
|
182
|
+
const handler = Reflect.get(instance, methodName)
|
|
183
|
+
if (typeof handler !== 'function') {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`RPC method '${prefix}.${methodName}' is not a function on ${controller.name}`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
return handler.call(instance, ctx, params)
|
|
169
189
|
})
|
|
170
190
|
}
|
|
171
191
|
}
|
|
@@ -199,6 +219,16 @@ export class RpcRouter {
|
|
|
199
219
|
|
|
200
220
|
// Batch support (max 50 to prevent DoS)
|
|
201
221
|
if (Array.isArray(body)) {
|
|
222
|
+
// §6: "If the batch rpc call itself fails to be recognized as an valid
|
|
223
|
+
// JSON or as an Array with at least one value, the response from the
|
|
224
|
+
// Server MUST be a single Response object." An empty batch answered with
|
|
225
|
+
// 204 No Content, which is what this replies when every call in a batch
|
|
226
|
+
// was a notification — so a client that sent nothing was told its calls
|
|
227
|
+
// had run.
|
|
228
|
+
if (body.length === 0) {
|
|
229
|
+
ctx.response.status(200).json(rpcError(-32600, 'Invalid Request', null))
|
|
230
|
+
return
|
|
231
|
+
}
|
|
202
232
|
if (body.length > 50) {
|
|
203
233
|
ctx.response.status(400).json(rpcError(-32600, 'Batch too large (max 50)', null))
|
|
204
234
|
return
|
|
@@ -90,7 +90,14 @@ export function Schedule(cronExpr: string): MethodDecorator {
|
|
|
90
90
|
* none). Returns a shallow copy so callers cannot mutate the stored
|
|
91
91
|
* metadata by accident.
|
|
92
92
|
*/
|
|
93
|
-
|
|
93
|
+
/**
|
|
94
|
+
* `abstract new`, not `Constructor`: reading metadata off a class never
|
|
95
|
+
* constructs it, and the service registry holds abstract ones too since a
|
|
96
|
+
* contextual binding binds against an abstract base.
|
|
97
|
+
*/
|
|
98
|
+
export function getScheduleMetadata(
|
|
99
|
+
target: abstract new (...args: never[]) => unknown,
|
|
100
|
+
): ScheduleMetadata[] {
|
|
94
101
|
const stored: ScheduleMetadata[] = Reflect.getOwnMetadata(SCHEDULE_METADATA_KEY, target) ?? []
|
|
95
102
|
return [...stored]
|
|
96
103
|
}
|
|
@@ -225,12 +225,22 @@ export class CookieSigner {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
decrypt<T = string>(encrypted: string, purpose?: string): T | null {
|
|
228
|
-
|
|
229
|
-
|
|
228
|
+
// Destructured rather than indexed, and `extra` is what keeps the old
|
|
229
|
+
// length check honest: three names bind on a four-part string too, so the
|
|
230
|
+
// fourth has to be looked at to reject it.
|
|
231
|
+
const [ivPart, dataPart, tagPart, extra] = encrypted.split('.')
|
|
232
|
+
if (
|
|
233
|
+
ivPart === undefined ||
|
|
234
|
+
dataPart === undefined ||
|
|
235
|
+
tagPart === undefined ||
|
|
236
|
+
extra !== undefined
|
|
237
|
+
) {
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
230
240
|
try {
|
|
231
|
-
const iv = Buffer.from(
|
|
232
|
-
const data = Buffer.from(
|
|
233
|
-
const tag = Buffer.from(
|
|
241
|
+
const iv = Buffer.from(ivPart, 'base64url')
|
|
242
|
+
const data = Buffer.from(dataPart, 'base64url')
|
|
243
|
+
const tag = Buffer.from(tagPart, 'base64url')
|
|
234
244
|
const decipher = createDecipheriv('aes-256-gcm', this.#keyBuffer, iv)
|
|
235
245
|
decipher.setAuthTag(tag)
|
|
236
246
|
const raw = Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
|
|
@@ -55,8 +55,9 @@ function parseExpiry(value: string | number): number {
|
|
|
55
55
|
if (typeof value === 'number') return value
|
|
56
56
|
const match = value.match(/^(\d+)(s|m|h|d)$/)
|
|
57
57
|
if (!match) return 3600
|
|
58
|
-
const
|
|
59
|
-
|
|
58
|
+
const [, digits, unit] = match
|
|
59
|
+
const num = parseInt(digits ?? '0', 10)
|
|
60
|
+
switch (unit) {
|
|
60
61
|
case 's':
|
|
61
62
|
return num
|
|
62
63
|
case 'm':
|
|
@@ -8,10 +8,28 @@ import type { SessionDriverWithTagging, TaggedSession } from '../Session.js'
|
|
|
8
8
|
* app's own wrapper.
|
|
9
9
|
*/
|
|
10
10
|
export interface SessionDbConnection {
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Rows, as rows: `Record<string, unknown>`, not a shape the caller names.
|
|
13
|
+
*
|
|
14
|
+
* `query<T>` let this driver ASSERT what a SELECT returned — a promise the
|
|
15
|
+
* database never made and the connection cannot keep. It also made the
|
|
16
|
+
* interface unimplementable by anything but a real driver: a generic method
|
|
17
|
+
* must work for every `T`, so no fake could satisfy it, and this file's tests
|
|
18
|
+
* sat outside the typecheck because of it.
|
|
19
|
+
*
|
|
20
|
+
* The columns are read through {@link textColumn} below instead, which is
|
|
21
|
+
* what a row from a database deserves.
|
|
22
|
+
*/
|
|
23
|
+
query(sql: string, params?: unknown[]): Promise<Array<Record<string, unknown>>>
|
|
12
24
|
execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }>
|
|
13
25
|
}
|
|
14
26
|
|
|
27
|
+
/** A column that must be text to be usable, or `null`. */
|
|
28
|
+
function textColumn(row: Record<string, unknown>, name: string): string | null {
|
|
29
|
+
const value = row[name]
|
|
30
|
+
return typeof value === 'string' ? value : null
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
export interface DatabaseDriverOptions {
|
|
16
34
|
connection: SessionDbConnection
|
|
17
35
|
/** Table holding the sessions. Default `sessions`. */
|
|
@@ -71,17 +89,20 @@ export class DatabaseDriver implements SessionDriverWithTagging {
|
|
|
71
89
|
}
|
|
72
90
|
|
|
73
91
|
async read(sessionId: string): Promise<Record<string, unknown> | null> {
|
|
74
|
-
const rows = await this.#db.query
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
)
|
|
92
|
+
const rows = await this.#db.query(`SELECT data, expires_at FROM ${this.#table} WHERE id = ?`, [
|
|
93
|
+
sessionId,
|
|
94
|
+
])
|
|
78
95
|
const row = rows[0]
|
|
79
96
|
if (!row) return null
|
|
80
97
|
if (Number(row.expires_at) < Date.now()) {
|
|
81
98
|
await this.destroy(sessionId)
|
|
82
99
|
return null
|
|
83
100
|
}
|
|
84
|
-
|
|
101
|
+
const data = textColumn(row, 'data')
|
|
102
|
+
// A row whose payload is not text is not a session — the same outcome as a
|
|
103
|
+
// row that will not parse.
|
|
104
|
+
if (data === null) return null
|
|
105
|
+
return parseData(data)
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
async write(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
|
|
@@ -157,11 +178,18 @@ export class DatabaseDriver implements SessionDriverWithTagging {
|
|
|
157
178
|
|
|
158
179
|
/** Every non-expired session this user holds — their active devices. */
|
|
159
180
|
async tagged(userId: string | number): Promise<TaggedSession[]> {
|
|
160
|
-
const rows = await this.#db.query
|
|
181
|
+
const rows = await this.#db.query(
|
|
161
182
|
`SELECT id, data FROM ${this.#table} WHERE user_id = ? AND expires_at >= ?`,
|
|
162
183
|
[String(userId), Date.now()],
|
|
163
184
|
)
|
|
164
|
-
|
|
185
|
+
const sessions: TaggedSession[] = []
|
|
186
|
+
for (const row of rows) {
|
|
187
|
+
const id = textColumn(row, 'id')
|
|
188
|
+
const data = textColumn(row, 'data')
|
|
189
|
+
if (id === null || data === null) continue
|
|
190
|
+
sessions.push({ id, data: parseData(data) })
|
|
191
|
+
}
|
|
192
|
+
return sessions
|
|
165
193
|
}
|
|
166
194
|
|
|
167
195
|
/** Delete every expired row — for a scheduled job, since nothing else does. */
|
|
@@ -67,11 +67,19 @@ export class RedisDriver implements SessionDriverWithTagging {
|
|
|
67
67
|
}
|
|
68
68
|
if (!this.#pending) {
|
|
69
69
|
const resolver = this.#source
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
// `finally`, not the success path: clearing it only on success left a
|
|
71
|
+
// REJECTED promise in the slot, and every later call returned that same
|
|
72
|
+
// rejection. One refused connection at boot — Redis still starting, a
|
|
73
|
+
// network blip — and the driver never tried again for the life of the
|
|
74
|
+
// process. Bay and echo already clear it this way.
|
|
75
|
+
this.#pending = Promise.resolve(resolver())
|
|
76
|
+
.then((client) => {
|
|
77
|
+
this.#resolved = client
|
|
78
|
+
return client
|
|
79
|
+
})
|
|
80
|
+
.finally(() => {
|
|
81
|
+
this.#pending = undefined
|
|
82
|
+
})
|
|
75
83
|
}
|
|
76
84
|
return this.#pending
|
|
77
85
|
}
|
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
* @implements MISS-25
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { createHash } from 'node:crypto'
|
|
13
12
|
import * as fs from 'node:fs'
|
|
13
|
+
import type { FileHandle } from 'node:fs/promises'
|
|
14
|
+
import * as fsp from 'node:fs/promises'
|
|
14
15
|
import * as path from 'node:path'
|
|
16
|
+
import { matchesIfNoneMatch, statTag } from '../http/etag.js'
|
|
15
17
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
18
|
+
import { contentType } from '../http/mime.js'
|
|
16
19
|
|
|
17
20
|
export interface StaticConfig {
|
|
18
21
|
/** Root directory to serve files from. */
|
|
@@ -51,32 +54,6 @@ const DEFAULT_EXTENSIONS = [
|
|
|
51
54
|
'.zip',
|
|
52
55
|
]
|
|
53
56
|
|
|
54
|
-
const MIME_TYPES: Record<string, string> = {
|
|
55
|
-
'.html': 'text/html',
|
|
56
|
-
'.css': 'text/css',
|
|
57
|
-
'.js': 'application/javascript',
|
|
58
|
-
'.json': 'application/json',
|
|
59
|
-
'.xml': 'application/xml',
|
|
60
|
-
'.txt': 'text/plain',
|
|
61
|
-
'.png': 'image/png',
|
|
62
|
-
'.jpg': 'image/jpeg',
|
|
63
|
-
'.jpeg': 'image/jpeg',
|
|
64
|
-
'.gif': 'image/gif',
|
|
65
|
-
'.svg': 'image/svg+xml',
|
|
66
|
-
'.ico': 'image/x-icon',
|
|
67
|
-
'.webp': 'image/webp',
|
|
68
|
-
'.woff': 'font/woff',
|
|
69
|
-
'.woff2': 'font/woff2',
|
|
70
|
-
'.ttf': 'font/ttf',
|
|
71
|
-
'.eot': 'application/vnd.ms-fontobject',
|
|
72
|
-
'.mp4': 'video/mp4',
|
|
73
|
-
'.webm': 'video/webm',
|
|
74
|
-
'.mp3': 'audio/mpeg',
|
|
75
|
-
'.ogg': 'audio/ogg',
|
|
76
|
-
'.pdf': 'application/pdf',
|
|
77
|
-
'.zip': 'application/zip',
|
|
78
|
-
}
|
|
79
|
-
|
|
80
57
|
export class StaticMiddleware {
|
|
81
58
|
#root: string
|
|
82
59
|
#prefix: string
|
|
@@ -95,46 +72,65 @@ export class StaticMiddleware {
|
|
|
95
72
|
return next()
|
|
96
73
|
}
|
|
97
74
|
|
|
98
|
-
const resolved = this.#
|
|
75
|
+
const resolved = await this.#openServableFile(ctx.request.path())
|
|
99
76
|
if (!resolved) return next()
|
|
100
|
-
const {
|
|
101
|
-
|
|
102
|
-
const etag = createHash('md5').update(`${stat.size}-${stat.mtimeMs}`).digest('hex')
|
|
103
|
-
|
|
104
|
-
// ETag check — 304 Not Modified
|
|
105
|
-
const ifNoneMatch = ctx.request.header('if-none-match')
|
|
106
|
-
if (ifNoneMatch === etag) {
|
|
107
|
-
ctx.response.status(304)
|
|
108
|
-
return
|
|
109
|
-
}
|
|
77
|
+
const { handle, ext, stat } = resolved
|
|
110
78
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
79
|
+
// The handle is ours until the read stream takes it; every path that does
|
|
80
|
+
// not hand it over closes it, or the process leaks a descriptor per 304.
|
|
81
|
+
let owned = true
|
|
82
|
+
try {
|
|
83
|
+
const etag = statTag(stat)
|
|
84
|
+
|
|
85
|
+
if (matchesIfNoneMatch(ctx.request.header('if-none-match'), etag)) {
|
|
86
|
+
ctx.response.status(304)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// One table for the whole package (`src/http/mime.ts`), generated from
|
|
91
|
+
// mime-db. A second hand-written one here is how `.js` came to be
|
|
92
|
+
// served as `application/javascript` from one code path and
|
|
93
|
+
// `text/javascript` from the other.
|
|
94
|
+
ctx.response.header('Content-Type', contentType(ext) || 'application/octet-stream')
|
|
95
|
+
ctx.response.header('Content-Length', String(stat.size))
|
|
96
|
+
ctx.response.header('Cache-Control', `public, max-age=${this.#maxAge}`)
|
|
97
|
+
ctx.response.header('ETag', etag)
|
|
98
|
+
|
|
99
|
+
if (ctx.request.method() === 'HEAD') {
|
|
100
|
+
ctx.response.status(200)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Streamed, not read whole.
|
|
105
|
+
//
|
|
106
|
+
// `readFileSync` stalled the event loop for EVERY concurrent request
|
|
107
|
+
// while one client asked for a file, and the allowed extensions include
|
|
108
|
+
// `.mp4`, `.zip` and `.pdf` — so one large asset became every other
|
|
109
|
+
// request's latency, and each request allocated the file again. The
|
|
110
|
+
// stream applies backpressure and holds one chunk at a time.
|
|
111
|
+
//
|
|
112
|
+
// The read stream owns the handle from here and closes it at end.
|
|
118
113
|
ctx.response.status(200)
|
|
119
|
-
|
|
114
|
+
owned = false
|
|
115
|
+
await ctx.response.stream(handle.createReadStream())
|
|
116
|
+
} finally {
|
|
117
|
+
if (owned) await handle.close().catch(() => {})
|
|
120
118
|
}
|
|
121
|
-
|
|
122
|
-
// Send the raw Buffer — `content.toString()` would interpret PNG /
|
|
123
|
-
// PDF / ZIP bytes as UTF-8 and replace any byte the encoding can't
|
|
124
|
-
// round-trip (anything in the surrogate range, plus invalid
|
|
125
|
-
// sequences) with U+FFFD.
|
|
126
|
-
const content = fs.readFileSync(realFilePath)
|
|
127
|
-
ctx.response.status(200).sendBuffer(content)
|
|
128
119
|
}
|
|
129
120
|
|
|
130
121
|
/**
|
|
131
|
-
*
|
|
132
|
-
* is not servable (wrong prefix/extension, outside the root,
|
|
133
|
-
* symlink escaping the root).
|
|
122
|
+
* Open a request path as a real, in-root, regular file, or answer `null`
|
|
123
|
+
* when it is not servable (wrong prefix/extension, outside the root,
|
|
124
|
+
* missing, or a symlink escaping the root). `null` tells `handle` to call
|
|
125
|
+
* `next()`.
|
|
126
|
+
*
|
|
127
|
+
* The file is OPENED here rather than merely checked, and its metadata read
|
|
128
|
+
* from the descriptor: what gets served is then the same file that passed
|
|
129
|
+
* the checks, which a path re-read after the fact cannot promise.
|
|
134
130
|
*/
|
|
135
|
-
#
|
|
131
|
+
async #openServableFile(
|
|
136
132
|
reqPath: string,
|
|
137
|
-
): {
|
|
133
|
+
): Promise<{ handle: FileHandle; ext: string; stat: fs.Stats } | null> {
|
|
138
134
|
// Match only when the prefix is followed by `/` or end-of-path —
|
|
139
135
|
// otherwise `/staticx/foo.js` would be intercepted when the configured
|
|
140
136
|
// prefix is `/static`, hijacking unrelated routes.
|
|
@@ -152,7 +148,7 @@ export class StaticMiddleware {
|
|
|
152
148
|
// Two containment checks. The first uses `path.sep` to reject sibling
|
|
153
149
|
// directories with a shared prefix (`/var/www-static-secret` slipping
|
|
154
150
|
// past a guard for `/var/www-static`). The second resolves symlinks
|
|
155
|
-
// via
|
|
151
|
+
// via realpath so a symlink planted inside the root cannot point
|
|
156
152
|
// outside it. We do realpath AFTER the lexical check so a non-existent
|
|
157
153
|
// path returns `next()` rather than throwing.
|
|
158
154
|
const rootWithSep = this.#root.endsWith(path.sep) ? this.#root : this.#root + path.sep
|
|
@@ -160,15 +156,11 @@ export class StaticMiddleware {
|
|
|
160
156
|
return null
|
|
161
157
|
}
|
|
162
158
|
|
|
163
|
-
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
164
|
-
return null
|
|
165
|
-
}
|
|
166
|
-
|
|
167
159
|
let realFilePath: string
|
|
168
160
|
let realRoot: string
|
|
169
161
|
try {
|
|
170
|
-
realFilePath =
|
|
171
|
-
realRoot =
|
|
162
|
+
realFilePath = await fsp.realpath(filePath)
|
|
163
|
+
realRoot = await fsp.realpath(this.#root)
|
|
172
164
|
} catch {
|
|
173
165
|
return null
|
|
174
166
|
}
|
|
@@ -177,6 +169,29 @@ export class StaticMiddleware {
|
|
|
177
169
|
return null
|
|
178
170
|
}
|
|
179
171
|
|
|
180
|
-
|
|
172
|
+
// O_NOFOLLOW closes the window the checks above leave open: between
|
|
173
|
+
// `realpath` and the read, the last path component can be replaced by a
|
|
174
|
+
// symlink pointing anywhere, and a read by name follows it. Refusing to
|
|
175
|
+
// follow a link at open time makes that swap fail the open instead of
|
|
176
|
+
// serving a file outside the root. A parent directory swapped in the same
|
|
177
|
+
// window is not covered — that needs openat(), which Node does not expose.
|
|
178
|
+
let handle: FileHandle
|
|
179
|
+
try {
|
|
180
|
+
handle = await fsp.open(realFilePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
181
|
+
} catch {
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const stat = await handle.stat()
|
|
187
|
+
if (!stat.isFile()) {
|
|
188
|
+
await handle.close()
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
return { handle, ext, stat }
|
|
192
|
+
} catch {
|
|
193
|
+
await handle.close().catch(() => {})
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
181
196
|
}
|
|
182
197
|
}
|
|
@@ -149,15 +149,14 @@ function parseMultipartResponse(
|
|
|
149
149
|
// Trim the trailing CRLF before the next boundary.
|
|
150
150
|
let content = part.subarray(headerEnd + 4)
|
|
151
151
|
if (content.subarray(-2).toString('binary') === '\r\n') content = content.subarray(0, -2)
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
if (fileMatch) {
|
|
152
|
+
const [, name] = headerText.match(/name="([^"]*)"/i) ?? []
|
|
153
|
+
if (name === undefined) continue
|
|
154
|
+
const [, filename] = headerText.match(/filename="([^"]*)"/i) ?? []
|
|
155
|
+
if (filename !== undefined) {
|
|
157
156
|
const typeMatch = headerText.match(/content-type:\s*([^\r\n]+)/i)
|
|
158
157
|
files[name] = {
|
|
159
158
|
fieldName: name,
|
|
160
|
-
filename
|
|
159
|
+
filename,
|
|
161
160
|
type: typeMatch?.[1]?.trim() ?? 'application/octet-stream',
|
|
162
161
|
size: content.length,
|
|
163
162
|
content,
|
|
@@ -355,8 +354,8 @@ export class ApiResponse {
|
|
|
355
354
|
/** Charset from `Content-Type`, if any — helix `charset()`. */
|
|
356
355
|
charset(): string | undefined {
|
|
357
356
|
const ct = this.#raw.headers['content-type'] ?? ''
|
|
358
|
-
const
|
|
359
|
-
return
|
|
357
|
+
const [, charset] = ct.match(/charset=([^;]+)/i) ?? []
|
|
358
|
+
return charset?.trim()
|
|
360
359
|
}
|
|
361
360
|
|
|
362
361
|
/** All response cookies, parsed from `Set-Cookie` — helix `cookies()`. */
|
|
@@ -380,8 +379,8 @@ export class ApiResponse {
|
|
|
380
379
|
if (!link) return {}
|
|
381
380
|
const out: Record<string, string> = {}
|
|
382
381
|
for (const part of link.split(',')) {
|
|
383
|
-
const
|
|
384
|
-
if (
|
|
382
|
+
const [, url, rel] = part.match(/<([^>]+)>\s*;\s*rel="?([^";]+)"?/) ?? []
|
|
383
|
+
if (url !== undefined && rel !== undefined) out[rel] = url
|
|
385
384
|
}
|
|
386
385
|
return out
|
|
387
386
|
}
|