@c9up/ream 0.2.28 → 0.2.30
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 +5 -6
- package/dist/Codemods.d.ts +24 -0
- package/dist/Codemods.d.ts.map +1 -1
- package/dist/Codemods.js +211 -1
- package/dist/Codemods.js.map +1 -1
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +17 -5
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts +15 -2
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +33 -23
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.d.ts +4 -4
- package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.js +41 -11
- package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
- package/dist/bodyparser/MultipartFile.d.ts +21 -15
- package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
- package/dist/bodyparser/MultipartFile.js +40 -58
- package/dist/bodyparser/MultipartFile.js.map +1 -1
- package/dist/bodyparser/parseSize.d.ts +7 -4
- package/dist/bodyparser/parseSize.d.ts.map +1 -1
- package/dist/bodyparser/parseSize.js +12 -17
- package/dist/bodyparser/parseSize.js.map +1 -1
- package/dist/bodyparser/qsParse.d.ts +20 -1
- package/dist/bodyparser/qsParse.d.ts.map +1 -1
- package/dist/bodyparser/qsParse.js +78 -25
- package/dist/bodyparser/qsParse.js.map +1 -1
- package/dist/codemods/source.d.ts +57 -0
- package/dist/codemods/source.d.ts.map +1 -0
- package/dist/codemods/source.js +282 -0
- package/dist/codemods/source.js.map +1 -0
- package/dist/console/prompts.d.ts.map +1 -1
- package/dist/console/prompts.js +3 -1
- package/dist/console/prompts.js.map +1 -1
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +61 -71
- package/dist/container/Container.js.map +1 -1
- package/dist/events/Emitter.d.ts +12 -0
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +36 -9
- package/dist/events/Emitter.js.map +1 -1
- package/dist/events/helix/helpers.d.ts.map +1 -1
- package/dist/events/helix/helpers.js +19 -9
- package/dist/events/helix/helpers.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts +8 -0
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +79 -12
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/helpers/MessageBuilder.d.ts +4 -2
- package/dist/helpers/MessageBuilder.d.ts.map +1 -1
- package/dist/helpers/MessageBuilder.js +4 -2
- package/dist/helpers/MessageBuilder.js.map +1 -1
- package/dist/helpers/errors.d.ts +11 -0
- package/dist/helpers/errors.d.ts.map +1 -0
- package/dist/helpers/errors.js +17 -0
- package/dist/helpers/errors.js.map +1 -0
- package/dist/helpers/napi-loader.d.ts.map +1 -1
- package/dist/helpers/napi-loader.js +3 -2
- package/dist/helpers/napi-loader.js.map +1 -1
- package/dist/http/Exception.d.ts +14 -1
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +10 -2
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/HttpContext.d.ts +31 -2
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js +12 -5
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts +9 -3
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +43 -37
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts +42 -17
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +80 -58
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts +7 -4
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +14 -6
- package/dist/http/Response.js.map +1 -1
- package/dist/http/SseStream.js +6 -7
- package/dist/http/SseStream.js.map +1 -1
- package/dist/http/permissions.d.ts +25 -0
- package/dist/http/permissions.d.ts.map +1 -0
- package/dist/http/permissions.js +38 -0
- package/dist/http/permissions.js.map +1 -0
- package/dist/http-client/HttpClient.d.ts +111 -0
- package/dist/http-client/HttpClient.d.ts.map +1 -0
- package/dist/http-client/HttpClient.js +386 -0
- package/dist/http-client/HttpClient.js.map +1 -0
- package/dist/http-client/addresses.d.ts +14 -0
- package/dist/http-client/addresses.d.ts.map +1 -0
- package/dist/http-client/addresses.js +57 -0
- package/dist/http-client/addresses.js.map +1 -0
- package/dist/http-client/fake.d.ts +50 -0
- package/dist/http-client/fake.d.ts.map +1 -0
- package/dist/http-client/fake.js +95 -0
- package/dist/http-client/fake.js.map +1 -0
- package/dist/http-client/index.d.ts +7 -0
- package/dist/http-client/index.d.ts.map +1 -0
- package/dist/http-client/index.js +7 -0
- package/dist/http-client/index.js.map +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/Pipeline.d.ts.map +1 -1
- package/dist/middleware/Pipeline.js +6 -3
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/native/generated.d.ts +93 -36
- package/dist/native/generated.d.ts.map +1 -1
- package/dist/profiler/Profiler.d.ts.map +1 -1
- package/dist/profiler/Profiler.js +4 -1
- package/dist/profiler/Profiler.js.map +1 -1
- package/dist/profiler/client.generated.d.ts +5 -0
- package/dist/profiler/client.generated.d.ts.map +1 -0
- package/dist/profiler/client.generated.js +6 -0
- package/dist/profiler/client.generated.js.map +1 -0
- package/dist/profiler/collectors.d.ts +92 -13
- package/dist/profiler/collectors.d.ts.map +1 -1
- package/dist/profiler/collectors.js +106 -19
- package/dist/profiler/collectors.js.map +1 -1
- package/dist/profiler/endpoint.d.ts +2 -1
- package/dist/profiler/endpoint.d.ts.map +1 -1
- package/dist/profiler/endpoint.js +25 -1
- package/dist/profiler/endpoint.js.map +1 -1
- package/dist/profiler/index.d.ts +8 -4
- package/dist/profiler/index.d.ts.map +1 -1
- package/dist/profiler/index.js +11 -4
- package/dist/profiler/index.js.map +1 -1
- package/dist/profiler/middleware.d.ts.map +1 -1
- package/dist/profiler/middleware.js +76 -10
- package/dist/profiler/middleware.js.map +1 -1
- package/dist/profiler/preview.d.ts +17 -0
- package/dist/profiler/preview.d.ts.map +1 -0
- package/dist/profiler/preview.js +100 -0
- package/dist/profiler/preview.js.map +1 -0
- package/dist/profiler/toolbar.d.ts +15 -23
- package/dist/profiler/toolbar.d.ts.map +1 -1
- package/dist/profiler/toolbar.js +25 -91
- package/dist/profiler/toolbar.js.map +1 -1
- package/dist/profiler/types.d.ts +6 -1
- package/dist/profiler/types.d.ts.map +1 -1
- package/dist/router/Router.d.ts +6 -0
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +115 -12
- package/dist/router/Router.js.map +1 -1
- package/dist/rpc/RpcProvider.d.ts +6 -0
- package/dist/rpc/RpcProvider.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.js +4 -1
- package/dist/rpc/RpcProvider.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts +27 -14
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +97 -40
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/Scheduler.js +1 -1
- package/dist/scheduler/Scheduler.js.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.js +13 -2
- package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +32 -8
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/security/SignedUrl.d.ts.map +1 -1
- package/dist/security/SignedUrl.js +16 -19
- package/dist/security/SignedUrl.js.map +1 -1
- package/dist/server/Server.d.ts +44 -12
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/Server.js +31 -10
- package/dist/server/Server.js.map +1 -1
- package/dist/server/nativeGrpc.d.ts +27 -0
- package/dist/server/nativeGrpc.d.ts.map +1 -0
- package/dist/server/nativeGrpc.js +47 -0
- package/dist/server/nativeGrpc.js.map +1 -0
- package/dist/server/nativeLimits.d.ts +57 -0
- package/dist/server/nativeLimits.d.ts.map +1 -0
- package/dist/server/nativeLimits.js +75 -0
- package/dist/server/nativeLimits.js.map +1 -0
- package/dist/session/Session.d.ts +12 -0
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +13 -4
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +28 -9
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/config.d.ts +5 -0
- package/dist/session/config.d.ts.map +1 -1
- package/dist/session/config.js +8 -0
- package/dist/session/config.js.map +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +26 -7
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/session/errors.d.ts +17 -0
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +21 -0
- package/dist/session/errors.js.map +1 -1
- package/dist/session/templateTags.d.ts +14 -0
- package/dist/session/templateTags.d.ts.map +1 -1
- package/dist/session/templateTags.js +45 -17
- package/dist/session/templateTags.js.map +1 -1
- package/dist/storage/StaticMiddleware.d.ts.map +1 -1
- package/dist/storage/StaticMiddleware.js +49 -27
- package/dist/storage/StaticMiddleware.js.map +1 -1
- package/dist/testing/ApiResponse.d.ts.map +1 -1
- package/dist/testing/ApiResponse.js +7 -1
- package/dist/testing/ApiResponse.js.map +1 -1
- package/dist/testing/TestClient.d.ts.map +1 -1
- package/dist/testing/TestClient.js +16 -4
- package/dist/testing/TestClient.js.map +1 -1
- package/dist/utils/Macroable.d.ts +1 -1
- package/dist/utils/Macroable.js +2 -2
- package/dist/utils/Macroable.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 +11 -3
- 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/src/Codemods.ts +314 -3
- package/src/HttpKernel.ts +18 -7
- package/src/Ignitor.ts +58 -26
- package/src/bodyparser/BodyParserMiddleware.ts +45 -16
- package/src/bodyparser/MultipartFile.ts +46 -67
- package/src/bodyparser/parseSize.ts +17 -17
- package/src/bodyparser/qsParse.ts +96 -26
- package/src/codemods/source.ts +303 -0
- package/src/console/prompts.ts +3 -1
- package/src/container/Container.ts +97 -81
- package/src/events/Emitter.ts +37 -10
- package/src/events/helix/helpers.ts +24 -19
- package/src/graphql/GraphQLEngine.ts +91 -14
- package/src/helpers/MessageBuilder.ts +4 -2
- package/src/helpers/errors.ts +16 -0
- package/src/helpers/napi-loader.ts +3 -2
- package/src/http/Exception.ts +17 -2
- package/src/http/HttpContext.ts +34 -5
- package/src/http/RedirectBuilder.ts +51 -37
- package/src/http/Request.ts +102 -58
- package/src/http/Response.ts +15 -7
- package/src/http/SseStream.ts +6 -7
- package/src/http/permissions.ts +47 -0
- package/src/http-client/HttpClient.ts +505 -0
- package/src/http-client/addresses.ts +58 -0
- package/src/http-client/fake.ts +141 -0
- package/src/http-client/index.ts +21 -0
- package/src/index.ts +6 -1
- package/src/middleware/Pipeline.ts +7 -3
- package/src/native/generated.ts +106 -48
- package/src/profiler/Profiler.ts +4 -1
- package/src/profiler/client/toolbar.css +595 -0
- package/src/profiler/client/toolbar.js +1299 -0
- package/src/profiler/client.generated.ts +7 -0
- package/src/profiler/collectors.ts +156 -19
- package/src/profiler/endpoint.ts +27 -1
- package/src/profiler/index.ts +17 -3
- package/src/profiler/middleware.ts +74 -9
- package/src/profiler/preview.ts +104 -0
- package/src/profiler/toolbar.ts +28 -91
- package/src/profiler/types.ts +6 -1
- package/src/router/Router.ts +136 -13
- package/src/rpc/RpcProvider.ts +10 -1
- package/src/rpc/RpcRouter.ts +129 -45
- package/src/scheduler/Scheduler.ts +1 -1
- package/src/scheduler/locks/RedisLockBackend.ts +12 -4
- package/src/security/CookieSigner.ts +30 -8
- package/src/security/SignedUrl.ts +18 -17
- package/src/server/Server.ts +60 -12
- package/src/server/nativeGrpc.ts +59 -0
- package/src/server/nativeLimits.ts +111 -0
- package/src/session/Session.ts +29 -4
- package/src/session/SessionMiddleware.ts +30 -10
- package/src/session/config.ts +7 -0
- package/src/session/drivers/MemoryDriver.ts +26 -7
- package/src/session/errors.ts +31 -0
- package/src/session/templateTags.ts +57 -0
- package/src/storage/StaticMiddleware.ts +46 -27
- package/src/testing/ApiResponse.ts +7 -1
- package/src/testing/TestClient.ts +14 -5
- package/src/utils/Macroable.ts +2 -2
- package/scripts/build-napi-types.mjs +0 -69
- package/scripts/copy-napi.mjs +0 -121
- package/scripts/generate-mime.mjs +0 -119
- package/scripts/generate-napi-types.mjs +0 -187
package/src/rpc/RpcRouter.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Usage:
|
|
7
7
|
* rpc.method('task.validate', handler).guard('jwt').role('cs_member')
|
|
8
|
-
* rpc.namespace('task', TaskController) //
|
|
8
|
+
* rpc.namespace('task', TaskController, ['show', 'update']) // listed methods, DI-resolved per call
|
|
9
9
|
* rpc.group({ guard: 'jwt' }, (r) => { r.method('user.find', handler) })
|
|
10
10
|
*
|
|
11
11
|
* @implements MISS-27
|
|
@@ -15,11 +15,14 @@ import {
|
|
|
15
15
|
buildSuccess,
|
|
16
16
|
isNotification,
|
|
17
17
|
isRpcShapedError,
|
|
18
|
+
type JsonRpcId,
|
|
18
19
|
parseRequest as parseRpcRequest,
|
|
19
20
|
buildError as rpcError,
|
|
20
21
|
} from '@c9up/comet'
|
|
21
22
|
import { currentNodeEnv } from '../env/nodeEnv.js'
|
|
23
|
+
import { isReamError, ReamError } from '../errors/ReamError.js'
|
|
22
24
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
25
|
+
import { holdsPermissions } from '../http/permissions.js'
|
|
23
26
|
import type { MiddlewareRegistry, RuntimeValidator } from '../middleware/Pipeline.js'
|
|
24
27
|
import { composeMiddleware, runValidator } from '../middleware/Pipeline.js'
|
|
25
28
|
|
|
@@ -79,17 +82,58 @@ export class RpcMethodBuilder {
|
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* JSON-RPC batch arrives this way; a single request is a plain object and is
|
|
85
|
-
* returned unchanged.
|
|
85
|
+
* A -32603 whose detail reaches the caller only outside production: an
|
|
86
|
+
* internal failure's message is where hosts, credentials and queries end up.
|
|
86
87
|
*/
|
|
87
|
-
function
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
function internalError(detail: string, id: JsonRpcId) {
|
|
89
|
+
return rpcError(-32603, currentNodeEnv() !== 'production' ? detail : 'Internal error', id)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The public method names of a controller instance type. */
|
|
93
|
+
export type ControllerMethodName<T> = {
|
|
94
|
+
[K in keyof T]: T[K] extends (...args: never[]) => unknown ? K : never
|
|
95
|
+
}[keyof T] &
|
|
96
|
+
string
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether `name` is a plain method of the controller's own class chain.
|
|
100
|
+
* The walk stops before `Object.prototype` and reads descriptors, so
|
|
101
|
+
* neither `toString` nor a getter (whose read would run code) qualifies.
|
|
102
|
+
*/
|
|
103
|
+
function isControllerMethod(proto: unknown, name: unknown): boolean {
|
|
104
|
+
if (typeof name !== 'string' || name === '' || name === 'constructor') return false
|
|
105
|
+
let current: unknown = proto
|
|
106
|
+
while (typeof current === 'object' && current !== null && current !== Object.prototype) {
|
|
107
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, name)
|
|
108
|
+
if (descriptor) return typeof descriptor.value === 'function'
|
|
109
|
+
current = Object.getPrototypeOf(current)
|
|
91
110
|
}
|
|
92
|
-
return
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The JSON media types JSON-RPC over HTTP is sent with. None of them is a
|
|
116
|
+
* CORS-safelisted request type, so a foreign page cannot send one without a
|
|
117
|
+
* preflight.
|
|
118
|
+
*/
|
|
119
|
+
const DEFAULT_CONTENT_TYPES = [
|
|
120
|
+
'application/json',
|
|
121
|
+
'application/json-rpc',
|
|
122
|
+
'application/jsonrequest',
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
/** `Application/JSON; charset=utf-8` → `application/json`. */
|
|
126
|
+
function mediaType(contentType: string | undefined): string {
|
|
127
|
+
return (contentType?.split(';')[0] ?? '').trim().toLowerCase()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface RpcRouterOptions {
|
|
131
|
+
/**
|
|
132
|
+
* Media types `handle()` accepts a request body under; anything else is
|
|
133
|
+
* answered 415 before the body is read. Default: `application/json`,
|
|
134
|
+
* `application/json-rpc`, `application/jsonrequest`.
|
|
135
|
+
*/
|
|
136
|
+
contentTypes?: readonly string[]
|
|
93
137
|
}
|
|
94
138
|
|
|
95
139
|
/**
|
|
@@ -97,10 +141,10 @@ function unwrapBatchBody(value: unknown): unknown {
|
|
|
97
141
|
* Returns the JSON-RPC error code+message to reject with, or undefined when the
|
|
98
142
|
* caller is authorized.
|
|
99
143
|
*/
|
|
100
|
-
function checkRpcAuthorization(
|
|
144
|
+
async function checkRpcAuthorization(
|
|
101
145
|
ctx: HttpContext,
|
|
102
146
|
def: RpcMethodDefinition,
|
|
103
|
-
): { code: number; message: string } | undefined {
|
|
147
|
+
): Promise<{ code: number; message: string } | undefined> {
|
|
104
148
|
const needsAuth = def.guards.length > 0 || def.roles.length > 0 || def.permissions.length > 0
|
|
105
149
|
if (needsAuth && !ctx.auth?.isAuthenticated) {
|
|
106
150
|
return { code: -32003, message: 'Unauthorized' }
|
|
@@ -113,9 +157,9 @@ function checkRpcAuthorization(
|
|
|
113
157
|
return { code: -32003, message: 'Insufficient role' }
|
|
114
158
|
}
|
|
115
159
|
}
|
|
160
|
+
// A token-authenticated call also needs the token to allow each permission.
|
|
116
161
|
if (def.permissions.length > 0) {
|
|
117
|
-
|
|
118
|
-
if (!def.permissions.every((p: string) => userPerms.includes(p))) {
|
|
162
|
+
if (!(await holdsPermissions(ctx.auth, def.permissions))) {
|
|
119
163
|
return { code: -32003, message: 'Insufficient permissions' }
|
|
120
164
|
}
|
|
121
165
|
}
|
|
@@ -126,6 +170,13 @@ export class RpcRouter {
|
|
|
126
170
|
#methods: Map<string, RpcMethodDefinition> = new Map()
|
|
127
171
|
#groupConfig: { guards: string[]; roles: string[]; middleware: string[] } | null = null
|
|
128
172
|
#container?: RpcContainer
|
|
173
|
+
readonly #contentTypes: ReadonlySet<string>
|
|
174
|
+
|
|
175
|
+
constructor(options: RpcRouterOptions = {}) {
|
|
176
|
+
this.#contentTypes = new Set(
|
|
177
|
+
(options.contentTypes ?? DEFAULT_CONTENT_TYPES).map((type) => mediaType(type)),
|
|
178
|
+
)
|
|
179
|
+
}
|
|
129
180
|
|
|
130
181
|
/**
|
|
131
182
|
* Set the IoC container used to resolve `namespace()` controllers — mirrors
|
|
@@ -137,8 +188,23 @@ export class RpcRouter {
|
|
|
137
188
|
this.#container = container
|
|
138
189
|
}
|
|
139
190
|
|
|
140
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* Register an RPC method. JSON-RPC 2.0 §4 reserves names starting with
|
|
193
|
+
* `rpc.` for the protocol itself, so they are refused here, as an empty
|
|
194
|
+
* name is.
|
|
195
|
+
*/
|
|
141
196
|
method(name: string, handler: RpcHandler): RpcMethodBuilder {
|
|
197
|
+
if (typeof name !== 'string' || name === '') {
|
|
198
|
+
throw new ReamError(
|
|
199
|
+
'E_RPC_INVALID_METHOD_NAME',
|
|
200
|
+
'An RPC method name must be a non-empty string',
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
if (name.startsWith('rpc.')) {
|
|
204
|
+
throw new ReamError('E_RPC_RESERVED_METHOD_NAME', `'${name}' is a reserved RPC method name`, {
|
|
205
|
+
hint: 'JSON-RPC 2.0 reserves names starting with "rpc." for rpc-internal methods; pick another prefix.',
|
|
206
|
+
})
|
|
207
|
+
}
|
|
142
208
|
const def: RpcMethodDefinition = {
|
|
143
209
|
name,
|
|
144
210
|
handler,
|
|
@@ -152,24 +218,28 @@ export class RpcRouter {
|
|
|
152
218
|
}
|
|
153
219
|
|
|
154
220
|
/**
|
|
155
|
-
* Register methods
|
|
221
|
+
* Register the listed methods of a controller as `<prefix>.<method>`.
|
|
156
222
|
*
|
|
157
|
-
* The
|
|
158
|
-
* `
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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.
|
|
223
|
+
* The list is explicit, like a route binding one `[Controller, 'method']`
|
|
224
|
+
* pair: TypeScript `private`/`protected` vanish at runtime, so a namespace
|
|
225
|
+
* that published "every method on the prototype" made helpers the author
|
|
226
|
+
* never meant to expose remotely callable. The type admits only public
|
|
227
|
+
* method names; the runtime check refuses a name with no plain method
|
|
228
|
+
* behind it (a getter, `constructor`, anything from `Object.prototype`).
|
|
167
229
|
*/
|
|
168
|
-
namespace
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
230
|
+
namespace<T extends object>(
|
|
231
|
+
prefix: string,
|
|
232
|
+
controller: abstract new (...args: never[]) => T,
|
|
233
|
+
methods: ReadonlyArray<ControllerMethodName<T>>,
|
|
234
|
+
): void {
|
|
235
|
+
for (const methodName of methods) {
|
|
236
|
+
if (!isControllerMethod(controller.prototype, methodName)) {
|
|
237
|
+
throw new ReamError(
|
|
238
|
+
'E_RPC_UNKNOWN_CONTROLLER_METHOD',
|
|
239
|
+
`${controller.name} has no method '${String(methodName)}' to expose as an RPC method`,
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
173
243
|
for (const methodName of methods) {
|
|
174
244
|
this.method(`${prefix}.${methodName}`, async (ctx, params) => {
|
|
175
245
|
// Resolve through the container on every call (fresh DI per request,
|
|
@@ -212,10 +282,26 @@ export class RpcRouter {
|
|
|
212
282
|
|
|
213
283
|
/** Handle an incoming JSON-RPC request. */
|
|
214
284
|
async handle(ctx: HttpContext): Promise<void> {
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
285
|
+
// A form post, a text/plain body or one with no Content-Type is a CORS
|
|
286
|
+
// simple request: a foreign page sends it with the victim's cookies and
|
|
287
|
+
// no preflight. Refused before the body is read, as JSON-RPC servers
|
|
288
|
+
// answer 415 to anything but JSON.
|
|
289
|
+
if (!this.#contentTypes.has(mediaType(ctx.request.header('content-type')))) {
|
|
290
|
+
ctx.response.status(415).send('')
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Parsed here, not through `request.body()`, which turns malformed JSON
|
|
295
|
+
// into `{}` — and §5.1 answers invalid JSON with -32700, not -32600.
|
|
296
|
+
// `raw()` stays outside the try: its 413 is not a parse error.
|
|
297
|
+
const raw = ctx.request.raw()
|
|
298
|
+
let body: unknown
|
|
299
|
+
try {
|
|
300
|
+
body = JSON.parse(raw)
|
|
301
|
+
} catch {
|
|
302
|
+
ctx.response.status(200).json(rpcError(-32700, 'Parse error', null))
|
|
303
|
+
return
|
|
304
|
+
}
|
|
219
305
|
|
|
220
306
|
// Batch support (max 50 to prevent DoS)
|
|
221
307
|
if (Array.isArray(body)) {
|
|
@@ -295,7 +381,7 @@ export class RpcRouter {
|
|
|
295
381
|
if (!def) return rpcError(-32601, 'Method not found', id)
|
|
296
382
|
|
|
297
383
|
try {
|
|
298
|
-
const denied = checkRpcAuthorization(ctx, def)
|
|
384
|
+
const denied = await checkRpcAuthorization(ctx, def)
|
|
299
385
|
if (denied) return rpcError(denied.code, denied.message, id)
|
|
300
386
|
|
|
301
387
|
// Run declared named middleware (e.g. guards) — they throw to deny, mapped
|
|
@@ -310,8 +396,7 @@ export class RpcRouter {
|
|
|
310
396
|
if (def.validator) {
|
|
311
397
|
const token = `validator:${def.validator}`
|
|
312
398
|
if (!this.#container?.has(token)) {
|
|
313
|
-
return
|
|
314
|
-
-32603,
|
|
399
|
+
return internalError(
|
|
315
400
|
`Validator '${def.validator}' is not registered (bind container.singleton('${token}', () => schema)).`,
|
|
316
401
|
id,
|
|
317
402
|
)
|
|
@@ -324,9 +409,11 @@ export class RpcRouter {
|
|
|
324
409
|
try {
|
|
325
410
|
outcome = await runValidator(validator, params)
|
|
326
411
|
} catch (err) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
412
|
+
// Caught here rather than below so a validator's error is never
|
|
413
|
+
// honoured as a domain error: a rule hitting the database throws
|
|
414
|
+
// whatever the driver throws.
|
|
415
|
+
return internalError(
|
|
416
|
+
isReamError(err, 'E_VALIDATOR_UNUSABLE')
|
|
330
417
|
? `Validator '${def.validator}' exposes no usable validate method.`
|
|
331
418
|
: `Validator '${def.validator}' failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
332
419
|
id,
|
|
@@ -346,10 +433,7 @@ export class RpcRouter {
|
|
|
346
433
|
const message = typeof err.message === 'string' ? err.message : 'RPC error'
|
|
347
434
|
return rpcError(err.code, message, id, err.data)
|
|
348
435
|
}
|
|
349
|
-
|
|
350
|
-
const isDev = currentNodeEnv() !== 'production'
|
|
351
|
-
const message = isDev && err instanceof Error ? err.message : 'Internal error'
|
|
352
|
-
return rpcError(-32603, message, id)
|
|
436
|
+
return internalError(err instanceof Error ? err.message : 'Internal error', id)
|
|
353
437
|
}
|
|
354
438
|
}
|
|
355
439
|
|
|
@@ -144,7 +144,7 @@ function loadNative(): NativeModule {
|
|
|
144
144
|
* so scheduler context is never lost.
|
|
145
145
|
*/
|
|
146
146
|
function translateNativeError(e: unknown): ReamError {
|
|
147
|
-
const asError = e
|
|
147
|
+
const asError = e instanceof Error ? e : new Error(String(e))
|
|
148
148
|
const parsed = ReamError.fromNapi(asError)
|
|
149
149
|
// `fromNapi` returns an `E_UNKNOWN`-coded error when the message is not
|
|
150
150
|
// a JSON payload. Preserve that context under a scheduler-specific
|
|
@@ -67,12 +67,20 @@ export class RedisLockBackend implements LockBackend {
|
|
|
67
67
|
this.#prefix = options.prefix ?? 'ream:schedule:lock:'
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* The client, resolved once and kept. Only a success is kept: a lookup
|
|
72
|
+
* that fails (the connection is not registered yet at boot, Redis is
|
|
73
|
+
* briefly down) is forgotten so the next task retries it, instead of
|
|
74
|
+
* every lock failing with the same error until the process restarts.
|
|
75
|
+
*/
|
|
71
76
|
#client(): Promise<LockRedisClient> {
|
|
72
77
|
if (!this.#resolved) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
const source = this.#source
|
|
79
|
+
const pending = (async () => (typeof source === 'function' ? source() : source))()
|
|
80
|
+
this.#resolved = pending
|
|
81
|
+
pending.catch(() => {
|
|
82
|
+
if (this.#resolved === pending) this.#resolved = undefined
|
|
83
|
+
})
|
|
76
84
|
}
|
|
77
85
|
return this.#resolved
|
|
78
86
|
}
|
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto'
|
|
24
24
|
import { hmacSign, hmacVerify } from './crypto.js'
|
|
25
25
|
|
|
26
|
+
/** The 96-bit nonce GCM is specified for. */
|
|
27
|
+
const GCM_IV_BYTES = 12
|
|
28
|
+
/** The full 128-bit authentication tag — never a truncated one. */
|
|
29
|
+
const GCM_TAG_BYTES = 16
|
|
30
|
+
|
|
26
31
|
/** Raised when no APP_KEY is configured (AdonisJS `E_MISSING_APP_KEY`). */
|
|
27
32
|
export class E_MISSING_APP_KEY extends Error {
|
|
28
33
|
readonly code = 'E_MISSING_APP_KEY' as const
|
|
@@ -120,14 +125,22 @@ export class CookieSigner {
|
|
|
120
125
|
return null
|
|
121
126
|
}
|
|
122
127
|
if (typeof parsed !== 'object' || parsed === null) return null
|
|
123
|
-
const envelope = parsed as Envelope
|
|
124
128
|
// The KEY must be there, whatever it holds — that is what distinguishes an
|
|
125
129
|
// envelope from an arbitrary JSON document, and a signed `null` or `0` is a
|
|
126
130
|
// legitimate payload.
|
|
127
|
-
if (!('m' in
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
+
if (!('m' in parsed)) return null
|
|
132
|
+
// Each field checked for the type `#seal` writes before it is compared: an
|
|
133
|
+
// expiry that is not a number (`"e": "9999"`, `"e": null`) or a purpose
|
|
134
|
+
// that is not a string must not slip past the comparisons below.
|
|
135
|
+
const expiry: unknown = 'e' in parsed ? parsed.e : undefined
|
|
136
|
+
if (expiry !== undefined) {
|
|
137
|
+
if (typeof expiry !== 'number' || !Number.isFinite(expiry)) return null
|
|
138
|
+
if (expiry < Date.now()) return null
|
|
139
|
+
}
|
|
140
|
+
const sealedFor: unknown = 'p' in parsed ? parsed.p : undefined
|
|
141
|
+
if (sealedFor !== undefined && typeof sealedFor !== 'string') return null
|
|
142
|
+
if (sealedFor !== purpose) return null
|
|
143
|
+
return parsed.m
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
/**
|
|
@@ -217,8 +230,10 @@ export class CookieSigner {
|
|
|
217
230
|
}
|
|
218
231
|
|
|
219
232
|
encrypt(value: unknown, expiresIn?: number, purpose?: string): string {
|
|
220
|
-
const iv = randomBytes(
|
|
221
|
-
const cipher = createCipheriv('aes-256-gcm', this.#keyBuffer, iv
|
|
233
|
+
const iv = randomBytes(GCM_IV_BYTES)
|
|
234
|
+
const cipher = createCipheriv('aes-256-gcm', this.#keyBuffer, iv, {
|
|
235
|
+
authTagLength: GCM_TAG_BYTES,
|
|
236
|
+
})
|
|
222
237
|
const sealed = this.#seal(value, expiresIn, purpose)
|
|
223
238
|
const encrypted = Buffer.concat([cipher.update(sealed, 'utf8'), cipher.final()])
|
|
224
239
|
const tag = cipher.getAuthTag()
|
|
@@ -242,7 +257,14 @@ export class CookieSigner {
|
|
|
242
257
|
const iv = Buffer.from(ivPart, 'base64url')
|
|
243
258
|
const data = Buffer.from(dataPart, 'base64url')
|
|
244
259
|
const tag = Buffer.from(tagPart, 'base64url')
|
|
245
|
-
|
|
260
|
+
// GCM verifies only as many tag bytes as it is handed: a client that
|
|
261
|
+
// trims the tag to 4 bytes would cut forgery cost from 2^128 to 2^32
|
|
262
|
+
// guesses. Only what `encrypt` produces is accepted — a 12-byte IV and
|
|
263
|
+
// the full 16-byte tag — and the decipher is pinned to that length too.
|
|
264
|
+
if (iv.length !== GCM_IV_BYTES || tag.length !== GCM_TAG_BYTES) return null
|
|
265
|
+
const decipher = createDecipheriv('aes-256-gcm', this.#keyBuffer, iv, {
|
|
266
|
+
authTagLength: GCM_TAG_BYTES,
|
|
267
|
+
})
|
|
246
268
|
decipher.setAuthTag(tag)
|
|
247
269
|
const raw = Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
|
|
248
270
|
return this.#open(raw, purpose) as T | null
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Uses Rust NAPI via crypto facade when available.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { durationToSeconds } from '../helpers/duration.js'
|
|
6
7
|
import { constantTimeEq, hmacSign } from './crypto.js'
|
|
7
8
|
|
|
8
9
|
export interface SignedUrlConfig {
|
|
@@ -43,7 +44,12 @@ export class SignedUrl {
|
|
|
43
44
|
if (!providedSig) return false
|
|
44
45
|
const expires = url.searchParams.get('expires')
|
|
45
46
|
if (expires && Math.floor(Date.now() / 1000) > parseInt(expires, 10)) return false
|
|
46
|
-
|
|
47
|
+
// Exact match both ways: a URL minted for `password-reset` must not pass a
|
|
48
|
+
// check that names no purpose (or another one), and a purpose-less URL
|
|
49
|
+
// must not pass a check that names one. An empty purpose is no purpose,
|
|
50
|
+
// as in `make`.
|
|
51
|
+
const signedPurpose = url.searchParams.get('purpose') || undefined
|
|
52
|
+
if (signedPurpose !== (purpose || undefined)) return false
|
|
47
53
|
|
|
48
54
|
url.searchParams.delete('signature')
|
|
49
55
|
const expectedSig = hmacSign(url.pathname + url.search, this.#secret)
|
|
@@ -51,22 +57,17 @@ export class SignedUrl {
|
|
|
51
57
|
}
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The lifetime of a signed URL in seconds. Read with the duration grammar
|
|
62
|
+
* used everywhere else (`'30 minutes'`, `'1 week'`, `'2h'`), and refused when
|
|
63
|
+
* it cannot be read: signing with a default instead turned "one week" into
|
|
64
|
+
* one hour without a word.
|
|
65
|
+
*/
|
|
54
66
|
function parseExpiry(value: string | number): number {
|
|
55
|
-
if (typeof value === 'number'
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const num = parseInt(digits ?? '0', 10)
|
|
60
|
-
switch (unit) {
|
|
61
|
-
case 's':
|
|
62
|
-
return num
|
|
63
|
-
case 'm':
|
|
64
|
-
return num * 60
|
|
65
|
-
case 'h':
|
|
66
|
-
return num * 3600
|
|
67
|
-
case 'd':
|
|
68
|
-
return num * 86400
|
|
69
|
-
default:
|
|
70
|
-
return 3600
|
|
67
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Cannot read ${value} as a signed URL expiry: it must be a finite number of seconds.`,
|
|
70
|
+
)
|
|
71
71
|
}
|
|
72
|
+
return durationToSeconds(value, 'a signed URL expiry')
|
|
72
73
|
}
|
package/src/server/Server.ts
CHANGED
|
@@ -37,12 +37,23 @@ export type MiddlewareEntry = LazyImport<MiddlewareClassConstructor> | Middlewar
|
|
|
37
37
|
export interface GrpcCall {
|
|
38
38
|
/** `/package.Service/Method`, as it arrived. */
|
|
39
39
|
path: string
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Request metadata: the request headers, every value of a repeated one in
|
|
42
|
+
* order, names lowercased. Never the client's trailers. A `-bin` value is
|
|
43
|
+
* the base64 text that arrived: this is the HTTP/2 header layer, and the
|
|
44
|
+
* gRPC package that registers the handler decodes it, as a gRPC library's
|
|
45
|
+
* metadata class does over Node's HTTP/2 headers.
|
|
46
|
+
*/
|
|
47
|
+
metadata: Record<string, string[]>
|
|
42
48
|
/** The request message, still encoded. */
|
|
43
49
|
message: Uint8Array
|
|
44
50
|
/** Resolved client IP, by the same trusted-proxy rules as `request.ip()`. */
|
|
45
51
|
ip: string
|
|
52
|
+
/**
|
|
53
|
+
* Aborted when the client gives up on the call — resets its stream or
|
|
54
|
+
* closes the connection — before the handler answered.
|
|
55
|
+
*/
|
|
56
|
+
signal: AbortSignal
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
/** A reply message, still encoded — or a gRPC status code instead of one. */
|
|
@@ -50,6 +61,26 @@ export type GrpcReply = { message: Uint8Array } | { code: number; details: strin
|
|
|
50
61
|
|
|
51
62
|
export type GrpcHandler = (call: GrpcCall) => Promise<GrpcReply>
|
|
52
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Message size bounds of the gRPC calls, in bytes; `-1` for none. A request
|
|
66
|
+
* past its bound is refused, `RESOURCE_EXHAUSTED`, before it is read; a reply
|
|
67
|
+
* past its bound is answered `RESOURCE_EXHAUSTED` instead.
|
|
68
|
+
*/
|
|
69
|
+
export interface GrpcLimits {
|
|
70
|
+
/** Default 4 MiB. */
|
|
71
|
+
maxReceiveMessageLength?: number
|
|
72
|
+
/** Default `-1`: no bound. */
|
|
73
|
+
maxSendMessageLength?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function checkGrpcLimit(name: string, value: number | undefined): void {
|
|
77
|
+
if (value === undefined || value === -1 || (Number.isSafeInteger(value) && value >= 0)) return
|
|
78
|
+
throw new ReamError(
|
|
79
|
+
'E_SERVER_GRPC_INVALID_LIMIT',
|
|
80
|
+
`${name} must be a whole number of bytes, or -1 for no limit — got ${value}.`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
53
84
|
export class Server {
|
|
54
85
|
#router: Router
|
|
55
86
|
#serverMiddleware: MiddlewareFunction[] = []
|
|
@@ -57,22 +88,31 @@ export class Server {
|
|
|
57
88
|
#resolvedErrorHandler?: ExceptionHandler
|
|
58
89
|
#trustedProxies: string[] = []
|
|
59
90
|
#grpcHandler?: GrpcHandler
|
|
91
|
+
#grpcLimits: GrpcLimits = {}
|
|
60
92
|
|
|
61
93
|
constructor(router: Router) {
|
|
62
94
|
this.#router = router
|
|
63
95
|
}
|
|
64
96
|
|
|
65
97
|
/**
|
|
66
|
-
* Configure the trusted
|
|
67
|
-
* pre-resolve `request.ip` once per request — JS reads
|
|
68
|
-
* matching in TS.
|
|
98
|
+
* Configure the trusted proxies. The Rust HyperServer uses them to
|
|
99
|
+
* pre-resolve `request.ip()` / `request.ips()` once per request — JS reads
|
|
100
|
+
* the fields, no CIDR matching in TS.
|
|
101
|
+
*
|
|
102
|
+
* The chain is the socket peer, then the `X-Forwarded-For` entries read
|
|
103
|
+
* right to left; it stops at the first hop that is not a trusted proxy, and
|
|
104
|
+
* its last entry is the client IP. Proxies append what they saw, so the
|
|
105
|
+
* left-most entry — the one a client writes itself — is only reached when
|
|
106
|
+
* every hop after it is trusted. `X-Real-IP` is never read.
|
|
69
107
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* (
|
|
74
|
-
*
|
|
75
|
-
*
|
|
108
|
+
* Entries: addresses or CIDR ranges, IPv4 or IPv6 (`'10.0.0.0/8'`,
|
|
109
|
+
* `'fd00::/8'`), IPv4 netmasks (`'10.0.0.0/255.0.0.0'`), the named ranges
|
|
110
|
+
* `'loopback'`, `'linklocal'`, `'uniquelocal'`, or `'*'` to trust every hop
|
|
111
|
+
* (the left-most entry wins — any client can choose its IP). An entry that is
|
|
112
|
+
* none of these makes the server refuse to start.
|
|
113
|
+
*
|
|
114
|
+
* Empty list (default) is **strict fail-closed**: `X-Forwarded-For` is
|
|
115
|
+
* ignored and only the socket peer is used.
|
|
76
116
|
*/
|
|
77
117
|
trustedProxies(cidrs: string[]): this {
|
|
78
118
|
this.#trustedProxies = [...cidrs]
|
|
@@ -99,16 +139,24 @@ export class Server {
|
|
|
99
139
|
* One handler per server: a second one throws. Two packages both serving
|
|
100
140
|
* gRPC is a conflict to resolve, not to settle by load order.
|
|
101
141
|
*/
|
|
102
|
-
grpc(handler: GrpcHandler): this {
|
|
142
|
+
grpc(handler: GrpcHandler, limits: GrpcLimits = {}): this {
|
|
103
143
|
if (this.#grpcHandler) {
|
|
104
144
|
throw new ReamError('E_SERVER_GRPC_ALREADY_SET', 'A gRPC handler is already registered.', {
|
|
105
145
|
hint: 'Only one package can serve gRPC on the HTTP port.',
|
|
106
146
|
})
|
|
107
147
|
}
|
|
148
|
+
checkGrpcLimit('maxReceiveMessageLength', limits.maxReceiveMessageLength)
|
|
149
|
+
checkGrpcLimit('maxSendMessageLength', limits.maxSendMessageLength)
|
|
108
150
|
this.#grpcHandler = handler
|
|
151
|
+
this.#grpcLimits = { ...limits }
|
|
109
152
|
return this
|
|
110
153
|
}
|
|
111
154
|
|
|
155
|
+
/** The message bounds given with the gRPC handler (consumed by Ignitor at boot). */
|
|
156
|
+
getGrpcLimits(): GrpcLimits {
|
|
157
|
+
return { ...this.#grpcLimits }
|
|
158
|
+
}
|
|
159
|
+
|
|
112
160
|
/** The gRPC handler, if one is set (consumed by Ignitor at boot). */
|
|
113
161
|
getGrpcHandler(): GrpcHandler | undefined {
|
|
114
162
|
return this.#grpcHandler
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gRPC handler as the native server calls it.
|
|
3
|
+
*
|
|
4
|
+
* The native server numbers each call and, separately, reports the numbers
|
|
5
|
+
* of the calls a client gave up on — a reset stream or a closed connection —
|
|
6
|
+
* before the handler answered. This turns those reports into the `signal` of
|
|
7
|
+
* each {@link GrpcCall}, so that a handler can stop the work it started.
|
|
8
|
+
*
|
|
9
|
+
* Calls reach JavaScript in increasing id order, but a cancel travels on its
|
|
10
|
+
* own queue and can arrive before its call: one for an id not seen yet is
|
|
11
|
+
* kept until that call arrives; one for an id already seen and finished is
|
|
12
|
+
* dropped.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { GrpcCall, GrpcHandler, GrpcReply } from './Server.js'
|
|
16
|
+
|
|
17
|
+
/** A call as the native server hands it over: numbered, without a signal. */
|
|
18
|
+
export interface NativeGrpcCall extends Omit<GrpcCall, 'signal'> {
|
|
19
|
+
id: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type NativeGrpcHandler = (call: NativeGrpcCall) => Promise<GrpcReply>
|
|
23
|
+
|
|
24
|
+
export interface CancellableGrpc {
|
|
25
|
+
/** Give to the native server's `onGrpc`. */
|
|
26
|
+
onCall: NativeGrpcHandler
|
|
27
|
+
/** Give to the native server's `onGrpcCancel`. */
|
|
28
|
+
onCancel: (id: number) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function cancellableGrpc(handler: GrpcHandler): CancellableGrpc {
|
|
32
|
+
const running = new Map<number, AbortController>()
|
|
33
|
+
// Cancels that arrived before their call.
|
|
34
|
+
const early = new Set<number>()
|
|
35
|
+
let lastSeen = 0
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
async onCall({ id, ...call }) {
|
|
39
|
+
lastSeen = Math.max(lastSeen, id)
|
|
40
|
+
const controller = new AbortController()
|
|
41
|
+
for (const cancelled of early) {
|
|
42
|
+
if (cancelled > id) continue
|
|
43
|
+
early.delete(cancelled)
|
|
44
|
+
if (cancelled === id) controller.abort()
|
|
45
|
+
}
|
|
46
|
+
running.set(id, controller)
|
|
47
|
+
try {
|
|
48
|
+
return await handler({ ...call, signal: controller.signal })
|
|
49
|
+
} finally {
|
|
50
|
+
running.delete(id)
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
onCancel(id) {
|
|
54
|
+
const controller = running.get(id)
|
|
55
|
+
if (controller !== undefined) controller.abort()
|
|
56
|
+
else if (id > lastSeen) early.add(id)
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|