functionalscript 0.46.1 → 0.47.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/fjs/basen/cbase32/module.f.mjs +18 -10
- package/fjs/basen/cbase32/proof.f.d.mts +3 -0
- package/fjs/basen/cbase32/proof.f.mjs +24 -2
- package/fjs/basen/proof.f.mjs +8 -16
- package/fjs/bnf/ll1/proof.f.d.mts +2 -0
- package/fjs/bnf/ll1/proof.f.mjs +33 -18
- package/fjs/bnf/module.f.d.mts +2 -2
- package/fjs/bnf/module.f.mjs +2 -2
- package/fjs/cas/proof.f.mjs +4 -26
- package/fjs/ci/common/module.f.mjs +6 -0
- package/fjs/ci/config/module.f.d.mts +7 -7
- package/fjs/ci/config/module.f.mjs +7 -7
- package/fjs/cli/module.f.mjs +14 -10
- package/fjs/crypto/vdf/module.f.d.mts +0 -1
- package/fjs/crypto/vdf/module.f.mjs +3 -14
- package/fjs/dev/module.f.mjs +10 -20
- package/fjs/djs/parser/module.f.d.mts +101 -53
- package/fjs/djs/parser/module.f.mjs +732 -525
- package/fjs/djs/parser/proof.f.d.mts +6 -0
- package/fjs/djs/parser/proof.f.mjs +315 -11
- package/fjs/djs/parser/types.d.ts +29 -2
- package/fjs/djs/tokenizer/module.f.mjs +122 -105
- package/fjs/djs/tokenizer/proof.f.mjs +17 -0
- package/fjs/djs/transpiler/module.f.mjs +4 -5
- package/fjs/djs/transpiler/proof.f.d.mts +2 -0
- package/fjs/djs/transpiler/proof.f.mjs +25 -0
- package/fjs/edag/amnesia/module.f.d.mts +20 -0
- package/fjs/edag/amnesia/module.f.mjs +303 -0
- package/fjs/edag/amnesia/proof.f.d.mts +79 -0
- package/fjs/edag/amnesia/proof.f.mjs +573 -0
- package/fjs/edag/amnesia/types.d.ts +15 -0
- package/fjs/edag/module.f.d.mts +410 -0
- package/fjs/edag/module.f.mjs +559 -0
- package/fjs/edag/proof.f.d.mts +116 -0
- package/fjs/edag/proof.f.mjs +686 -0
- package/fjs/edag/types.d.ts +55 -0
- package/fjs/effects/node/memory/module.d.mts +37 -3
- package/fjs/effects/node/memory/module.mjs +38 -4
- package/fjs/effects/node/memory/proof.d.mts +5 -2
- package/fjs/effects/node/memory/proof.mjs +36 -6
- package/fjs/effects/node/module.d.mts +9 -1
- package/fjs/effects/node/module.f.d.mts +39 -0
- package/fjs/effects/node/module.f.mjs +46 -0
- package/fjs/effects/node/module.mjs +234 -32
- package/fjs/effects/node/types.d.ts +28 -2
- package/fjs/effects/node/virtual/module.f.d.mts +19 -10
- package/fjs/effects/node/virtual/module.f.mjs +246 -32
- package/fjs/effects/node/virtual/proof.f.d.mts +17 -2
- package/fjs/effects/node/virtual/proof.f.mjs +232 -8
- package/fjs/effects/node/virtual/types.d.ts +57 -1
- package/fjs/emergent_testing/browser/proof.d.mts +55 -0
- package/fjs/emergent_testing/browser/proof.mjs +352 -0
- package/fjs/emergent_testing/browser/species.proof.d.mts +4 -0
- package/fjs/emergent_testing/browser/species.proof.mjs +45 -0
- package/fjs/emergent_testing/browser.d.mts +79 -0
- package/fjs/emergent_testing/browser.mjs +432 -0
- package/fjs/emergent_testing/proof.f.d.mts +1 -1
- package/fjs/emergent_testing/proof.f.mjs +2 -2
- package/fjs/js/string_escape/module.f.d.mts +47 -0
- package/fjs/js/string_escape/module.f.mjs +95 -0
- package/fjs/js/string_escape/proof.f.d.mts +8 -0
- package/fjs/js/string_escape/proof.f.mjs +68 -0
- package/fjs/js/tokenizer/module.f.mjs +14 -12
- package/fjs/mcp/cas/module.f.d.mts +1 -7
- package/fjs/mcp/cas/module.f.mjs +24 -15
- package/fjs/mcp/evo/module.f.mjs +9 -24
- package/fjs/mcp/module.f.d.mts +3 -1
- package/fjs/mcp/module.f.mjs +4 -2
- package/fjs/media/json/rtti/module.f.d.mts +2 -2
- package/fjs/media/json/rtti/module.f.mjs +1 -1
- package/fjs/media/json/schema/module.f.d.mts +15 -5
- package/fjs/media/json/schema/module.f.mjs +42 -15
- package/fjs/media/json/schema/proof.f.d.mts +10 -1
- package/fjs/media/json/schema/proof.f.mjs +88 -16
- package/fjs/media/json/serializer/module.f.mjs +14 -26
- package/fjs/media/json/types.d.ts +1 -1
- package/fjs/media/lock/module.f.d.mts +8 -3
- package/fjs/media/lock/module.f.mjs +9 -3
- package/fjs/media/lock/proof.f.d.mts +1 -1
- package/fjs/media/lock/proof.f.mjs +2 -2
- package/fjs/media/lock/types.d.ts +1 -3
- package/fjs/media/module.f.d.mts +20 -13
- package/fjs/media/module.f.mjs +29 -16
- package/fjs/media/note/module.f.d.mts +14 -7
- package/fjs/media/note/module.f.mjs +14 -7
- package/fjs/media/note/proof.f.mjs +1 -1
- package/fjs/media/proof.f.mjs +5 -5
- package/fjs/media/revision/module.f.d.mts +8 -2
- package/fjs/media/revision/module.f.mjs +9 -3
- package/fjs/media/revision/proof.f.mjs +1 -1
- package/fjs/media/revision/types.d.ts +1 -1
- package/fjs/media/type/module.f.d.mts +20 -0
- package/fjs/media/type/module.f.mjs +74 -0
- package/fjs/media/type/proof.f.d.mts +5 -0
- package/fjs/media/type/proof.f.mjs +24 -1
- package/fjs/module.f.mjs +6 -0
- package/fjs/path/module.f.d.mts +60 -3
- package/fjs/path/module.f.mjs +138 -14
- package/fjs/path/proof.f.d.mts +3 -0
- package/fjs/path/proof.f.mjs +298 -2
- package/fjs/protocol/json_rpc/module.f.d.mts +65 -28
- package/fjs/protocol/json_rpc/module.f.mjs +49 -13
- package/fjs/protocol/json_rpc/proof.f.d.mts +4 -0
- package/fjs/protocol/json_rpc/proof.f.mjs +29 -0
- package/fjs/protocol/json_rpc/types.d.ts +12 -2
- package/fjs/protocol/mcp/module.f.d.mts +89 -54
- package/fjs/protocol/mcp/module.f.mjs +110 -56
- package/fjs/protocol/mcp/proof.f.d.mts +13 -0
- package/fjs/protocol/mcp/proof.f.mjs +107 -7
- package/fjs/protocol/mcp/stdio/module.f.mjs +3 -4
- package/fjs/protocol/mcp/stdio/proof.f.mjs +2 -2
- package/fjs/protocol/mcp/types.d.ts +14 -2
- package/fjs/text/utf8/module.f.mjs +27 -1
- package/fjs/text/utf8/proof.f.mjs +37 -0
- package/fjs/types/array/module.f.mjs +18 -11
- package/fjs/types/array/proof.f.mjs +27 -1
- package/fjs/types/bigfloat/module.f.d.mts +48 -9
- package/fjs/types/bigfloat/module.f.mjs +147 -59
- package/fjs/types/bigfloat/proof.f.d.mts +2 -0
- package/fjs/types/bigfloat/proof.f.mjs +136 -4
- package/fjs/types/bigfloat/types.d.ts +17 -0
- package/fjs/types/function/module.f.d.mts +11 -2
- package/fjs/types/function/module.f.mjs +20 -2
- package/fjs/types/function/proof.f.mjs +5 -1
- package/fjs/types/phantom/types.d.ts +21 -0
- package/fjs/types/result/module.f.d.mts +4 -4
- package/fjs/types/result/module.f.mjs +2 -2
- package/fjs/types/result/proof.f.d.mts +6 -2
- package/fjs/types/result/proof.f.mjs +17 -1
- package/fjs/types/rtti/common/module.f.d.mts +78 -5
- package/fjs/types/rtti/common/module.f.mjs +168 -3
- package/fjs/types/rtti/common/proof.f.d.mts +14 -0
- package/fjs/types/rtti/common/proof.f.mjs +61 -2
- package/fjs/types/rtti/common/types.d.ts +23 -1
- package/fjs/types/rtti/data/module.f.d.mts +57 -1
- package/fjs/types/rtti/data/module.f.mjs +241 -47
- package/fjs/types/rtti/data/proof.f.d.mts +10 -0
- package/fjs/types/rtti/data/proof.f.mjs +272 -39
- package/fjs/types/rtti/data/types.d.ts +14 -7
- package/fjs/types/rtti/host.proof.d.mts +33 -0
- package/fjs/types/rtti/host.proof.mjs +170 -0
- package/fjs/types/rtti/module.f.d.mts +51 -6
- package/fjs/types/rtti/module.f.mjs +50 -3
- package/fjs/types/rtti/parse/module.f.d.mts +31 -24
- package/fjs/types/rtti/parse/module.f.mjs +197 -59
- package/fjs/types/rtti/parse/proof.f.d.mts +37 -2
- package/fjs/types/rtti/parse/proof.f.mjs +171 -47
- package/fjs/types/rtti/proof.f.d.mts +6 -0
- package/fjs/types/rtti/proof.f.mjs +42 -1
- package/fjs/types/rtti/ts/module.f.d.mts +9 -3
- package/fjs/types/rtti/ts/module.f.mjs +68 -15
- package/fjs/types/rtti/ts/proof.f.d.mts +42 -2
- package/fjs/types/rtti/ts/proof.f.mjs +139 -6
- package/fjs/types/rtti/ts/types.d.ts +164 -16
- package/fjs/types/rtti/types.d.ts +31 -3
- package/fjs/types/rtti/validate/module.f.d.mts +42 -25
- package/fjs/types/rtti/validate/module.f.mjs +187 -53
- package/fjs/types/rtti/validate/proof.f.d.mts +28 -2
- package/fjs/types/rtti/validate/proof.f.mjs +430 -75
- package/fjs/types/ts/types.d.ts +4 -0
- package/fjs/web/module.f.d.mts +115 -0
- package/fjs/web/module.f.mjs +581 -0
- package/fjs/web/proof.f.d.mts +39 -0
- package/fjs/web/proof.f.mjs +413 -0
- package/fjs/web/types.d.ts +39 -0
- package/fjs/website/browser-prepare.d.mts +5 -0
- package/fjs/website/browser-prepare.mjs +115 -0
- package/fjs/website/browser-source.d.mts +37 -0
- package/fjs/website/browser-source.mjs +202 -0
- package/fjs/website/browser-source.proof.d.mts +31 -0
- package/fjs/website/browser-source.proof.mjs +162 -0
- package/fjs/website/browser.d.mts +5 -0
- package/fjs/website/browser.mjs +8 -0
- package/fjs/website/module.f.d.mts +3 -3
- package/fjs/website/module.f.mjs +62 -9
- package/fjs/website/proof.f.d.mts +4 -0
- package/fjs/website/proof.f.mjs +18 -3
- package/package.json +2 -3
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A static file server: `fjs web [root] [port]` maps each request path to a
|
|
3
|
+
* file under `root` and answers with its bytes.
|
|
4
|
+
*
|
|
5
|
+
* The module is split so that the decision is pure and the socket handling is a
|
|
6
|
+
* thin shell around it. `resolve` turns a URL into a path and does every check
|
|
7
|
+
* that can be made without touching the disk; `respond` reads the file that
|
|
8
|
+
* path names and builds the response frame, performing IO but no networking;
|
|
9
|
+
* `main` is the only part that creates a server, listens, and blocks. That is
|
|
10
|
+
* why `respond` can be proven end to end against an in-memory file system.
|
|
11
|
+
*
|
|
12
|
+
* ## Responses
|
|
13
|
+
*
|
|
14
|
+
* | case | status |
|
|
15
|
+
* |-----------------------------------------------|--------|
|
|
16
|
+
* | file found | `200` |
|
|
17
|
+
* | `GET`/`HEAD` on a missing, dot-prefixed, or non-regular path | `404` |
|
|
18
|
+
* | any other method | `405` with `Allow` |
|
|
19
|
+
* | a `Host` this server does not answer for | `403` |
|
|
20
|
+
* | a path that escapes `root`, or an undecodable URL | `400` |
|
|
21
|
+
* | a file larger than one `Vec` | `413` |
|
|
22
|
+
* | any other host failure | `500` |
|
|
23
|
+
*
|
|
24
|
+
* Failures carry a `text/plain` body. Nothing else is configurable in this
|
|
25
|
+
* version — no directory listing, no range requests, no compression, no caching
|
|
26
|
+
* headers, no TLS.
|
|
27
|
+
*
|
|
28
|
+
* See `./README.md` for what is deliberately absent, and `./types.ts` for the
|
|
29
|
+
* type-level API.
|
|
30
|
+
*
|
|
31
|
+
* @module
|
|
32
|
+
*
|
|
33
|
+
* @import { Effect } from '../effects/types.ts'
|
|
34
|
+
* @import { FileStat, IoChannel, Program, ReadFile, ServerResponse } from '../effects/node/types.ts'
|
|
35
|
+
* @import { Nullable } from '../types/nullable/types.ts'
|
|
36
|
+
* @import { Result } from '../types/result/types.ts'
|
|
37
|
+
* @import { Vec } from '../types/bit_vec/types.ts'
|
|
38
|
+
* @import { Refusal, Resolve, Respond, WebOp } from './types.ts'
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { pureError, pureOk, resultMapStep, step } from '../effects/module.f.mjs'
|
|
42
|
+
import {
|
|
43
|
+
createServer, errorExit, errorSummary, exitStep, forever, isNotFound, listen, log, readFile,
|
|
44
|
+
stat,
|
|
45
|
+
} from '../effects/node/module.f.mjs'
|
|
46
|
+
import { detectPath } from '../media/type/module.f.mjs'
|
|
47
|
+
import { escapes, join, parse } from '../path/module.f.mjs'
|
|
48
|
+
import { isValidCodePoint } from '../text/code_point/module.f.mjs'
|
|
49
|
+
import { utf8 } from '../text/module.f.mjs'
|
|
50
|
+
import { fromCodePointList, toCodePointList } from '../text/utf8/module.f.mjs'
|
|
51
|
+
import { codePointListToString, stringToCodePointList } from '../text/utf16/module.f.mjs'
|
|
52
|
+
import { length, maxLengthBytes } from '../types/bit_vec/module.f.mjs'
|
|
53
|
+
import { toArray } from '../types/list/module.f.mjs'
|
|
54
|
+
import { error, ok } from '../types/result/module.f.mjs'
|
|
55
|
+
|
|
56
|
+
// ── Routing ───────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/** @type {string} */
|
|
59
|
+
const hexDigits = '0123456789abcdef'
|
|
60
|
+
|
|
61
|
+
/** The value of one hexadecimal digit, or `-1` for anything else.
|
|
62
|
+
*
|
|
63
|
+
* @type {(c: string) => number}
|
|
64
|
+
*/
|
|
65
|
+
const hexDigit = c => hexDigits.indexOf(c.toLowerCase())
|
|
66
|
+
|
|
67
|
+
/** The UTF-8 bytes of `s`.
|
|
68
|
+
*
|
|
69
|
+
* @type {(s: string) => readonly number[]}
|
|
70
|
+
*/
|
|
71
|
+
const utf8Bytes = s => toArray(fromCodePointList(stringToCodePointList(s)))
|
|
72
|
+
|
|
73
|
+
/** Reads `bytes` back as a string, or `null` if they are not valid UTF-8.
|
|
74
|
+
*
|
|
75
|
+
* @type {(bytes: readonly number[]) => Nullable<string>}
|
|
76
|
+
*/
|
|
77
|
+
const utf8String = bytes => {
|
|
78
|
+
const codePoints = toArray(toCodePointList(bytes))
|
|
79
|
+
for (const c of codePoints) {
|
|
80
|
+
if (!isValidCodePoint(c)) { return null }
|
|
81
|
+
}
|
|
82
|
+
return codePointListToString(codePoints)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether `part` — a piece of a target split on `%` — opens with two hexadecimal
|
|
87
|
+
* digits, which is what makes it an escape rather than a mistake.
|
|
88
|
+
*
|
|
89
|
+
* @type {(part: string) => boolean}
|
|
90
|
+
*/
|
|
91
|
+
const isEscape = part =>
|
|
92
|
+
part.length >= 2 && hexDigit(part.charAt(0)) >= 0 && hexDigit(part.charAt(1)) >= 0
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The bytes one escape contributes: the byte it names, then whatever plain text
|
|
96
|
+
* followed it. Total, because {@link isEscape} has already vouched for the part.
|
|
97
|
+
*
|
|
98
|
+
* @type {(part: string) => readonly number[]}
|
|
99
|
+
*/
|
|
100
|
+
const escapeBytes = part =>
|
|
101
|
+
[hexDigit(part.charAt(0)) * 16 + hexDigit(part.charAt(1)), ...utf8Bytes(part.slice(2))]
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Percent-decodes `s`: each `%XX` becomes the byte it names, every other
|
|
105
|
+
* character contributes its own UTF-8 bytes, and the whole byte sequence is
|
|
106
|
+
* then read back as UTF-8 — so `%D0%9F` is one letter rather than two mangled
|
|
107
|
+
* ones. Decoding per escape could not do that: a multi-byte character arrives
|
|
108
|
+
* as several escapes, and no one of them is a character on its own.
|
|
109
|
+
*
|
|
110
|
+
* Validating every escape *before* decoding any is what keeps this linear.
|
|
111
|
+
* Growing one byte array per escape — `[...bytes, byte, ...rest]` — copies
|
|
112
|
+
* everything decoded so far on every escape, which is quadratic in the number of
|
|
113
|
+
* escapes: a 15 KB target of 5,000 escapes fits under Node's header limit and
|
|
114
|
+
* cost about 140 ms of event loop, per request, to reach whatever answer it was
|
|
115
|
+
* always going to get. Two linear passes cost one.
|
|
116
|
+
*
|
|
117
|
+
* `null` when an escape is not two hexadecimal digits, or when the bytes they
|
|
118
|
+
* spell are not valid UTF-8.
|
|
119
|
+
*
|
|
120
|
+
* @type {(s: string) => Nullable<string>}
|
|
121
|
+
*/
|
|
122
|
+
const percentDecode = s => {
|
|
123
|
+
const [literal, ...escaped] = s.split('%')
|
|
124
|
+
if (!escaped.every(isEscape)) { return null }
|
|
125
|
+
return utf8String([...utf8Bytes(literal), ...escaped.flatMap(escapeBytes)])
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A request target, split into the two parts that decide the answer.
|
|
130
|
+
*
|
|
131
|
+
* `authority` is the host the *target* names, which only an absolute-form target
|
|
132
|
+
* carries; `null` says the target named none, and the `Host` header is then the
|
|
133
|
+
* only thing that does.
|
|
134
|
+
*
|
|
135
|
+
* @typedef {{ readonly authority: Nullable<string>, readonly path: string }} _Target
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
/** What separates a scheme from the authority that follows it.
|
|
139
|
+
*
|
|
140
|
+
* @type {string}
|
|
141
|
+
*/
|
|
142
|
+
const schemeMark = '://'
|
|
143
|
+
|
|
144
|
+
/** The schemes an absolute-form target may name.
|
|
145
|
+
*
|
|
146
|
+
* @type {readonly string[]}
|
|
147
|
+
*/
|
|
148
|
+
const schemes = ['http', 'https']
|
|
149
|
+
|
|
150
|
+
/** What separates a credential from the host it was offered to.
|
|
151
|
+
*
|
|
152
|
+
* @type {string}
|
|
153
|
+
*/
|
|
154
|
+
const userInfoMark = '@'
|
|
155
|
+
|
|
156
|
+
/** What separates a host from its port.
|
|
157
|
+
*
|
|
158
|
+
* @type {string}
|
|
159
|
+
*/
|
|
160
|
+
const portMark = ':'
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Reads a request target, or `null` if it is not one this server can act on.
|
|
164
|
+
*
|
|
165
|
+
* Two forms reach an origin server. **Origin-form** (`/main.css?v=2`) is what a
|
|
166
|
+
* browser sends, and its path is the whole target. **Absolute-form**
|
|
167
|
+
* (`http://host/main.css`) is what a client sends to a proxy — RFC 9112 §3.2.2
|
|
168
|
+
* requires an origin server to accept it anyway, and to take the host from *it*
|
|
169
|
+
* rather than from the `Host` header, which is why the authority comes back
|
|
170
|
+
* here instead of being discarded. Treating it as a path was the bug this
|
|
171
|
+
* replaces: `http://host/main.css` resolved to a file named `http:` with `host`
|
|
172
|
+
* inside it, and answered `404` for the wrong reason.
|
|
173
|
+
*
|
|
174
|
+
* Anything else — the asterisk-form `*`, an authority-form `host:port` from a
|
|
175
|
+
* `CONNECT`, an empty target, an authority carrying userinfo — names nothing
|
|
176
|
+
* this server serves.
|
|
177
|
+
*
|
|
178
|
+
* The fragment is stripped although a client keeps it to itself; a `respond`
|
|
179
|
+
* called directly might still be given one, and it costs one `split`.
|
|
180
|
+
*
|
|
181
|
+
* @type {(target: string) => Nullable<_Target>}
|
|
182
|
+
*/
|
|
183
|
+
const parseTarget = target => {
|
|
184
|
+
const [beforeFragment] = target.split('#')
|
|
185
|
+
const [withoutQuery] = beforeFragment.split('?')
|
|
186
|
+
if (withoutQuery.startsWith('/')) { return { authority: null, path: withoutQuery } }
|
|
187
|
+
const mark = withoutQuery.indexOf(schemeMark)
|
|
188
|
+
// A scheme is not "whatever precedes `://`": `://localhost/x` and
|
|
189
|
+
// `1://localhost/x` are malformed targets, and reading them as absolute-form
|
|
190
|
+
// served the file for a request that names no scheme at all. This server
|
|
191
|
+
// speaks two, so it accepts two.
|
|
192
|
+
if (mark < 0 || !schemes.includes(withoutQuery.slice(0, mark).toLowerCase())) { return null }
|
|
193
|
+
const afterScheme = withoutQuery.slice(mark + schemeMark.length)
|
|
194
|
+
const slash = afterScheme.indexOf('/')
|
|
195
|
+
const authority = slash < 0 ? afterScheme : afterScheme.slice(0, slash)
|
|
196
|
+
// Userinfo names a credential, not a host, and an authority carrying one
|
|
197
|
+
// reads as a different host depending on which end you start from — which
|
|
198
|
+
// is what makes it worth refusing outright rather than parsing past.
|
|
199
|
+
if (authority.includes(userInfoMark)) { return null }
|
|
200
|
+
// An `http` URI with an **empty host** is one RFC 9110 §4.2.1 says a
|
|
201
|
+
// recipient must reject as invalid, and the reason is visible here:
|
|
202
|
+
// `http:///index.html` reads as an empty authority and the path
|
|
203
|
+
// `/index.html` to this parser, and as the host `index.html` and the path
|
|
204
|
+
// `/` to a URL parser. Two readings, neither of them the client's, so
|
|
205
|
+
// neither is answered. `http://:80/x` is the same target wearing a port,
|
|
206
|
+
// and `new URL` refuses that one outright.
|
|
207
|
+
if (authority === '' || authority.startsWith(portMark)) { return null }
|
|
208
|
+
return { authority, path: slash < 0 ? '/' : afterScheme.slice(slash) }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The one character no file system path can carry.
|
|
212
|
+
*
|
|
213
|
+
* @type {string}
|
|
214
|
+
*/
|
|
215
|
+
const nul = '\u0000'
|
|
216
|
+
|
|
217
|
+
/** Whether `segment` is one a client is not shown.
|
|
218
|
+
*
|
|
219
|
+
* @type {(segment: string) => boolean}
|
|
220
|
+
*/
|
|
221
|
+
const isHidden = segment => segment.startsWith('.')
|
|
222
|
+
|
|
223
|
+
/** @type {(status: number) => (message: string) => Result<never, Refusal>} */
|
|
224
|
+
const refuse = status => message => error({ status, message })
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The directory a `root` argument names.
|
|
228
|
+
*
|
|
229
|
+
* An **empty** `root` is the working directory, not the file system root.
|
|
230
|
+
* `join('', 'etc')` is `/etc` — a leading empty segment reads as absolute — so
|
|
231
|
+
* without this, `fjs web ''` would serve `/etc/passwd` on request. The
|
|
232
|
+
* argument's default cannot catch it: a destructuring default replaces
|
|
233
|
+
* `undefined`, and `''` is a value the caller passed.
|
|
234
|
+
*
|
|
235
|
+
* @type {(root: string) => string}
|
|
236
|
+
*/
|
|
237
|
+
const served = root => root === '' ? '.' : root
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Maps a request URL to a path under `root`, or explains why none exists.
|
|
241
|
+
*
|
|
242
|
+
* A directory request — a path ending in `/`, including the bare `/` — is
|
|
243
|
+
* answered with its `index.html`, which is what makes a generated site browsable
|
|
244
|
+
* at all.
|
|
245
|
+
*
|
|
246
|
+
* An empty `root` is read as the working directory — see {@link served}.
|
|
247
|
+
*
|
|
248
|
+
* **Dot-prefixed segments are not served.** `.git/config`, `.env` and
|
|
249
|
+
* `.ssh/id_rsa` are the files whose exposure the loopback binding exists to
|
|
250
|
+
* prevent, and a static server that hands them to anyone who asks has the
|
|
251
|
+
* boundary in the wrong place. The answer is `404` rather than `403`: whether
|
|
252
|
+
* such a file exists is itself the thing not being disclosed.
|
|
253
|
+
*
|
|
254
|
+
* **A NUL is a malformed URL, not a host error.** `%00` decodes to a byte no
|
|
255
|
+
* path can contain; left to the file system it comes back as an
|
|
256
|
+
* `ERR_INVALID_ARG_VALUE` and a `500`, reporting a host failure for what is
|
|
257
|
+
* plainly a bad request.
|
|
258
|
+
*
|
|
259
|
+
* **Traversal is rejected in segment space, not by string comparison.**
|
|
260
|
+
* `escapes` folds the path with its root taken off, so a `..` with nothing left
|
|
261
|
+
* to cancel survives to be counted; that is the whole check. `parse` cannot
|
|
262
|
+
* answer it — `parse` folds *with* the root in place, and clamping away such a
|
|
263
|
+
* `..` is precisely what it does, which is why reading its output for one
|
|
264
|
+
* silently stopped working. Nor is taking the root off the same as dropping a
|
|
265
|
+
* leading `/`: the remainder of `///../x` is `//../x`, which reads as rooted
|
|
266
|
+
* again, so the question belongs to `fjs/path` rather than to string surgery
|
|
267
|
+
* here. Comparing the joined path against `root` textually is the weaker test;
|
|
268
|
+
* it became expressible once `normalize` began keeping roots, and is still not
|
|
269
|
+
* what is done.
|
|
270
|
+
*
|
|
271
|
+
* @type {Resolve}
|
|
272
|
+
*/
|
|
273
|
+
export const resolve = root => url => {
|
|
274
|
+
const base = served(root)
|
|
275
|
+
const target = parseTarget(url)
|
|
276
|
+
if (target === null) { return refuse(400)('malformed request URL') }
|
|
277
|
+
const decoded = percentDecode(target.path)
|
|
278
|
+
if (decoded === null || decoded.includes(nul)) { return refuse(400)('malformed request URL') }
|
|
279
|
+
const segments = parse(decoded)
|
|
280
|
+
// `escapes`, not a `..` among `segments` — see the traversal note above.
|
|
281
|
+
// `/a/../b` collapses and is served; `/../b` escapes and is not.
|
|
282
|
+
if (escapes(decoded)) { return refuse(400)('request path escapes the served root') }
|
|
283
|
+
if (segments.some(isHidden)) { return refuse(404)('not found') }
|
|
284
|
+
const isDirectory = segments.length === 0 || decoded.endsWith('/')
|
|
285
|
+
return ok(join(base, ...(isDirectory ? [...segments, 'index.html'] : segments)))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Answering ─────────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A file too large to answer with. `readFile` yields a single `Vec`, so this is
|
|
292
|
+
* a limit of the effect rather than a policy: see the README.
|
|
293
|
+
*
|
|
294
|
+
* @typedef {readonly['tooLarge', number]} _TooLarge
|
|
295
|
+
*/
|
|
296
|
+
|
|
297
|
+
/** @type {(size: number) => _TooLarge} */
|
|
298
|
+
const tooLarge = size => ['tooLarge', size]
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* An entry that is not a regular file — a FIFO, a device, a socket. It exists,
|
|
302
|
+
* so this is not a missing path, and it is not something this server will read.
|
|
303
|
+
*
|
|
304
|
+
* @typedef {readonly['notRegular']} _NotRegular
|
|
305
|
+
*/
|
|
306
|
+
|
|
307
|
+
/** @type {_NotRegular} */
|
|
308
|
+
const notRegular = ['notRegular']
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A response frame carrying `body`, with its length declared.
|
|
312
|
+
*
|
|
313
|
+
* `Content-Length` is written here rather than left to the runner, because the
|
|
314
|
+
* runner does not write one: Node sends an unmeasured body with
|
|
315
|
+
* `Transfer-Encoding: chunked`, and for a `HEAD` request — where it drops the
|
|
316
|
+
* body but keeps these headers — that leaves the client with neither the bytes
|
|
317
|
+
* nor their count, which is the one thing a `HEAD` is asked for.
|
|
318
|
+
*
|
|
319
|
+
* @type {(status: number) => (contentType: string) => (body: Vec) => ServerResponse}
|
|
320
|
+
*/
|
|
321
|
+
const response = status => contentType => body => ({
|
|
322
|
+
status,
|
|
323
|
+
headers: {
|
|
324
|
+
'content-type': contentType,
|
|
325
|
+
'content-length': `${length(body) >> 3n}`,
|
|
326
|
+
// The `Content-Type` above is derived from a file name, and a browser
|
|
327
|
+
// that sniffs past it decides for itself what a served file is — which
|
|
328
|
+
// is the one thing this server has already answered.
|
|
329
|
+
'x-content-type-options': 'nosniff',
|
|
330
|
+
},
|
|
331
|
+
body,
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
/** @type {(status: number) => (message: string) => ServerResponse} */
|
|
335
|
+
const plainText = status => message =>
|
|
336
|
+
response(status)('text/plain; charset=utf-8')(utf8(`${message}\n`))
|
|
337
|
+
|
|
338
|
+
/** The methods this server answers.
|
|
339
|
+
*
|
|
340
|
+
* @type {string}
|
|
341
|
+
*/
|
|
342
|
+
const allow = 'GET, HEAD'
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The names this server will answer *for*, which is not the same question as
|
|
346
|
+
* what it binds.
|
|
347
|
+
*
|
|
348
|
+
* Binding loopback stops another machine from reaching the socket; it does not
|
|
349
|
+
* stop a browser on this one from being *told* that a name the attacker owns
|
|
350
|
+
* lives at `127.0.0.1`. That is DNS rebinding: a page from `attacker.example`
|
|
351
|
+
* whose DNS answer flips to loopback, and whose fetches then arrive here
|
|
352
|
+
* carrying `Host: attacker.example`. Served, the browser files the response
|
|
353
|
+
* under the attacker's origin and hands the working tree to their JavaScript.
|
|
354
|
+
* The request looks ordinary at every layer below this one — right socket,
|
|
355
|
+
* right port, real client — and the `Host` header is the only place the lie is
|
|
356
|
+
* written down.
|
|
357
|
+
*
|
|
358
|
+
* @type {readonly string[]}
|
|
359
|
+
*/
|
|
360
|
+
const servedHosts = ['localhost', '127.0.0.1', '[::1]']
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The largest port a number names — the one an authority may carry, and the one
|
|
364
|
+
* `main` may bind.
|
|
365
|
+
*
|
|
366
|
+
* @type {number}
|
|
367
|
+
*/
|
|
368
|
+
const maxPort = 0xffff
|
|
369
|
+
|
|
370
|
+
/** Whether `s` is a decimal number, and a non-empty one.
|
|
371
|
+
*
|
|
372
|
+
* @type {(s: string) => boolean}
|
|
373
|
+
*/
|
|
374
|
+
const isDigits = s => s !== '' && [...s].every(c => c >= '0' && c <= '9')
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Whether `s` names a port.
|
|
378
|
+
*
|
|
379
|
+
* Digits **in range**: `65536` is a number and not a port, and a URL parser
|
|
380
|
+
* agrees — `new URL('http://localhost:65536/')` throws where
|
|
381
|
+
* `http://localhost:8099/` does not. The digits are read as a number rather
|
|
382
|
+
* than counted, because a parser reads `00008099` as `8099` and a length test
|
|
383
|
+
* would not. Past what a number can hold the read is `Infinity`, which is out
|
|
384
|
+
* of range like every other value that large.
|
|
385
|
+
*
|
|
386
|
+
* @type {(s: string) => boolean}
|
|
387
|
+
*/
|
|
388
|
+
const isPort = s => isDigits(s) && Number(s) <= maxPort
|
|
389
|
+
|
|
390
|
+
/** Whether what follows a host name is nothing, or a port.
|
|
391
|
+
*
|
|
392
|
+
* @type {(rest: string) => boolean}
|
|
393
|
+
*/
|
|
394
|
+
const isPortSuffix = rest =>
|
|
395
|
+
rest === '' || (rest.startsWith(portMark) && isPort(rest.slice(portMark.length)))
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The name an authority names, or `null` if it does not name one.
|
|
399
|
+
*
|
|
400
|
+
* Lower case, because a host name is case-insensitive and `LOCALHOST` is not a
|
|
401
|
+
* different machine. Without a trailing root dot, because `localhost.` is not
|
|
402
|
+
* one either.
|
|
403
|
+
*
|
|
404
|
+
* **And nothing after the name is discarded.** Reading the prefix and dropping
|
|
405
|
+
* the rest made `localhost:bad`, `localhost:8080:999` and `[::1]evil` all read
|
|
406
|
+
* as names this server answers for — a check that ignores what it does not
|
|
407
|
+
* understand is not a check. What may follow a name is a port and nothing else;
|
|
408
|
+
* an IPv6 literal is bracketed, and its brackets are part of the name.
|
|
409
|
+
*
|
|
410
|
+
* @type {(host: string) => Nullable<string>}
|
|
411
|
+
*/
|
|
412
|
+
const hostName = host => {
|
|
413
|
+
const lower = host.toLowerCase()
|
|
414
|
+
if (lower.startsWith('[')) {
|
|
415
|
+
const end = lower.indexOf(']')
|
|
416
|
+
if (end < 0 || !isPortSuffix(lower.slice(end + 1))) { return null }
|
|
417
|
+
return lower.slice(0, end + 1)
|
|
418
|
+
}
|
|
419
|
+
const colon = lower.indexOf(':')
|
|
420
|
+
if (colon >= 0 && !isPortSuffix(lower.slice(colon))) { return null }
|
|
421
|
+
const name = colon < 0 ? lower : lower.slice(0, colon)
|
|
422
|
+
return name.endsWith('.') ? name.slice(0, -1) : name
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Whether this server answers for `host`.
|
|
427
|
+
*
|
|
428
|
+
* An absent `Host` is not served: HTTP/1.1 requires one, every browser sends
|
|
429
|
+
* one, and accepting the absence would leave the check with a hole shaped
|
|
430
|
+
* exactly like a client that omits it deliberately.
|
|
431
|
+
*
|
|
432
|
+
* Neither is one carrying **userinfo**. `127.0.0.1:8080@attacker.example` is an
|
|
433
|
+
* authority whose host is `attacker.example` — the part before the `@` is a
|
|
434
|
+
* credential, not a name — and reading it left-to-right finds a loopback
|
|
435
|
+
* address that was never the host at all. RFC 9110 §4.2.4 deprecates userinfo in
|
|
436
|
+
* an `http` URI and says a sender must not generate one, so refusing is both
|
|
437
|
+
* correct and the only reading that cannot be walked backwards into.
|
|
438
|
+
*
|
|
439
|
+
* @type {(host: string | undefined) => boolean}
|
|
440
|
+
*/
|
|
441
|
+
const isServedHost = host => {
|
|
442
|
+
if (host === undefined || host.includes(userInfoMark)) { return false }
|
|
443
|
+
const name = hostName(host)
|
|
444
|
+
return name !== null && servedHosts.includes(name)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* `405`, carrying the `Allow` header the status may not omit: a refusal that
|
|
449
|
+
* does not say what *would* be accepted leaves the client to guess, which is
|
|
450
|
+
* why RFC 9110 requires an origin server to list them here.
|
|
451
|
+
*
|
|
452
|
+
* @type {() => ServerResponse}
|
|
453
|
+
*/
|
|
454
|
+
const methodNotAllowed = () => {
|
|
455
|
+
const answer = plainText(405)('only GET and HEAD are supported')
|
|
456
|
+
return { ...answer, headers: { ...answer.headers, allow } }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Reads `path`, but only once `stat` has said it is a regular file that fits in
|
|
461
|
+
* one `Vec`.
|
|
462
|
+
*
|
|
463
|
+
* Both questions are asked before the read, and neither is optional. An
|
|
464
|
+
* oversized file must fail loudly rather than be truncated. A **non-regular**
|
|
465
|
+
* entry must not be read at all: `open` on a FIFO with no writer blocks until
|
|
466
|
+
* one appears, so the read would never return and would hold a thread-pool slot
|
|
467
|
+
* while it waited — a served tree with one FIFO in it, and a handful of requests
|
|
468
|
+
* stall every other response. Size cannot stand in for that check, because a
|
|
469
|
+
* FIFO stats as zero bytes and passes every bound.
|
|
470
|
+
*
|
|
471
|
+
* @type {(path: string) => (s: FileStat) => Effect<ReadFile, Vec, IoChannel | _TooLarge | _NotRegular>}
|
|
472
|
+
*/
|
|
473
|
+
const readBounded = path => ({ size, isFile }) => {
|
|
474
|
+
if (!isFile) { return pureError(notRegular) }
|
|
475
|
+
return BigInt(size) > maxLengthBytes ? pureError(tooLarge(size)) : readFile(path)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The response frame for whatever reading `path` produced. This is where the
|
|
480
|
+
* error channel ends: every failure becomes a status code, which is what lets
|
|
481
|
+
* a `RequestListener` declare `never`.
|
|
482
|
+
*
|
|
483
|
+
* @type {(path: string) => (r: Result<Vec, IoChannel | _TooLarge | _NotRegular>) => ServerResponse}
|
|
484
|
+
*/
|
|
485
|
+
const fileResponse = path => r => {
|
|
486
|
+
if (r[0] === 'ok') { return response(200)(detectPath(path))(r[1]) }
|
|
487
|
+
const e = r[1]
|
|
488
|
+
// A name that is not a regular file is answered as absent, for the reason a
|
|
489
|
+
// dot-prefixed one is: what it *is* would be a disclosure of its own.
|
|
490
|
+
if (e[0] === 'notRegular') { return plainText(404)('not found') }
|
|
491
|
+
if (e[0] === 'tooLarge') {
|
|
492
|
+
return plainText(413)(`file is ${e[1]} bytes; this server cannot answer with more than ${maxLengthBytes}`)
|
|
493
|
+
}
|
|
494
|
+
if (isNotFound(e)) { return plainText(404)('not found') }
|
|
495
|
+
// `errorSummary`, not `errorMessage`: the host puts the absolute path it
|
|
496
|
+
// could not read into the message, and a client is not entitled to the
|
|
497
|
+
// server's filesystem layout.
|
|
498
|
+
return plainText(500)(errorSummary(e))
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Answers one request: resolve, read, and frame the result.
|
|
503
|
+
*
|
|
504
|
+
* The host is checked first, against the loopback names this server answers for
|
|
505
|
+
* — see {@link servedHosts} for why binding loopback is not enough on its own.
|
|
506
|
+
* An absolute-form target names its own host, and RFC 9112 §3.2.2 says to
|
|
507
|
+
* believe that over the `Host` header.
|
|
508
|
+
*
|
|
509
|
+
* `HEAD` is answered exactly like `GET`, bytes included: Node drops the body of
|
|
510
|
+
* a `HEAD` response itself and keeps the headers, so the one frame serves both
|
|
511
|
+
* — and because {@link response} states `Content-Length`, a `HEAD` client still
|
|
512
|
+
* learns the size it asked for.
|
|
513
|
+
*
|
|
514
|
+
* @type {Respond}
|
|
515
|
+
*/
|
|
516
|
+
export const respond = root => ({ method, url, headers }) => {
|
|
517
|
+
// Before anything else, including what method it is: a request for a name
|
|
518
|
+
// this server does not answer for is not a request to be interpreted.
|
|
519
|
+
//
|
|
520
|
+
// An absolute-form target carries the name itself, and RFC 9112 §3.2.2 says
|
|
521
|
+
// an origin server must believe *it* over the `Host` header — a proxy
|
|
522
|
+
// rewrites one and not the other, so the target is the one the client meant.
|
|
523
|
+
const target = parseTarget(url)
|
|
524
|
+
const host = target === null || target.authority === null ? headers.host : target.authority
|
|
525
|
+
if (!isServedHost(host)) { return pureOk(plainText(403)('host not served')) }
|
|
526
|
+
if (method !== 'GET' && method !== 'HEAD') { return pureOk(methodNotAllowed()) }
|
|
527
|
+
const resolved = resolve(root)(url)
|
|
528
|
+
if (resolved[0] === 'error') {
|
|
529
|
+
const { status, message } = resolved[1]
|
|
530
|
+
return pureOk(plainText(status)(message))
|
|
531
|
+
}
|
|
532
|
+
const path = resolved[1]
|
|
533
|
+
const bytes = step(stat(path), readBounded(path))
|
|
534
|
+
return resultMapStep(bytes, r => ok(fileResponse(path)(r)))
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── The program ───────────────────────────────────────────────────────────────
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* The address the server binds.
|
|
541
|
+
*
|
|
542
|
+
* Loopback, not the unspecified address: this serves whatever directory it was
|
|
543
|
+
* pointed at, which is `.` by default, so binding every interface would publish
|
|
544
|
+
* a working tree — sources, keys, a `.env` — to the whole network because the
|
|
545
|
+
* operator typed two words. Reaching it from another machine is a decision, and
|
|
546
|
+
* it waits for `--host` to be a thing one can write.
|
|
547
|
+
*
|
|
548
|
+
* @type {string}
|
|
549
|
+
*/
|
|
550
|
+
const loopback = '127.0.0.1'
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* `fjs web [root] [port]` — serve `root` (default `.`) on `port` (default
|
|
554
|
+
* `8080`), bound to {@link loopback}.
|
|
555
|
+
*
|
|
556
|
+
* Both arguments are positional because `fjs/cli` has no notion of a named
|
|
557
|
+
* option yet; `port` becomes `--port` once one exists, and `--host` is what
|
|
558
|
+
* would let a caller bind anything but loopback.
|
|
559
|
+
*
|
|
560
|
+
* The chain ends in `forever`, so the program only stops when the process does.
|
|
561
|
+
* A runner that cannot block forever answers `notImplemented` there, which is
|
|
562
|
+
* how the whole program remains runnable — and observable — under the virtual
|
|
563
|
+
* runner.
|
|
564
|
+
*
|
|
565
|
+
* @type {Program<WebOp>}
|
|
566
|
+
*/
|
|
567
|
+
export const main = ({ args }) => {
|
|
568
|
+
const [root = '.', portArgument = '8080'] = args
|
|
569
|
+
const port = Number(portArgument)
|
|
570
|
+
// `0` is excluded with the out-of-range values: Node reads it as "any free
|
|
571
|
+
// port", and the program has no way to ask which one it got, so the URL it
|
|
572
|
+
// prints would name a port nothing is listening on.
|
|
573
|
+
if (!Number.isInteger(port) || port < 1 || port > maxPort) {
|
|
574
|
+
return errorExit(`invalid port "${portArgument}"`)
|
|
575
|
+
}
|
|
576
|
+
const server = createServer(respond(root))
|
|
577
|
+
const listening = step(server, s => listen(s, port, loopback))
|
|
578
|
+
const announced = step(listening, () => log(`serving ${served(root)} on http://${loopback}:${port}/`))
|
|
579
|
+
const ended = step(announced, forever)
|
|
580
|
+
return exitStep(ended)
|
|
581
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { IncomingMessage, ServerResponse } from '../effects/node/types.ts'
|
|
3
|
+
* @import { Dir, State } from '../effects/node/virtual/types.ts'
|
|
4
|
+
* @import { Vec } from '../types/bit_vec/types.ts'
|
|
5
|
+
*/
|
|
6
|
+
export declare const proof: {
|
|
7
|
+
resolve: {
|
|
8
|
+
index: () => void;
|
|
9
|
+
absoluteForm: () => void;
|
|
10
|
+
file: () => void;
|
|
11
|
+
absoluteRoot: () => void;
|
|
12
|
+
emptyRoot: () => void;
|
|
13
|
+
percentEncoding: () => void;
|
|
14
|
+
rejected: () => void;
|
|
15
|
+
};
|
|
16
|
+
respond: {
|
|
17
|
+
found: () => void;
|
|
18
|
+
head: () => void;
|
|
19
|
+
missing: () => void;
|
|
20
|
+
rebinding: () => void;
|
|
21
|
+
methodNotAllowed: () => void;
|
|
22
|
+
traversal: () => void;
|
|
23
|
+
directoryWithoutSlash: () => void;
|
|
24
|
+
hidden: () => void;
|
|
25
|
+
tooLarge: () => void;
|
|
26
|
+
notRegular: () => void;
|
|
27
|
+
hostFailure: () => void;
|
|
28
|
+
};
|
|
29
|
+
main: {
|
|
30
|
+
endToEnd: () => void;
|
|
31
|
+
emptyRoot: () => void;
|
|
32
|
+
arguments: () => void;
|
|
33
|
+
badPort: () => void;
|
|
34
|
+
};
|
|
35
|
+
virtualServer: {
|
|
36
|
+
noRequests: () => void;
|
|
37
|
+
emptyBody: () => void;
|
|
38
|
+
};
|
|
39
|
+
};
|