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.
Files changed (178) hide show
  1. package/fjs/basen/cbase32/module.f.mjs +18 -10
  2. package/fjs/basen/cbase32/proof.f.d.mts +3 -0
  3. package/fjs/basen/cbase32/proof.f.mjs +24 -2
  4. package/fjs/basen/proof.f.mjs +8 -16
  5. package/fjs/bnf/ll1/proof.f.d.mts +2 -0
  6. package/fjs/bnf/ll1/proof.f.mjs +33 -18
  7. package/fjs/bnf/module.f.d.mts +2 -2
  8. package/fjs/bnf/module.f.mjs +2 -2
  9. package/fjs/cas/proof.f.mjs +4 -26
  10. package/fjs/ci/common/module.f.mjs +6 -0
  11. package/fjs/ci/config/module.f.d.mts +7 -7
  12. package/fjs/ci/config/module.f.mjs +7 -7
  13. package/fjs/cli/module.f.mjs +14 -10
  14. package/fjs/crypto/vdf/module.f.d.mts +0 -1
  15. package/fjs/crypto/vdf/module.f.mjs +3 -14
  16. package/fjs/dev/module.f.mjs +10 -20
  17. package/fjs/djs/parser/module.f.d.mts +101 -53
  18. package/fjs/djs/parser/module.f.mjs +732 -525
  19. package/fjs/djs/parser/proof.f.d.mts +6 -0
  20. package/fjs/djs/parser/proof.f.mjs +315 -11
  21. package/fjs/djs/parser/types.d.ts +29 -2
  22. package/fjs/djs/tokenizer/module.f.mjs +122 -105
  23. package/fjs/djs/tokenizer/proof.f.mjs +17 -0
  24. package/fjs/djs/transpiler/module.f.mjs +4 -5
  25. package/fjs/djs/transpiler/proof.f.d.mts +2 -0
  26. package/fjs/djs/transpiler/proof.f.mjs +25 -0
  27. package/fjs/edag/amnesia/module.f.d.mts +20 -0
  28. package/fjs/edag/amnesia/module.f.mjs +303 -0
  29. package/fjs/edag/amnesia/proof.f.d.mts +79 -0
  30. package/fjs/edag/amnesia/proof.f.mjs +573 -0
  31. package/fjs/edag/amnesia/types.d.ts +15 -0
  32. package/fjs/edag/module.f.d.mts +410 -0
  33. package/fjs/edag/module.f.mjs +559 -0
  34. package/fjs/edag/proof.f.d.mts +116 -0
  35. package/fjs/edag/proof.f.mjs +686 -0
  36. package/fjs/edag/types.d.ts +55 -0
  37. package/fjs/effects/node/memory/module.d.mts +37 -3
  38. package/fjs/effects/node/memory/module.mjs +38 -4
  39. package/fjs/effects/node/memory/proof.d.mts +5 -2
  40. package/fjs/effects/node/memory/proof.mjs +36 -6
  41. package/fjs/effects/node/module.d.mts +9 -1
  42. package/fjs/effects/node/module.f.d.mts +39 -0
  43. package/fjs/effects/node/module.f.mjs +46 -0
  44. package/fjs/effects/node/module.mjs +234 -32
  45. package/fjs/effects/node/types.d.ts +28 -2
  46. package/fjs/effects/node/virtual/module.f.d.mts +19 -10
  47. package/fjs/effects/node/virtual/module.f.mjs +246 -32
  48. package/fjs/effects/node/virtual/proof.f.d.mts +17 -2
  49. package/fjs/effects/node/virtual/proof.f.mjs +232 -8
  50. package/fjs/effects/node/virtual/types.d.ts +57 -1
  51. package/fjs/emergent_testing/browser/proof.d.mts +55 -0
  52. package/fjs/emergent_testing/browser/proof.mjs +352 -0
  53. package/fjs/emergent_testing/browser/species.proof.d.mts +4 -0
  54. package/fjs/emergent_testing/browser/species.proof.mjs +45 -0
  55. package/fjs/emergent_testing/browser.d.mts +79 -0
  56. package/fjs/emergent_testing/browser.mjs +432 -0
  57. package/fjs/emergent_testing/proof.f.d.mts +1 -1
  58. package/fjs/emergent_testing/proof.f.mjs +2 -2
  59. package/fjs/js/string_escape/module.f.d.mts +47 -0
  60. package/fjs/js/string_escape/module.f.mjs +95 -0
  61. package/fjs/js/string_escape/proof.f.d.mts +8 -0
  62. package/fjs/js/string_escape/proof.f.mjs +68 -0
  63. package/fjs/js/tokenizer/module.f.mjs +14 -12
  64. package/fjs/mcp/cas/module.f.d.mts +1 -7
  65. package/fjs/mcp/cas/module.f.mjs +24 -15
  66. package/fjs/mcp/evo/module.f.mjs +9 -24
  67. package/fjs/mcp/module.f.d.mts +3 -1
  68. package/fjs/mcp/module.f.mjs +4 -2
  69. package/fjs/media/json/rtti/module.f.d.mts +2 -2
  70. package/fjs/media/json/rtti/module.f.mjs +1 -1
  71. package/fjs/media/json/schema/module.f.d.mts +15 -5
  72. package/fjs/media/json/schema/module.f.mjs +42 -15
  73. package/fjs/media/json/schema/proof.f.d.mts +10 -1
  74. package/fjs/media/json/schema/proof.f.mjs +88 -16
  75. package/fjs/media/json/serializer/module.f.mjs +14 -26
  76. package/fjs/media/json/types.d.ts +1 -1
  77. package/fjs/media/lock/module.f.d.mts +8 -3
  78. package/fjs/media/lock/module.f.mjs +9 -3
  79. package/fjs/media/lock/proof.f.d.mts +1 -1
  80. package/fjs/media/lock/proof.f.mjs +2 -2
  81. package/fjs/media/lock/types.d.ts +1 -3
  82. package/fjs/media/module.f.d.mts +20 -13
  83. package/fjs/media/module.f.mjs +29 -16
  84. package/fjs/media/note/module.f.d.mts +14 -7
  85. package/fjs/media/note/module.f.mjs +14 -7
  86. package/fjs/media/note/proof.f.mjs +1 -1
  87. package/fjs/media/proof.f.mjs +5 -5
  88. package/fjs/media/revision/module.f.d.mts +8 -2
  89. package/fjs/media/revision/module.f.mjs +9 -3
  90. package/fjs/media/revision/proof.f.mjs +1 -1
  91. package/fjs/media/revision/types.d.ts +1 -1
  92. package/fjs/media/type/module.f.d.mts +20 -0
  93. package/fjs/media/type/module.f.mjs +74 -0
  94. package/fjs/media/type/proof.f.d.mts +5 -0
  95. package/fjs/media/type/proof.f.mjs +24 -1
  96. package/fjs/module.f.mjs +6 -0
  97. package/fjs/path/module.f.d.mts +60 -3
  98. package/fjs/path/module.f.mjs +138 -14
  99. package/fjs/path/proof.f.d.mts +3 -0
  100. package/fjs/path/proof.f.mjs +298 -2
  101. package/fjs/protocol/json_rpc/module.f.d.mts +65 -28
  102. package/fjs/protocol/json_rpc/module.f.mjs +49 -13
  103. package/fjs/protocol/json_rpc/proof.f.d.mts +4 -0
  104. package/fjs/protocol/json_rpc/proof.f.mjs +29 -0
  105. package/fjs/protocol/json_rpc/types.d.ts +12 -2
  106. package/fjs/protocol/mcp/module.f.d.mts +89 -54
  107. package/fjs/protocol/mcp/module.f.mjs +110 -56
  108. package/fjs/protocol/mcp/proof.f.d.mts +13 -0
  109. package/fjs/protocol/mcp/proof.f.mjs +107 -7
  110. package/fjs/protocol/mcp/stdio/module.f.mjs +3 -4
  111. package/fjs/protocol/mcp/stdio/proof.f.mjs +2 -2
  112. package/fjs/protocol/mcp/types.d.ts +14 -2
  113. package/fjs/text/utf8/module.f.mjs +27 -1
  114. package/fjs/text/utf8/proof.f.mjs +37 -0
  115. package/fjs/types/array/module.f.mjs +18 -11
  116. package/fjs/types/array/proof.f.mjs +27 -1
  117. package/fjs/types/bigfloat/module.f.d.mts +48 -9
  118. package/fjs/types/bigfloat/module.f.mjs +147 -59
  119. package/fjs/types/bigfloat/proof.f.d.mts +2 -0
  120. package/fjs/types/bigfloat/proof.f.mjs +136 -4
  121. package/fjs/types/bigfloat/types.d.ts +17 -0
  122. package/fjs/types/function/module.f.d.mts +11 -2
  123. package/fjs/types/function/module.f.mjs +20 -2
  124. package/fjs/types/function/proof.f.mjs +5 -1
  125. package/fjs/types/phantom/types.d.ts +21 -0
  126. package/fjs/types/result/module.f.d.mts +4 -4
  127. package/fjs/types/result/module.f.mjs +2 -2
  128. package/fjs/types/result/proof.f.d.mts +6 -2
  129. package/fjs/types/result/proof.f.mjs +17 -1
  130. package/fjs/types/rtti/common/module.f.d.mts +78 -5
  131. package/fjs/types/rtti/common/module.f.mjs +168 -3
  132. package/fjs/types/rtti/common/proof.f.d.mts +14 -0
  133. package/fjs/types/rtti/common/proof.f.mjs +61 -2
  134. package/fjs/types/rtti/common/types.d.ts +23 -1
  135. package/fjs/types/rtti/data/module.f.d.mts +57 -1
  136. package/fjs/types/rtti/data/module.f.mjs +241 -47
  137. package/fjs/types/rtti/data/proof.f.d.mts +10 -0
  138. package/fjs/types/rtti/data/proof.f.mjs +272 -39
  139. package/fjs/types/rtti/data/types.d.ts +14 -7
  140. package/fjs/types/rtti/host.proof.d.mts +33 -0
  141. package/fjs/types/rtti/host.proof.mjs +170 -0
  142. package/fjs/types/rtti/module.f.d.mts +51 -6
  143. package/fjs/types/rtti/module.f.mjs +50 -3
  144. package/fjs/types/rtti/parse/module.f.d.mts +31 -24
  145. package/fjs/types/rtti/parse/module.f.mjs +197 -59
  146. package/fjs/types/rtti/parse/proof.f.d.mts +37 -2
  147. package/fjs/types/rtti/parse/proof.f.mjs +171 -47
  148. package/fjs/types/rtti/proof.f.d.mts +6 -0
  149. package/fjs/types/rtti/proof.f.mjs +42 -1
  150. package/fjs/types/rtti/ts/module.f.d.mts +9 -3
  151. package/fjs/types/rtti/ts/module.f.mjs +68 -15
  152. package/fjs/types/rtti/ts/proof.f.d.mts +42 -2
  153. package/fjs/types/rtti/ts/proof.f.mjs +139 -6
  154. package/fjs/types/rtti/ts/types.d.ts +164 -16
  155. package/fjs/types/rtti/types.d.ts +31 -3
  156. package/fjs/types/rtti/validate/module.f.d.mts +42 -25
  157. package/fjs/types/rtti/validate/module.f.mjs +187 -53
  158. package/fjs/types/rtti/validate/proof.f.d.mts +28 -2
  159. package/fjs/types/rtti/validate/proof.f.mjs +430 -75
  160. package/fjs/types/ts/types.d.ts +4 -0
  161. package/fjs/web/module.f.d.mts +115 -0
  162. package/fjs/web/module.f.mjs +581 -0
  163. package/fjs/web/proof.f.d.mts +39 -0
  164. package/fjs/web/proof.f.mjs +413 -0
  165. package/fjs/web/types.d.ts +39 -0
  166. package/fjs/website/browser-prepare.d.mts +5 -0
  167. package/fjs/website/browser-prepare.mjs +115 -0
  168. package/fjs/website/browser-source.d.mts +37 -0
  169. package/fjs/website/browser-source.mjs +202 -0
  170. package/fjs/website/browser-source.proof.d.mts +31 -0
  171. package/fjs/website/browser-source.proof.mjs +162 -0
  172. package/fjs/website/browser.d.mts +5 -0
  173. package/fjs/website/browser.mjs +8 -0
  174. package/fjs/website/module.f.d.mts +3 -3
  175. package/fjs/website/module.f.mjs +62 -9
  176. package/fjs/website/proof.f.d.mts +4 -0
  177. package/fjs/website/proof.f.mjs +18 -3
  178. 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
+ };