@fuzdev/fuz_app 0.134.0 → 0.135.0
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/dist/actions/CLAUDE.md +91 -9
- package/dist/actions/register_action_ws.d.ts +23 -3
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +57 -37
- package/dist/actions/transports.d.ts +23 -0
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +23 -0
- package/dist/actions/transports_ws_backend.d.ts +132 -9
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +236 -33
- package/dist/actions/ws_endpoint_spec.d.ts +9 -0
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/actions/ws_outbound_writer.d.ts +206 -0
- package/dist/actions/ws_outbound_writer.d.ts.map +1 -0
- package/dist/actions/ws_outbound_writer.js +431 -0
- package/dist/auth/CLAUDE.md +2 -2
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +3 -1
- package/dist/auth/cell_actions.js +1 -1
- package/dist/auth/role_schema.d.ts +14 -3
- package/dist/auth/role_schema.d.ts.map +1 -1
- package/dist/auth/role_schema.js +14 -2
- package/dist/db/migrate.d.ts +1 -1
- package/dist/db/migrate.js +2 -2
- package/dist/http/CLAUDE.md +2 -1
- package/dist/http/body_limit.d.ts +61 -0
- package/dist/http/body_limit.d.ts.map +1 -0
- package/dist/http/body_limit.js +87 -0
- package/dist/http/db_routes.js +2 -2
- package/dist/http/error_schemas.d.ts +2 -0
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +2 -0
- package/dist/realtime/outbound_budget.d.ts +91 -0
- package/dist/realtime/outbound_budget.d.ts.map +1 -0
- package/dist/realtime/outbound_budget.js +108 -0
- package/dist/realtime/sse.d.ts +36 -3
- package/dist/realtime/sse.d.ts.map +1 -1
- package/dist/realtime/sse.js +58 -6
- package/dist/realtime/sse_constants.d.ts +12 -0
- package/dist/realtime/sse_constants.d.ts.map +1 -1
- package/dist/realtime/sse_constants.js +12 -0
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +3 -6
- package/dist/testing/CLAUDE.md +70 -10
- package/dist/testing/admin_integration.js +1 -1
- package/dist/testing/audit_completeness.d.ts +1 -1
- package/dist/testing/audit_completeness.js +1 -1
- package/dist/testing/audit_drift_guard.d.ts +1 -1
- package/dist/testing/audit_drift_guard.js +1 -1
- package/dist/testing/cross_backend/body_size_smuggling.d.ts +9 -9
- package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size_smuggling.js +110 -60
- package/dist/testing/cross_backend/capabilities.d.ts +18 -15
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +1 -0
- package/dist/testing/cross_backend/default_backend_configs.js +5 -5
- package/dist/testing/cross_backend/role_grant_role_grammar.d.ts +6 -0
- package/dist/testing/cross_backend/role_grant_role_grammar.d.ts.map +1 -0
- package/dist/testing/cross_backend/role_grant_role_grammar.js +117 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +2 -3
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +2 -3
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +8 -4
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +82 -2
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +101 -2
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +9 -7
- package/dist/testing/cross_backend/ws_outbound_overflow.d.ts +29 -0
- package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_outbound_overflow.js +211 -0
- package/dist/testing/integration.js +1 -1
- package/dist/testing/schema_introspect.d.ts +10 -2
- package/dist/testing/schema_introspect.d.ts.map +1 -1
- package/dist/testing/schema_introspect.js +15 -7
- package/dist/testing/transports/ws_transport.d.ts +4 -2
- package/dist/testing/transports/ws_transport.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.js +2 -1
- package/dist/testing/ws_round_trip.d.ts +32 -2
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +26 -4
- package/package.json +1 -1
|
@@ -165,7 +165,7 @@ export const create_cell_actions = (deps) => {
|
|
|
165
165
|
// **in-tx** (only when an authorizer is mounted), reusing the parent row
|
|
166
166
|
// when the parent *is* the root, else one read by `root_id` — so the
|
|
167
167
|
// authorizer stays pure + the read rides the handler's connection (no
|
|
168
|
-
// separate-handle deadlock on the single-connection
|
|
168
|
+
// separate-handle deadlock on the single-connection in-memory drivers).
|
|
169
169
|
const parent_id = input.parent_id ?? null;
|
|
170
170
|
let root_id = null;
|
|
171
171
|
let root_data = null;
|
|
@@ -24,7 +24,17 @@ import { z } from 'zod';
|
|
|
24
24
|
import { type CredentialTypeSchemaResult } from './credential_type_schema.js';
|
|
25
25
|
import { type GrantPathSchemaResult } from './grant_path_schema.js';
|
|
26
26
|
import type { ScopeKindSchemaResult } from './scope_kind_schema.js';
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* The longest role name `RoleName` admits, in characters. A role string is
|
|
29
|
+
* caller-chosen text until it passes the grammar, and the grant verbs record
|
|
30
|
+
* the role they refuse in a failure audit row, so the grammar bounds its
|
|
31
|
+
* length too. Same value as the Rust spine's `ROLE_NAME_LENGTH_MAX`.
|
|
32
|
+
*/
|
|
33
|
+
export declare const ROLE_NAME_LENGTH_MAX = 64;
|
|
34
|
+
/**
|
|
35
|
+
* Valid role name: lowercase letters and underscores, no leading/trailing
|
|
36
|
+
* underscore, at most `ROLE_NAME_LENGTH_MAX` characters.
|
|
37
|
+
*/
|
|
28
38
|
export declare const RoleName: z.ZodString;
|
|
29
39
|
export type RoleName = z.infer<typeof RoleName>;
|
|
30
40
|
/** System-level role. Requires daemon token (filesystem proof). Controls the keep. */
|
|
@@ -78,7 +88,7 @@ export declare const is_builtin_role: (name: string) => name is BuiltinRole;
|
|
|
78
88
|
* `builtin_role_specs_by_name`.
|
|
79
89
|
*/
|
|
80
90
|
export interface RoleSpec {
|
|
81
|
-
/** Unique role name. Must match `RoleName` regex; collisions with builtins throw. */
|
|
91
|
+
/** Unique role name. Must match `RoleName` — its regex and length cap; collisions with builtins throw. */
|
|
82
92
|
name: string;
|
|
83
93
|
/** Admin-UI-facing copy describing the role's intent. */
|
|
84
94
|
description?: string;
|
|
@@ -146,7 +156,8 @@ export interface RoleSchemaResult {
|
|
|
146
156
|
*
|
|
147
157
|
* Construction-time guards (all fire on misconfiguration):
|
|
148
158
|
*
|
|
149
|
-
* 1. Every `consumer_roles[i].name` matches `RoleName` regex
|
|
159
|
+
* 1. Every `consumer_roles[i].name` matches `RoleName` — its regex and its
|
|
160
|
+
* `ROLE_NAME_LENGTH_MAX` length cap.
|
|
150
161
|
* 2. No two consumer roles share a name.
|
|
151
162
|
* 3. No consumer role collides with a builtin (`keeper` / `admin`).
|
|
152
163
|
* 4. When `options.credential_types` is supplied, every entry in
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"role_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/role_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAEN,KAAK,0BAA0B,EAC/B,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAGN,KAAK,qBAAqB,EAC1B,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAEpE,
|
|
1
|
+
{"version":3,"file":"role_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/role_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAEN,KAAK,0BAA0B,EAC/B,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAGN,KAAK,qBAAqB,EAC1B,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAEpE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC;;;GAGG;AACH,eAAO,MAAM,QAAQ,aAMnB,CAAC;AACH,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAIhD,sFAAsF;AACtF,eAAO,MAAM,WAAW,WAAW,CAAC;AAEpC,iEAAiE;AACjE,eAAO,MAAM,UAAU,UAAU,CAAC;AAElC,+CAA+C;AAC/C,eAAO,MAAM,aAAa,8BAAqC,CAAC;AAEhE,yCAAyC;AACzC,eAAO,MAAM,WAAW;;;EAAwB,CAAC;AACjD,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,IAAI,IAAI,WACX,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,WAAW,QAAQ;IACxB,0GAA0G;IAC1G,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,yBAAyB,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAClD;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/C;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CACpC;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,EAAE,WAAW,CAAC,MAAM,EAAE,QAAQ,CAuBnE,CAAC;AAEH,iGAAiG;AACjG,MAAM,WAAW,uBAAuB;IACvC,uGAAuG;IACvG,gBAAgB,CAAC,EAAE,0BAA0B,CAAC;IAC9C,+FAA+F;IAC/F,WAAW,CAAC,EAAE,qBAAqB,CAAC;IACpC,oFAAoF;IACpF,WAAW,CAAC,EAAE,qBAAqB,CAAC;CACpC;AAED,oFAAoF;AACpF,MAAM,WAAW,gBAAgB;IAChC,0GAA0G;IAC1G,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACxB,yEAAyE;IACzE,UAAU,EAAE,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;CAC1C;AAqBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,eAAO,MAAM,kBAAkB,GAC9B,gBAAgB,aAAa,CAAC,QAAQ,CAAC,EACvC,UAAS,uBAA4B,KACnC,gBA2CF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,GAC/B,YAAY,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,EACzC,MAAM,MAAM,EACZ,YAAY,MAAM,KAChB,OAGF,CAAC;AAEF,kFAAkF;AAClF,eAAO,MAAM,0BAA0B,GACtC,YAAY,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,EACzC,YAAY,MAAM,KAChB,KAAK,CAAC,MAAM,CAMd,CAAC"}
|
package/dist/auth/role_schema.js
CHANGED
|
@@ -23,9 +23,20 @@
|
|
|
23
23
|
import { z } from 'zod';
|
|
24
24
|
import { CREDENTIAL_TYPE_DAEMON_TOKEN } from './credential_type_schema.js';
|
|
25
25
|
import { GRANT_PATH_ADMIN, GRANT_PATH_BOOTSTRAP } from './grant_path_schema.js';
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* The longest role name `RoleName` admits, in characters. A role string is
|
|
28
|
+
* caller-chosen text until it passes the grammar, and the grant verbs record
|
|
29
|
+
* the role they refuse in a failure audit row, so the grammar bounds its
|
|
30
|
+
* length too. Same value as the Rust spine's `ROLE_NAME_LENGTH_MAX`.
|
|
31
|
+
*/
|
|
32
|
+
export const ROLE_NAME_LENGTH_MAX = 64;
|
|
33
|
+
/**
|
|
34
|
+
* Valid role name: lowercase letters and underscores, no leading/trailing
|
|
35
|
+
* underscore, at most `ROLE_NAME_LENGTH_MAX` characters.
|
|
36
|
+
*/
|
|
27
37
|
export const RoleName = z
|
|
28
38
|
.string()
|
|
39
|
+
.max(ROLE_NAME_LENGTH_MAX, `Role names must be at most ${ROLE_NAME_LENGTH_MAX} characters`)
|
|
29
40
|
.regex(/^[a-z][a-z_]*[a-z]$|^[a-z]$/, 'Role names must be lowercase letters and underscores (a-z_), no leading/trailing underscore');
|
|
30
41
|
// Builtin roles — provided by fuz_app, always available.
|
|
31
42
|
/** System-level role. Requires daemon token (filesystem proof). Controls the keep. */
|
|
@@ -99,7 +110,8 @@ const validate_registry_membership = (role_name, field, values, registry) => {
|
|
|
99
110
|
*
|
|
100
111
|
* Construction-time guards (all fire on misconfiguration):
|
|
101
112
|
*
|
|
102
|
-
* 1. Every `consumer_roles[i].name` matches `RoleName` regex
|
|
113
|
+
* 1. Every `consumer_roles[i].name` matches `RoleName` — its regex and its
|
|
114
|
+
* `ROLE_NAME_LENGTH_MAX` length cap.
|
|
103
115
|
* 2. No two consumer roles share a name.
|
|
104
116
|
* 3. No consumer role collides with a builtin (`keeper` / `admin`).
|
|
105
117
|
* 4. When `options.credential_types` is supplied, every entry in
|
package/dist/db/migrate.d.ts
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
* tx atomicity + the `(namespace, name)` PK on `schema_version`: the
|
|
51
51
|
* loser's INSERT hits a PK violation, the chain-tx rolls back, and the
|
|
52
52
|
* next boot reads the committed state and proceeds cleanly. Environments
|
|
53
|
-
* without `pg_advisory_lock` (
|
|
53
|
+
* without `pg_advisory_lock` (pglet) silently fall through.
|
|
54
54
|
*
|
|
55
55
|
* @module
|
|
56
56
|
*/
|
package/dist/db/migrate.js
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
* tx atomicity + the `(namespace, name)` PK on `schema_version`: the
|
|
51
51
|
* loser's INSERT hits a PK violation, the chain-tx rolls back, and the
|
|
52
52
|
* next boot reads the committed state and proceeds cleanly. Environments
|
|
53
|
-
* without `pg_advisory_lock` (
|
|
53
|
+
* without `pg_advisory_lock` (pglet) silently fall through.
|
|
54
54
|
*
|
|
55
55
|
* @module
|
|
56
56
|
*/
|
|
@@ -99,7 +99,7 @@ const namespace_lock_key = (namespace) => {
|
|
|
99
99
|
* Run `fn` with the namespace's advisory lock held.
|
|
100
100
|
*
|
|
101
101
|
* Acquire / release are best-effort and silently fall through in
|
|
102
|
-
* environments without `pg_advisory_lock` (
|
|
102
|
+
* environments without `pg_advisory_lock` (pglet). The
|
|
103
103
|
* `finally` ensures release on any throw from `fn`.
|
|
104
104
|
*/
|
|
105
105
|
const with_namespace_lock = async (db, namespace, fn) => {
|
package/dist/http/CLAUDE.md
CHANGED
|
@@ -19,6 +19,7 @@ effects, see ../../../docs/architecture.md.
|
|
|
19
19
|
- `http/error_schemas.ts` — `ERROR_*` constants, standard error shapes, `derive_error_schemas`.
|
|
20
20
|
- `http/schema_helpers.ts` — shared Zod introspection (null/void/strict/surface/merge/middleware-applies) + `to_void_params` / `to_input_params` / `safe_parse_dropping_unknown_keys`.
|
|
21
21
|
- `http/middleware_spec.ts` — `MiddlewareSpec` interface.
|
|
22
|
+
- `http/body_limit.ts` — `create_body_limit_middleware(max_size)`: the `413` body cap `create_app_server` mounts. Refuses on `Content-Length` without reading `c.req.raw.body` and answers `Connection: close` — `413` past the cap, `400` `ERROR_INVALID_CONTENT_LENGTH` when the value is not a plain run of ASCII digits (a length the cap cannot read, which Hono's `bodyLimit` would pass uncounted) — else falls through to Hono's streaming `bodyLimit` (no `Content-Length`, or one beside `Transfer-Encoding`; that `413` carries no `Connection: close`). Mounted with `app.use`, not as a `MiddlewareSpec`, so neither status appears in a route's merged error schemas. After the header `413` Node closes at once (the header is what makes `@hono/node-server` do so), Deno closes once the declared body has arrived, and Bun keeps the connection and serves the next request (../../../docs/security.md §Body Size Limiting).
|
|
22
23
|
- `http/surface.ts` — `AppSurface`, `AppSurfaceSpec`, `generate_app_surface`, diagnostics.
|
|
23
24
|
- `http/surface_query.ts` — pure filters/groupings over `AppSurface`.
|
|
24
25
|
- `http/proxy.ts` — trusted-proxy middleware, CIDR parsing, rightmost-first XFF resolution. Pulls `hono/utils/ipaddr` for CIDR matching.
|
|
@@ -207,7 +208,7 @@ confirmation.
|
|
|
207
208
|
### `ERROR_*` constants by category
|
|
208
209
|
|
|
209
210
|
- **Validation**: `ERROR_INVALID_REQUEST_BODY`, `ERROR_INVALID_JSON_BODY`, `ERROR_INVALID_ROUTE_PARAMS`, `ERROR_INVALID_QUERY_PARAMS`
|
|
210
|
-
- **Auth**: `ERROR_AUTHENTICATION_REQUIRED`, `ERROR_INSUFFICIENT_PERMISSIONS`, `ERROR_CREDENTIAL_TYPE_REQUIRED`, `ERROR_RATE_LIMIT_EXCEEDED`, `ERROR_INVALID_CREDENTIALS`, `ERROR_PAYLOAD_TOO_LARGE`
|
|
211
|
+
- **Auth**: `ERROR_AUTHENTICATION_REQUIRED`, `ERROR_INSUFFICIENT_PERMISSIONS`, `ERROR_CREDENTIAL_TYPE_REQUIRED`, `ERROR_RATE_LIMIT_EXCEEDED`, `ERROR_INVALID_CREDENTIALS`, `ERROR_PAYLOAD_TOO_LARGE`, `ERROR_INVALID_CONTENT_LENGTH`
|
|
211
212
|
- **Origin + bearer**: `ERROR_FORBIDDEN_ORIGIN`, `ERROR_INVALID_TOKEN`, `ERROR_ACCOUNT_NOT_FOUND`
|
|
212
213
|
- **Keeper/daemon**: `ERROR_KEEPER_ACCOUNT_NOT_FOUND`
|
|
213
214
|
- **Bootstrap**: `ERROR_ALREADY_BOOTSTRAPPED`, `ERROR_TOKEN_FILE_MISSING`
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request body size limit — rejects an oversized body with `413`, and a
|
|
3
|
+
* `Content-Length` it cannot read with `400`, before auth and validation.
|
|
4
|
+
*
|
|
5
|
+
* Wraps Hono's `bodyLimit` with a header-first check:
|
|
6
|
+
*
|
|
7
|
+
* - A `Content-Length` that is not a plain run of ASCII digits (`abc`, `-1`,
|
|
8
|
+
* `+5`, `1e9`, `0x1F4`, empty, or a combined `5, 500`) is refused with `400`
|
|
9
|
+
* `{error: 'invalid_content_length'}`. A cap cannot be applied to a length
|
|
10
|
+
* that does not parse, and `bodyLimit` passes such a request on uncounted.
|
|
11
|
+
* The runtimes' HTTP parsers answer their own bodiless `400` to most of
|
|
12
|
+
* these before the application sees them, so this branch is defense in
|
|
13
|
+
* depth where they do and the refusal where they do not: Node's refuses
|
|
14
|
+
* them all; Deno hands over a repeated equal header as one combined value
|
|
15
|
+
* (`50, 50`); Bun hands over an empty value and a repeated header, equal or
|
|
16
|
+
* differing (`5, 50`), framing the body on the first (observed on Node
|
|
17
|
+
* 24.14.1, Deno 2.8.3, Bun 1.3.11). The Rust spine's parser (hyper) refuses
|
|
18
|
+
* a malformed value and differing repeats, and accepts an equal repeat as
|
|
19
|
+
* the one value.
|
|
20
|
+
* - A request whose `Content-Length` exceeds the cap is refused with `413`
|
|
21
|
+
* `{error: 'payload_too_large'}`, whatever its method. A digit run of any
|
|
22
|
+
* length compares as a number, so one too long to represent exactly is over
|
|
23
|
+
* the cap, never unreadable.
|
|
24
|
+
* - Both refusals answer on the header alone — without touching
|
|
25
|
+
* `c.req.raw.body` — and carry `Connection: close`.
|
|
26
|
+
* - A request with a well-formed `Content-Length` beside `Transfer-Encoding`,
|
|
27
|
+
* or with no `Content-Length`, falls through to `bodyLimit`'s streaming
|
|
28
|
+
* count: with `Transfer-Encoding` the declared length does not frame the
|
|
29
|
+
* body, so the bytes that arrive are what is measured. That `413` is sent
|
|
30
|
+
* once more than the cap has been read and carries no `Connection: close`.
|
|
31
|
+
* The Node, Deno and Bun parsers refuse a request carrying both headers
|
|
32
|
+
* with their own `400`; hyper accepts one and frames its body as chunked.
|
|
33
|
+
* - A well-formed `Content-Length` within the cap is trusted to frame the
|
|
34
|
+
* body, as the runtime's HTTP parser enforces.
|
|
35
|
+
*
|
|
36
|
+
* What the connection does after the header `413` is the runtime's
|
|
37
|
+
* (`docs/security.md` §"Body Size Limiting"):
|
|
38
|
+
*
|
|
39
|
+
* - **Node** (`@hono/node-server`) closes at once, the body unread. The
|
|
40
|
+
* `Connection: close` header is what does it: without one the adapter
|
|
41
|
+
* holds the connection for a drain window and then closes or, the body
|
|
42
|
+
* untouched and arriving within the window, reads it off and serves the
|
|
43
|
+
* next request.
|
|
44
|
+
* - **Deno** closes once the whole declared body has arrived and holds the
|
|
45
|
+
* connection open while a client withholds it; a request pipelined behind
|
|
46
|
+
* the body is not served. Neither the header nor what the handler does with
|
|
47
|
+
* the body changes that (observed on Deno 2.8.3).
|
|
48
|
+
* - **Bun** keeps the connection, skips the declared body, and serves the
|
|
49
|
+
* next request on it (observed on Bun 1.3.11).
|
|
50
|
+
*
|
|
51
|
+
* @module
|
|
52
|
+
*/
|
|
53
|
+
import type { MiddlewareHandler } from 'hono';
|
|
54
|
+
/**
|
|
55
|
+
* Create the body size limit middleware.
|
|
56
|
+
*
|
|
57
|
+
* @param max_size - the largest accepted body, in bytes
|
|
58
|
+
* @returns middleware answering `413` `{error: 'payload_too_large'}` past the cap and `400` `{error: 'invalid_content_length'}` to a `Content-Length` that is not a run of digits
|
|
59
|
+
*/
|
|
60
|
+
export declare const create_body_limit_middleware: (max_size: number) => MiddlewareHandler;
|
|
61
|
+
//# sourceMappingURL=body_limit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"body_limit.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/body_limit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAQ9C;;;;;GAKG;AACH,eAAO,MAAM,4BAA4B,GAAI,UAAU,MAAM,KAAG,iBAwB/D,CAAC"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request body size limit — rejects an oversized body with `413`, and a
|
|
3
|
+
* `Content-Length` it cannot read with `400`, before auth and validation.
|
|
4
|
+
*
|
|
5
|
+
* Wraps Hono's `bodyLimit` with a header-first check:
|
|
6
|
+
*
|
|
7
|
+
* - A `Content-Length` that is not a plain run of ASCII digits (`abc`, `-1`,
|
|
8
|
+
* `+5`, `1e9`, `0x1F4`, empty, or a combined `5, 500`) is refused with `400`
|
|
9
|
+
* `{error: 'invalid_content_length'}`. A cap cannot be applied to a length
|
|
10
|
+
* that does not parse, and `bodyLimit` passes such a request on uncounted.
|
|
11
|
+
* The runtimes' HTTP parsers answer their own bodiless `400` to most of
|
|
12
|
+
* these before the application sees them, so this branch is defense in
|
|
13
|
+
* depth where they do and the refusal where they do not: Node's refuses
|
|
14
|
+
* them all; Deno hands over a repeated equal header as one combined value
|
|
15
|
+
* (`50, 50`); Bun hands over an empty value and a repeated header, equal or
|
|
16
|
+
* differing (`5, 50`), framing the body on the first (observed on Node
|
|
17
|
+
* 24.14.1, Deno 2.8.3, Bun 1.3.11). The Rust spine's parser (hyper) refuses
|
|
18
|
+
* a malformed value and differing repeats, and accepts an equal repeat as
|
|
19
|
+
* the one value.
|
|
20
|
+
* - A request whose `Content-Length` exceeds the cap is refused with `413`
|
|
21
|
+
* `{error: 'payload_too_large'}`, whatever its method. A digit run of any
|
|
22
|
+
* length compares as a number, so one too long to represent exactly is over
|
|
23
|
+
* the cap, never unreadable.
|
|
24
|
+
* - Both refusals answer on the header alone — without touching
|
|
25
|
+
* `c.req.raw.body` — and carry `Connection: close`.
|
|
26
|
+
* - A request with a well-formed `Content-Length` beside `Transfer-Encoding`,
|
|
27
|
+
* or with no `Content-Length`, falls through to `bodyLimit`'s streaming
|
|
28
|
+
* count: with `Transfer-Encoding` the declared length does not frame the
|
|
29
|
+
* body, so the bytes that arrive are what is measured. That `413` is sent
|
|
30
|
+
* once more than the cap has been read and carries no `Connection: close`.
|
|
31
|
+
* The Node, Deno and Bun parsers refuse a request carrying both headers
|
|
32
|
+
* with their own `400`; hyper accepts one and frames its body as chunked.
|
|
33
|
+
* - A well-formed `Content-Length` within the cap is trusted to frame the
|
|
34
|
+
* body, as the runtime's HTTP parser enforces.
|
|
35
|
+
*
|
|
36
|
+
* What the connection does after the header `413` is the runtime's
|
|
37
|
+
* (`docs/security.md` §"Body Size Limiting"):
|
|
38
|
+
*
|
|
39
|
+
* - **Node** (`@hono/node-server`) closes at once, the body unread. The
|
|
40
|
+
* `Connection: close` header is what does it: without one the adapter
|
|
41
|
+
* holds the connection for a drain window and then closes or, the body
|
|
42
|
+
* untouched and arriving within the window, reads it off and serves the
|
|
43
|
+
* next request.
|
|
44
|
+
* - **Deno** closes once the whole declared body has arrived and holds the
|
|
45
|
+
* connection open while a client withholds it; a request pipelined behind
|
|
46
|
+
* the body is not served. Neither the header nor what the handler does with
|
|
47
|
+
* the body changes that (observed on Deno 2.8.3).
|
|
48
|
+
* - **Bun** keeps the connection, skips the declared body, and serves the
|
|
49
|
+
* next request on it (observed on Bun 1.3.11).
|
|
50
|
+
*
|
|
51
|
+
* @module
|
|
52
|
+
*/
|
|
53
|
+
import { bodyLimit } from 'hono/body-limit';
|
|
54
|
+
import { ERROR_INVALID_CONTENT_LENGTH, ERROR_PAYLOAD_TOO_LARGE } from './error_schemas.js';
|
|
55
|
+
/** A well-formed `Content-Length` value: one or more ASCII digits and nothing else. */
|
|
56
|
+
const CONTENT_LENGTH_DIGITS = /^[0-9]+$/;
|
|
57
|
+
/**
|
|
58
|
+
* Create the body size limit middleware.
|
|
59
|
+
*
|
|
60
|
+
* @param max_size - the largest accepted body, in bytes
|
|
61
|
+
* @returns middleware answering `413` `{error: 'payload_too_large'}` past the cap and `400` `{error: 'invalid_content_length'}` to a `Content-Length` that is not a run of digits
|
|
62
|
+
*/
|
|
63
|
+
export const create_body_limit_middleware = (max_size) => {
|
|
64
|
+
const streaming_limit = bodyLimit({
|
|
65
|
+
maxSize: max_size,
|
|
66
|
+
onError: (c) => c.json({ error: ERROR_PAYLOAD_TOO_LARGE }, 413)
|
|
67
|
+
});
|
|
68
|
+
return async (c, next) => {
|
|
69
|
+
const content_length = c.req.header('content-length');
|
|
70
|
+
if (content_length !== undefined) {
|
|
71
|
+
if (!CONTENT_LENGTH_DIGITS.test(content_length)) {
|
|
72
|
+
// a length that does not parse cannot be capped, and `bodyLimit`
|
|
73
|
+
// would pass the request on without counting it
|
|
74
|
+
return c.json({ error: ERROR_INVALID_CONTENT_LENGTH }, 400, { connection: 'close' });
|
|
75
|
+
}
|
|
76
|
+
// `Number` of a digit run is never `NaN`; past exact precision it is
|
|
77
|
+
// still a huge number (or `Infinity`), so it compares as over the cap
|
|
78
|
+
if (!c.req.header('transfer-encoding') && Number(content_length) > max_size) {
|
|
79
|
+
// refused on the header alone, the body untouched; `Connection: close`
|
|
80
|
+
// makes `@hono/node-server` close at once instead of holding the
|
|
81
|
+
// connection or serving the request behind the body
|
|
82
|
+
return c.json({ error: ERROR_PAYLOAD_TOO_LARGE }, 413, { connection: 'close' });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return streaming_limit(c, next);
|
|
86
|
+
};
|
|
87
|
+
};
|
package/dist/http/db_routes.js
CHANGED
|
@@ -425,8 +425,8 @@ export const create_db_route_specs = (deps, options) => {
|
|
|
425
425
|
}
|
|
426
426
|
// 23503 foreign_key_violation / 23001 restrict_violation — the
|
|
427
427
|
// same referencing-row refusal: PG 18+ raises 23001 for `ON
|
|
428
|
-
// DELETE RESTRICT` constraints where PG ≤17
|
|
429
|
-
//
|
|
428
|
+
// DELETE RESTRICT` constraints where PG ≤17 raises 23503 for
|
|
429
|
+
// both.
|
|
430
430
|
if (code === '23503' || code === '23001') {
|
|
431
431
|
const pg_err = err;
|
|
432
432
|
log?.warn('Foreign key violation:', pg_err.detail, pg_err.constraint);
|
|
@@ -53,6 +53,8 @@ export declare const ERROR_RATE_LIMIT_EXCEEDED: "rate_limit_exceeded";
|
|
|
53
53
|
export declare const ERROR_INVALID_CREDENTIALS: "invalid_credentials";
|
|
54
54
|
/** Request body exceeds the maximum allowed size. */
|
|
55
55
|
export declare const ERROR_PAYLOAD_TOO_LARGE: "payload_too_large";
|
|
56
|
+
/** Request `Content-Length` header is not a plain run of ASCII digits. */
|
|
57
|
+
export declare const ERROR_INVALID_CONTENT_LENGTH: "invalid_content_length";
|
|
56
58
|
/** Request origin not in allowlist. */
|
|
57
59
|
export declare const ERROR_FORBIDDEN_ORIGIN: "forbidden_origin";
|
|
58
60
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"error_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/error_schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAI9D,0CAA0C;AAC1C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,uDAAuD;AACvD,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;AAEpE,6CAA6C;AAC7C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,8CAA8C;AAC9C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAI1E,wCAAwC;AACxC,eAAO,MAAM,6BAA6B,EAAG,yBAAkC,CAAC;AAEhF,+CAA+C;AAC/C,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAElF;;;;;;;;GAQG;AACH,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAElF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,yCAAyC;AACzC,eAAO,MAAM,yBAAyB,EAAG,qBAA8B,CAAC;AAExE,sFAAsF;AACtF,eAAO,MAAM,yBAAyB,EAAG,qBAA8B,CAAC;AAExE,qDAAqD;AACrD,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"error_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/error_schemas.ts"],"names":[],"mappings":"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"}
|
|
@@ -55,6 +55,8 @@ export const ERROR_RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded';
|
|
|
55
55
|
export const ERROR_INVALID_CREDENTIALS = 'invalid_credentials';
|
|
56
56
|
/** Request body exceeds the maximum allowed size. */
|
|
57
57
|
export const ERROR_PAYLOAD_TOO_LARGE = 'payload_too_large';
|
|
58
|
+
/** Request `Content-Length` header is not a plain run of ASCII digits. */
|
|
59
|
+
export const ERROR_INVALID_CONTENT_LENGTH = 'invalid_content_length';
|
|
58
60
|
// --- Origin & bearer token verification ---
|
|
59
61
|
/** Request origin not in allowlist. */
|
|
60
62
|
export const ERROR_FORBIDDEN_ORIGIN = 'forbidden_origin';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The byte budget of the frames waiting to be written to one connection — a
|
|
3
|
+
* WebSocket or an SSE stream.
|
|
4
|
+
*
|
|
5
|
+
* A producer never waits on a recipient: a broadcast is synchronous and called
|
|
6
|
+
* from anywhere, so a client that stopped reading makes the server hold what it
|
|
7
|
+
* has not taken. `OutboundBudget` is the bound on that, and it is accounting
|
|
8
|
+
* only — its owner holds the frames and decides what "waiting" means for its
|
|
9
|
+
* connection (`WsOutboundWriter` for a WebSocket, `create_sse_response` for an
|
|
10
|
+
* SSE stream).
|
|
11
|
+
*
|
|
12
|
+
* - **The charge.** Each waiting frame is charged its payload bytes — the
|
|
13
|
+
* UTF-8 length of its text — plus `OUTBOUND_FRAME_OVERHEAD_BYTES`.
|
|
14
|
+
* - **The rule.** A frame is admitted when the charges already waiting plus
|
|
15
|
+
* its own stay within the budget — and, whatever its size, when nothing is
|
|
16
|
+
* waiting, so a single message larger than the whole budget is deliverable.
|
|
17
|
+
*
|
|
18
|
+
* Nothing counts frames: any number may wait while their charges fit. The twin
|
|
19
|
+
* of the Rust spine's `fuz_realtime` outbound queue, with the same charge and
|
|
20
|
+
* the same rule.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* What a waiting frame is charged against its budget on top of its payload
|
|
26
|
+
* bytes.
|
|
27
|
+
*
|
|
28
|
+
* It stands for what a queued frame costs beyond its text, so a frame with
|
|
29
|
+
* little or no payload is not free: a budget of `B` never holds more than `B`
|
|
30
|
+
* divided by this many frames, whatever their size. A fixed charge, not a
|
|
31
|
+
* measurement of memory use. Same value as the Rust spine's constant.
|
|
32
|
+
*/
|
|
33
|
+
export declare const OUTBOUND_FRAME_OVERHEAD_BYTES = 64;
|
|
34
|
+
/** What was waiting when a frame did not fit its connection's budget. */
|
|
35
|
+
export interface OutboundOverflow {
|
|
36
|
+
/** Payload bytes waiting, without the per-frame overhead the budget also charges. */
|
|
37
|
+
queued_bytes: number;
|
|
38
|
+
/** Frames waiting. */
|
|
39
|
+
queued_frames: number;
|
|
40
|
+
/** Payload bytes of the frame that did not fit, without its overhead. */
|
|
41
|
+
frame_bytes: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The accounting for one connection's waiting frames (module doc).
|
|
45
|
+
*
|
|
46
|
+
* `admit` charges a frame that fits, `release` frees the charge of one that is
|
|
47
|
+
* no longer waiting, and `clear` frees them all.
|
|
48
|
+
*/
|
|
49
|
+
export declare class OutboundBudget {
|
|
50
|
+
#private;
|
|
51
|
+
/** The budget, in bytes. */
|
|
52
|
+
readonly max_bytes: number;
|
|
53
|
+
/**
|
|
54
|
+
* @param max_bytes - the budget, in bytes; `0` admits one frame at a time
|
|
55
|
+
* @throws Error when `max_bytes` is not a non-negative safe integer
|
|
56
|
+
*/
|
|
57
|
+
constructor(max_bytes: number);
|
|
58
|
+
/** Payload bytes waiting, without the per-frame overhead. */
|
|
59
|
+
get queued_bytes(): number;
|
|
60
|
+
/** Frames waiting. */
|
|
61
|
+
get queued_frames(): number;
|
|
62
|
+
/**
|
|
63
|
+
* Charge a frame of `frame_bytes` payload bytes if it fits: when nothing is
|
|
64
|
+
* waiting, or when the charges waiting plus its own stay within the budget.
|
|
65
|
+
*
|
|
66
|
+
* @param frame_bytes - the frame's payload length in UTF-8 bytes
|
|
67
|
+
* @returns whether the frame was admitted and charged
|
|
68
|
+
* @mutates this - adds the frame's charge when it is admitted
|
|
69
|
+
*/
|
|
70
|
+
admit(frame_bytes: number): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Free the charge of one admitted frame that is no longer waiting.
|
|
73
|
+
*
|
|
74
|
+
* @param frame_bytes - the payload length the frame was admitted with
|
|
75
|
+
* @mutates this - removes the frame's charge
|
|
76
|
+
*/
|
|
77
|
+
release(frame_bytes: number): void;
|
|
78
|
+
/**
|
|
79
|
+
* Free every charge — the owner discarded what was waiting.
|
|
80
|
+
*
|
|
81
|
+
* @mutates this - removes every charge
|
|
82
|
+
*/
|
|
83
|
+
clear(): void;
|
|
84
|
+
/**
|
|
85
|
+
* What is waiting, for the log line of a frame that did not fit.
|
|
86
|
+
*
|
|
87
|
+
* @param frame_bytes - the payload length of the frame that did not fit
|
|
88
|
+
*/
|
|
89
|
+
to_overflow(frame_bytes: number): OutboundOverflow;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=outbound_budget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outbound_budget.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/outbound_budget.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAEhD,yEAAyE;AACzE,MAAM,WAAW,gBAAgB;IAChC,qFAAqF;IACrF,YAAY,EAAE,MAAM,CAAC;IACrB,sBAAsB;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,yEAAyE;IACzE,WAAW,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,qBAAa,cAAc;;IAC1B,4BAA4B;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAK3B;;;OAGG;gBACS,SAAS,EAAE,MAAM;IAO7B,6DAA6D;IAC7D,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,sBAAsB;IACtB,IAAI,aAAa,IAAI,MAAM,CAE1B;IAED;;;;;;;OAOG;IACH,KAAK,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO;IAanC;;;;;OAKG;IACH,OAAO,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI;IAKlC;;;;OAIG;IACH,KAAK,IAAI,IAAI;IAKb;;;;OAIG;IACH,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,gBAAgB;CAGlD"}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The byte budget of the frames waiting to be written to one connection — a
|
|
3
|
+
* WebSocket or an SSE stream.
|
|
4
|
+
*
|
|
5
|
+
* A producer never waits on a recipient: a broadcast is synchronous and called
|
|
6
|
+
* from anywhere, so a client that stopped reading makes the server hold what it
|
|
7
|
+
* has not taken. `OutboundBudget` is the bound on that, and it is accounting
|
|
8
|
+
* only — its owner holds the frames and decides what "waiting" means for its
|
|
9
|
+
* connection (`WsOutboundWriter` for a WebSocket, `create_sse_response` for an
|
|
10
|
+
* SSE stream).
|
|
11
|
+
*
|
|
12
|
+
* - **The charge.** Each waiting frame is charged its payload bytes — the
|
|
13
|
+
* UTF-8 length of its text — plus `OUTBOUND_FRAME_OVERHEAD_BYTES`.
|
|
14
|
+
* - **The rule.** A frame is admitted when the charges already waiting plus
|
|
15
|
+
* its own stay within the budget — and, whatever its size, when nothing is
|
|
16
|
+
* waiting, so a single message larger than the whole budget is deliverable.
|
|
17
|
+
*
|
|
18
|
+
* Nothing counts frames: any number may wait while their charges fit. The twin
|
|
19
|
+
* of the Rust spine's `fuz_realtime` outbound queue, with the same charge and
|
|
20
|
+
* the same rule.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* What a waiting frame is charged against its budget on top of its payload
|
|
26
|
+
* bytes.
|
|
27
|
+
*
|
|
28
|
+
* It stands for what a queued frame costs beyond its text, so a frame with
|
|
29
|
+
* little or no payload is not free: a budget of `B` never holds more than `B`
|
|
30
|
+
* divided by this many frames, whatever their size. A fixed charge, not a
|
|
31
|
+
* measurement of memory use. Same value as the Rust spine's constant.
|
|
32
|
+
*/
|
|
33
|
+
export const OUTBOUND_FRAME_OVERHEAD_BYTES = 64;
|
|
34
|
+
/**
|
|
35
|
+
* The accounting for one connection's waiting frames (module doc).
|
|
36
|
+
*
|
|
37
|
+
* `admit` charges a frame that fits, `release` frees the charge of one that is
|
|
38
|
+
* no longer waiting, and `clear` frees them all.
|
|
39
|
+
*/
|
|
40
|
+
export class OutboundBudget {
|
|
41
|
+
/** The budget, in bytes. */
|
|
42
|
+
max_bytes;
|
|
43
|
+
#bytes = 0;
|
|
44
|
+
#frames = 0;
|
|
45
|
+
/**
|
|
46
|
+
* @param max_bytes - the budget, in bytes; `0` admits one frame at a time
|
|
47
|
+
* @throws Error when `max_bytes` is not a non-negative safe integer
|
|
48
|
+
*/
|
|
49
|
+
constructor(max_bytes) {
|
|
50
|
+
if (!Number.isSafeInteger(max_bytes) || max_bytes < 0) {
|
|
51
|
+
throw new Error(`OutboundBudget: max_bytes must be a non-negative integer, got ${max_bytes}`);
|
|
52
|
+
}
|
|
53
|
+
this.max_bytes = max_bytes;
|
|
54
|
+
}
|
|
55
|
+
/** Payload bytes waiting, without the per-frame overhead. */
|
|
56
|
+
get queued_bytes() {
|
|
57
|
+
return this.#bytes;
|
|
58
|
+
}
|
|
59
|
+
/** Frames waiting. */
|
|
60
|
+
get queued_frames() {
|
|
61
|
+
return this.#frames;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Charge a frame of `frame_bytes` payload bytes if it fits: when nothing is
|
|
65
|
+
* waiting, or when the charges waiting plus its own stay within the budget.
|
|
66
|
+
*
|
|
67
|
+
* @param frame_bytes - the frame's payload length in UTF-8 bytes
|
|
68
|
+
* @returns whether the frame was admitted and charged
|
|
69
|
+
* @mutates this - adds the frame's charge when it is admitted
|
|
70
|
+
*/
|
|
71
|
+
admit(frame_bytes) {
|
|
72
|
+
if (this.#frames !== 0 &&
|
|
73
|
+
this.#bytes + frame_bytes + (this.#frames + 1) * OUTBOUND_FRAME_OVERHEAD_BYTES >
|
|
74
|
+
this.max_bytes) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
this.#bytes += frame_bytes;
|
|
78
|
+
this.#frames++;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Free the charge of one admitted frame that is no longer waiting.
|
|
83
|
+
*
|
|
84
|
+
* @param frame_bytes - the payload length the frame was admitted with
|
|
85
|
+
* @mutates this - removes the frame's charge
|
|
86
|
+
*/
|
|
87
|
+
release(frame_bytes) {
|
|
88
|
+
this.#bytes -= frame_bytes;
|
|
89
|
+
this.#frames--;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Free every charge — the owner discarded what was waiting.
|
|
93
|
+
*
|
|
94
|
+
* @mutates this - removes every charge
|
|
95
|
+
*/
|
|
96
|
+
clear() {
|
|
97
|
+
this.#bytes = 0;
|
|
98
|
+
this.#frames = 0;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* What is waiting, for the log line of a frame that did not fit.
|
|
102
|
+
*
|
|
103
|
+
* @param frame_bytes - the payload length of the frame that did not fit
|
|
104
|
+
*/
|
|
105
|
+
to_overflow(frame_bytes) {
|
|
106
|
+
return { queued_bytes: this.#bytes, queued_frames: this.#frames, frame_bytes };
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/realtime/sse.d.ts
CHANGED
|
@@ -4,6 +4,22 @@
|
|
|
4
4
|
* Provides generic helpers for creating SSE response streams
|
|
5
5
|
* and a notification type aligned with JSON-RPC 2.0.
|
|
6
6
|
*
|
|
7
|
+
* ## Outbound bound
|
|
8
|
+
*
|
|
9
|
+
* `SseStream.send` never waits on its client: each frame is written to the
|
|
10
|
+
* response stream, which holds it until the client reads. What a stream holds
|
|
11
|
+
* unread is kept within `SSE_OUTBOUND_QUEUE_BYTES` — each frame charged its
|
|
12
|
+
* bytes as written (`data: ` prefix and terminator included) plus
|
|
13
|
+
* `OUTBOUND_FRAME_OVERHEAD_BYTES`, and a frame always accepted when nothing is
|
|
14
|
+
* unread, so a single frame larger than the budget is still deliverable. A
|
|
15
|
+
* frame that does not fit **ends the stream**: it is not written, what was
|
|
16
|
+
* unread is dropped, the body ends with no final frame, and the stream's
|
|
17
|
+
* `on_close` listeners run. One `warn` line names the stream's account (when
|
|
18
|
+
* the caller passed it) and what was unread; no audit row is written, since each would itself be fanned to every
|
|
19
|
+
* stream. A client that is reading reconnects and misses the frames in
|
|
20
|
+
* between. The twin of the Rust spine's `fuz_realtime` SSE bound — the same
|
|
21
|
+
* budget and charge.
|
|
22
|
+
*
|
|
7
23
|
* @module
|
|
8
24
|
*/
|
|
9
25
|
import type { Context } from 'hono';
|
|
@@ -15,7 +31,11 @@ import type { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
|
15
31
|
* Transport-agnostic — works with any serializable type.
|
|
16
32
|
*/
|
|
17
33
|
export interface SseStream<T = unknown> {
|
|
18
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Send data to the client as a JSON SSE event. Never waits: a frame the
|
|
36
|
+
* stream's outbound budget does not fit ends the stream instead (module doc,
|
|
37
|
+
* "Outbound bound").
|
|
38
|
+
*/
|
|
19
39
|
send: (data: T) => void;
|
|
20
40
|
/** Send a comment (for keep-alive pings). */
|
|
21
41
|
comment: (text: string) => void;
|
|
@@ -40,6 +60,14 @@ export interface SseNotification {
|
|
|
40
60
|
/** Method-specific payload. */
|
|
41
61
|
params: unknown;
|
|
42
62
|
}
|
|
63
|
+
/** Options for `create_sse_response`. */
|
|
64
|
+
export interface SseResponseOptions {
|
|
65
|
+
/**
|
|
66
|
+
* The account the stream belongs to, named in the warning logged when the
|
|
67
|
+
* stream ends on its outbound budget.
|
|
68
|
+
*/
|
|
69
|
+
account_id?: string | null;
|
|
70
|
+
}
|
|
43
71
|
/**
|
|
44
72
|
* Create an SSE response for a Hono context.
|
|
45
73
|
*
|
|
@@ -59,10 +87,15 @@ export interface SseNotification {
|
|
|
59
87
|
* Uses `hono_stream.write()` directly (not `writeSSE`) to avoid
|
|
60
88
|
* Hono's HTML callback resolution — keeps the same `data: JSON\n\n` format.
|
|
61
89
|
*
|
|
62
|
-
*
|
|
90
|
+
* What the stream holds unread is bounded by `SSE_OUTBOUND_QUEUE_BYTES`; a
|
|
91
|
+
* frame past it ends the stream (module doc, "Outbound bound").
|
|
92
|
+
*
|
|
93
|
+
* @param log - logger for serialization and `on_close` listener errors, and
|
|
94
|
+
* for the warning when the stream ends on its outbound budget
|
|
95
|
+
* @param options - what the overflow warning names
|
|
63
96
|
* @returns object with the streaming `Response` and an `SseStream` controller
|
|
64
97
|
*/
|
|
65
|
-
export declare const create_sse_response: <T = unknown>(c: Context, log: Logger) => {
|
|
98
|
+
export declare const create_sse_response: <T = unknown>(c: Context, log: Logger, options?: SseResponseOptions) => {
|
|
66
99
|
response: Response;
|
|
67
100
|
stream: SseStream<T>;
|
|
68
101
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sse.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"sse.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAMtD;;;;GAIG;AACH,MAAM,WAAW,SAAS,CAAC,CAAC,GAAG,OAAO;IACrC;;;;OAIG;IACH,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC;IACxB,6CAA6C;IAC7C,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,wBAAwB;IACxB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;;;;OAKG;IACH,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;CACnC;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC/B,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,+BAA+B;IAC/B,MAAM,EAAE,OAAO,CAAC;CAChB;AAED,yCAAyC;AACzC,MAAM,WAAW,kBAAkB;IAClC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AA8DD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,mBAAmB,GAAI,CAAC,GAAG,OAAO,EAC9C,GAAG,OAAO,EACV,KAAK,MAAM,EACX,UAAU,kBAAkB,KAC1B;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAA;CAyF5C,CAAC;AAEF,gFAAgF;AAChF,MAAM,WAAW,SAAS;IACzB,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,wDAAwD;IACxD,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC;IAClB,8DAA8D;IAC9D,WAAW,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GAAI,CAAC,SAAS,eAAe,EACrE,aAAa;IAAE,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;CAAE,EAC9D,aAAa,KAAK,CAAC,SAAS,CAAC,EAC7B,KAAK,MAAM,KACT;IAAE,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;CAmBjD,CAAC"}
|