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
@@ -16,6 +16,7 @@
16
16
  * @import { IoResult, Server as EffectServer, Headers, Module, NodeOp, RequestListener as Erl, NodeProgram, NodeProgramOptions, WriteConsoles, TestContext, TestFn, } from './types.ts'
17
17
  * @import { Result } from '../../types/result/types.ts'
18
18
  * @import { StringMap } from '../../types/object/types.ts'
19
+ * @import { Nullable } from '../../types/nullable/types.ts'
19
20
  */
20
21
 
21
22
  import http from 'node:http'
@@ -30,14 +31,28 @@ import * as testContext from 'node:test'
30
31
  import { concat, normalize, toPosix } from '../../path/module.f.mjs'
31
32
  import { asyncRun } from '../module.mjs'
32
33
  import { memoryOperationMap } from './memory/module.mjs'
33
- import { exitCode, toIoError, usesInlineTestContext } from './module.f.mjs'
34
+ import {
35
+ emptyHost, emptyHostCode, emptyHostMessage, exitCode, toIoError, usesInlineTestContext,
36
+ } from './module.f.mjs'
34
37
  import { asBase, asNominal } from '../../types/nominal/module.f.mjs'
35
38
  import { error, ok, unwrap } from '../../types/result/module.f.mjs'
36
- import { asyncTryCatch } from '../../types/result/module.mjs'
39
+ import { asyncTryCatch, tryCatch } from '../../types/result/module.mjs'
37
40
  import { fromVec, listToVec, toVec } from '../../types/uint8array/module.f.mjs'
38
41
  import { maxLengthBytes } from '../../types/bit_vec/module.f.mjs'
39
42
 
40
- /** @typedef {{ readonly listen: (port: number) => void }} _Server */
43
+ /** The one thing this runner does with the socket a `connect` event hands it.
44
+ *
45
+ * @typedef {{ readonly end: (data: string) => void }} _Socket
46
+ */
47
+
48
+ /**
49
+ * @typedef {{
50
+ * readonly listen: (port: number, host: string) => void,
51
+ * readonly once: (event: string, f: (e: unknown) => void) => void,
52
+ * on(event: string, f: (req: unknown, socket: _Socket) => void): void,
53
+ * readonly removeListener: (event: string, f: (e: unknown) => void) => void,
54
+ * }} _Server
55
+ */
41
56
 
42
57
  /** @typedef {AsyncIterable<Uint8Array>} _Readable */
43
58
 
@@ -53,6 +68,7 @@ import { maxLengthBytes } from '../../types/bit_vec/module.f.mjs'
53
68
  * @typedef {{
54
69
  * readonly writeHead: (status: number, headers: StringMap<string>) => _ServerResponse,
55
70
  * readonly end: (body: Uint8Array) => void,
71
+ * readonly headersSent: boolean,
56
72
  * }} _ServerResponse
57
73
  */
58
74
 
@@ -86,30 +102,146 @@ const io = async f => {
86
102
  }
87
103
 
88
104
  /**
89
- * @template T
90
- * @param {AsyncIterable<T>} v
91
- * @returns {Promise<readonly T[]>}
105
+ * Reads a request body, giving up at the `Vec` cap rather than at the point
106
+ * where converting it would throw.
107
+ *
108
+ * `listToVec` on an oversized body throws *after* the whole thing has been
109
+ * buffered, which is the wrong end of the problem twice over: the memory is
110
+ * already spent, and the throw lands inside an `async` request handler whose
111
+ * promise nobody awaits. Counting as the chunks arrive stops both.
112
+ *
113
+ * **The accumulator is mutated, deliberately.** Rebuilding the array per chunk
114
+ * — `result = [...result, a]`, the shape the rest of this repository is written
115
+ * in — copies every chunk received so far on every chunk received, which is
116
+ * quadratic in the *number* of chunks. The byte cap does not bound that: 20,000
117
+ * one-byte chunks are 20 KB and 200 million copies, and took 2,794 ms of event
118
+ * loop to reach an answer the server had already decided on — 167 ms now, and
119
+ * the growth went from ×4 per doubling to ×2. A cap on payload size is not a cap
120
+ * on chunk count, and a request that will be refused must not cost more than one
121
+ * that is served. The array never leaves this function before it
122
+ * is finished, so nothing observes the mutation — which is the condition under
123
+ * which the impure shell is allowed to be impure.
124
+ *
125
+ * @param {_Readable} v
126
+ * @returns {Promise<Nullable<readonly Uint8Array[]>>} `null` past the cap.
92
127
  */
93
- const collect = async v => {
94
- /** @type {readonly T[]} */
95
- let result = []
128
+ const collectBounded = async v => {
129
+ /** @type {Uint8Array[]} */
130
+ const result = []
131
+ let size = 0
96
132
  for await (const a of v) {
97
- result = [...result, a]
133
+ size += a.length
134
+ if (size > maxFileSizeBytes) { return null }
135
+ result.push(a)
98
136
  }
99
137
  return result
100
138
  }
101
139
 
140
+ /**
141
+ * The runner's own answer, for the cases a listener never gets to give one: a
142
+ * request body too large to hand it, and a listener that threw.
143
+ *
144
+ * **It closes the connection**, which is the whole difference between refusing
145
+ * a request and surviving the refusal. Both cases answer without having read
146
+ * the request to its end, and on a keep-alive connection Node then waits for
147
+ * the rest of a body that is never coming — the socket is stuck, and the next
148
+ * request on it is never answered. A client that declares ten megabytes and
149
+ * sends a hundred kilobytes could hold connections open that way for as long as
150
+ * it liked. Draining the remainder would be the polite alternative and the
151
+ * wrong one: it reads bytes this server has already decided it will not use.
152
+ *
153
+ * @type {(res: _ServerResponse) => (status: number) => (message: string) => void}
154
+ */
155
+ const respondWith = res => status => message => {
156
+ const body = textEncoder.encode(`${message}\n`)
157
+ res
158
+ .writeHead(status, {
159
+ 'content-type': 'text/plain; charset=utf-8',
160
+ 'content-length': `${body.length}`,
161
+ connection: 'close',
162
+ })
163
+ .end(body)
164
+ }
165
+
166
+ /**
167
+ * The whole HTTP response to a `CONNECT`, as bytes on a raw socket.
168
+ *
169
+ * Written by hand rather than through {@link respondWith}, because the `connect`
170
+ * event hands over a socket and not a `ServerResponse` — there is no object to
171
+ * ask for a status line.
172
+ *
173
+ * @type {string}
174
+ */
175
+ const connectRefusal =
176
+ 'HTTP/1.1 501 Not Implemented\r\n'
177
+ + 'content-type: text/plain; charset=utf-8\r\n'
178
+ + 'content-length: 26\r\n'
179
+ + 'connection: close\r\n'
180
+ + '\r\n'
181
+ + 'this server cannot tunnel\n'
182
+
183
+ /**
184
+ * Answers one request through `listener`, or explains that it could not.
185
+ *
186
+ * A body past the cap never reaches the listener: `IncomingMessage.body` is a
187
+ * single `Vec`, so there is no request value to build, and `413` is the accurate
188
+ * answer rather than a truncated one. Streaming bodies lift the whole limit —
189
+ * see `./todo/streaming-http-bodies.md`.
190
+ *
191
+ * `unwrap` is total here: a `RequestListener` answers
192
+ * `Effect<…, ServerResponse, never>`, because the response frame *is* where a
193
+ * listener puts its failures.
194
+ *
195
+ * @type {(listener: Erl<NodeOp>) => _RequestListener}
196
+ */
197
+ const answerRequest = listener => async (req, res) => {
198
+ const body = await collectBounded(req)
199
+ if (body === null) {
200
+ respondWith(res)(413)('request body too large')
201
+ return
202
+ }
203
+ const { method, url, headers } = req
204
+ const { status, headers: outHeaders, body: outBody } = unwrap(await runNodeEffect(listener({
205
+ method,
206
+ url,
207
+ headers,
208
+ body: listToVec(body),
209
+ })))
210
+ res.writeHead(status, outHeaders).end(fromVec(outBody))
211
+ }
212
+
213
+ /**
214
+ * What a request gets when answering it threw.
215
+ *
216
+ * Once the listener has started writing there is no status left to change, so
217
+ * the only thing owed is an end to the response — leaving it open would hang
218
+ * the connection until it times out.
219
+ *
220
+ * @type {(res: _ServerResponse) => void}
221
+ */
222
+ const failSafe = res => {
223
+ if (res.headersSent) {
224
+ res.end(emptyBody)
225
+ return
226
+ }
227
+ respondWith(res)(500)('internal server error')
228
+ }
229
+
102
230
  const { mkdir, open, readFile, readdir, rename, writeFile, rm, access, stat } = fs.promises
103
231
 
104
232
  const { exec } = childProcess
105
233
 
106
234
  const maxFileSizeBytes = Number(maxLengthBytes)
107
235
 
236
+ const textEncoder = new TextEncoder()
237
+
238
+ const emptyBody = new Uint8Array()
239
+
108
240
  const prefix = /** @type {const} */ ('file:///')
109
241
 
110
242
  /** @type {(v: string) => Promise<Module>} */
111
243
  const asyncImport = v => {
112
- const s0 = v.includes(':') || v.startsWith('/') ? v : concat(process.cwd())(v)
244
+ const s0 = v.includes(':') ? v : concat(process.cwd())(v)
113
245
  const s1 = s0.startsWith(prefix) ? s0 : `${prefix}${s0}`
114
246
  return import(s1)
115
247
  }
@@ -291,7 +423,10 @@ const runNodeEffect = asyncRun({
291
423
  await fh.close()
292
424
  }
293
425
  }),
294
- stat: path => io(async () => ({ size: (await stat(path)).size })),
426
+ stat: path => io(async () => {
427
+ const s = await stat(path)
428
+ return { size: s.size, isFile: s.isFile() }
429
+ }),
295
430
  import: path => io(() => asyncImport(path)),
296
431
  exec: (command, stdin) => new Promise(resolve => {
297
432
  const child = exec(command, (e, stdout, stderr) =>
@@ -300,31 +435,98 @@ const runNodeEffect = asyncRun({
300
435
  child.stdin?.end(stdin)
301
436
  }),
302
437
  createServer: async requestListener => {
303
- const erl = /** @type {Erl<NodeOp>} */ (requestListener)
438
+ const answer = answerRequest(/** @type {Erl<NodeOp>} */ (requestListener))
439
+ // **Nothing may escape this handler.** Node does not await the promise
440
+ // an `async` request listener returns, so a throw inside one becomes an
441
+ // unhandled rejection and ends the *process*: one request, and the
442
+ // whole server is gone. A panic must not outlive the request that
443
+ // caused it, so the answer is caught here and the fallback — itself
444
+ // able to throw on a socket that has since died — is caught too.
304
445
  /** @type {_RequestListener} */
305
446
  const nodeRl = async (req, res) => {
306
- const reqBody = await collect(req)
307
- const { method, url, headers } = req
308
- // `RequestListener` answers `Effect<…, ServerResponse, never>` —
309
- // the response frame is where a listener puts its failures — so
310
- // this unwrap is total.
311
- const { status, headers: outHeaders, body: outBody } = unwrap(await runNodeEffect(erl({
312
- method,
313
- url,
314
- headers,
315
- body: listToVec(reqBody)
316
- })))
317
- res
318
- .writeHead(status, outHeaders)
319
- .end(fromVec(outBody))
447
+ const r = await asyncTryCatch(() => answer(req, res))
448
+ if (r[0] === 'error') { tryCatch(() => failSafe(res)) }
320
449
  }
321
- return ok(/** @satisfies {EffectServer} */ (asNominal(createServer(nodeRl))))
450
+ const server = createServer(nodeRl)
451
+ // A `CONNECT` never reaches the listener: Node routes it to the
452
+ // `connect` event, and with no handler there it drops the socket
453
+ // without a byte of HTTP — checked on Linux with Node 22.22.2, where
454
+ // `CONNECT localhost:18084 HTTP/1.1` closed the connection while a
455
+ // `POST` to the same server was answered. A client that asked a
456
+ // question deserves an answer, so the runner gives the one it can.
457
+ //
458
+ // `501`, not `405`. A `405` must carry `Allow` (RFC 9110 §15.5.6) and
459
+ // only the listener knows what it allows, while `501` is exactly what
460
+ // RFC 9110 §15.6.2 describes — a method the server cannot support for
461
+ // any resource. That is true of *every* server this effect layer can
462
+ // build: `RequestListener` maps a request frame to a response frame and
463
+ // has no vocabulary for a tunnel, so no listener could answer a
464
+ // `CONNECT` even if it were handed one.
465
+ //
466
+ // Answering here rather than passing it on follows the `413` and `500`
467
+ // above: the runner answers on the listener's behalf exactly when the
468
+ // listener structurally cannot.
469
+ server.on('connect', (_, socket) => { tryCatch(() => socket.end(connectRefusal)) })
470
+ return ok(/** @satisfies {EffectServer} */ (asNominal(server)))
322
471
  },
323
- listen: async (server, port) => {
472
+ // Binding is asynchronous, and its failure arrives as an `error` event
473
+ // rather than a throw: answering `ok` the moment `listen` was *called*
474
+ // reported a server that never started, and Node then killed the process
475
+ // with an unhandled `EADDRINUSE` — after the program had already printed
476
+ // the URL it was serving. So this settles on the outcome, not on the call.
477
+ listen: (server, port, host) => io(() => new Promise((resolve, reject) => {
324
478
  const s = /** @type {_Server} */ (asBase(server))
325
- s.listen(port)
326
- return ok(undefined)
327
- },
479
+ // An empty host is the trap this operation's required `host` argument
480
+ // exists to close, so it is refused rather than forwarded. Node treats
481
+ // `''` exactly as it treats an omitted argument and binds the
482
+ // unspecified address — `0.0.0.0` on Linux with Node 22.22.2 and `::`
483
+ // on Darwin with Node 23.11.0, a different address each and the same
484
+ // mistake — which is how a missing configuration value publishes a
485
+ // server on every interface while the program believes it stated an
486
+ // address. A program that wants every interface says `'0.0.0.0'` or
487
+ // `'::'` and means it.
488
+ //
489
+ // The error is Node's own code and message shape for an argument it
490
+ // rejects, since a caller reading `IoError.code` should not have to
491
+ // learn a second vocabulary for a refusal that is this runner's own.
492
+ if (host === emptyHost) {
493
+ reject(Object.assign(new Error(emptyHostMessage), { code: emptyHostCode }))
494
+ return
495
+ }
496
+ // Each handler removes the other, so exactly one outcome is recorded and
497
+ // neither is left attached. `once` only removes the handler that fired:
498
+ // a failed bind used to leave its `listening` handler behind, and a
499
+ // caller retrying after an `EADDRINUSE` accumulated one per attempt
500
+ // until Node warned about the leak.
501
+ /** @type {() => void} */
502
+ const onListening = () => {
503
+ // A later `error` event is not this effect's to answer, and a
504
+ // handler still holding `reject` would swallow it into an
505
+ // already-settled promise.
506
+ s.removeListener('error', onError)
507
+ resolve(undefined)
508
+ }
509
+ /** @type {(e: unknown) => void} */
510
+ const onError = e => {
511
+ s.removeListener('listening', onListening)
512
+ reject(e)
513
+ }
514
+ s.once('error', onError)
515
+ s.once('listening', onListening)
516
+ // `listen` can also fail *synchronously* — an out-of-range port throws
517
+ // `ERR_SOCKET_BAD_PORT`, an already-listening server throws too — and a
518
+ // throw here would reject the promise past both handlers, leaving them
519
+ // attached: 20 attempts, 20 stale `error` handlers, each holding a
520
+ // `reject` that can never fire and would swallow a later error into an
521
+ // already-settled promise. So the synchronous path cleans up after
522
+ // itself, exactly as the two event paths do.
523
+ const started = tryCatch(() => s.listen(port, host))
524
+ if (started[0] === 'error') {
525
+ s.removeListener('error', onError)
526
+ s.removeListener('listening', onListening)
527
+ reject(started[1])
528
+ }
529
+ })),
328
530
  forever: () => new Promise(() => {}),
329
531
  now: async () => ok(now()),
330
532
  sandbox: async f => ok(await sandbox(f)),
@@ -127,9 +127,20 @@ export type CreateExclusive = readonly ['createExclusive', (path: string) => IoR
127
127
  export type WriteBytes = readonly ['writeBytes', (path: string, offset: number, data: Vec) => IoResult<void>];
128
128
  /** @internal */
129
129
  export type _WriteLoop = <O extends Operation>(offset: number, e: List<O, Vec, IoChannel>) => Effect<O | WriteBytes, void, IoChannel>;
130
- /** File metadata returned by `stat`. Only `size` (in bytes) for now. */
130
+ /**
131
+ * File metadata returned by `stat`: the size in bytes, and whether the entry is
132
+ * a *regular* file.
133
+ *
134
+ * `isFile` is not a convenience. Reading a FIFO, a device or a socket is not
135
+ * reading a file: `open` on a FIFO with no writer blocks until one appears, so a
136
+ * `readFile` that reaches one never returns and holds a thread-pool slot for as
137
+ * long as it waits. Size cannot stand in for the check — a FIFO stats as zero
138
+ * bytes and passes every bound. It is the same question `Dirent` answers for a
139
+ * directory listing, asked about one path.
140
+ */
131
141
  export type FileStat = {
132
142
  readonly size: number;
143
+ readonly isFile: boolean;
133
144
  };
134
145
  export type Stat = readonly ['stat', (path: string) => IoResult<FileStat>];
135
146
  export type Fs = Mkdir | ReadFile | ReadBytes | Readdir | WriteFile | Rm | Rename | Exec | Access | CreateExclusive | WriteBytes | Stat;
@@ -153,7 +164,22 @@ export type ServerResponse = {
153
164
  */
154
165
  export type RequestListener<O extends Operation> = (_: IncomingMessage) => Effect<O, ServerResponse, never>;
155
166
  export type CreateServer = ['createServer', (listener: RequestListener<Operation>) => OpResult<Server>];
156
- export type Listen = ['listen', (server: Server, port: number) => OpResult<void>];
167
+ /**
168
+ * Starts accepting connections on `port` of `host`.
169
+ *
170
+ * The host is **required**, and that is the whole point of it: Node's own
171
+ * `listen(port)` binds the unspecified address, so omitting it publishes the
172
+ * server to every interface — a default nobody chose, and one a program serving
173
+ * local files must not get by writing less. Pass `'127.0.0.1'` for loopback
174
+ * only, `'0.0.0.0'` (or `'::'`) to accept from anywhere.
175
+ *
176
+ * It answers an {@link IoResult} because binding is where a server most often
177
+ * fails — the port is taken, the address is not the host's — and that failure
178
+ * arrives asynchronously, as the server's `error` event. An operation that
179
+ * answered the moment `listen` was *called* would report a server that never
180
+ * started, and leave the host to kill the process a moment later.
181
+ */
182
+ export type Listen = ['listen', (server: Server, port: number, host: string) => IoResult<void>];
157
183
  export type Http = CreateServer | Listen;
158
184
  export type Forever = ['forever', () => OpResult<never>];
159
185
  export type Module = StringMap<unknown>;
@@ -5,10 +5,11 @@
5
5
  *
6
6
  * @import { Vec } from '../../../types/bit_vec/types.ts'
7
7
  * @import { PartialMemOperationMap, RunInstance } from '../../mock/types.ts'
8
- * @import { Dirent, FileStat, IoError, IoResult, Module, NodeOp, NodeProgramOptions, SandboxResult } from '../types.ts'
8
+ * @import { Dirent, FileStat, IoError, IoResult, Module, NodeOp, NodeProgramOptions, OpResult, RequestListener, SandboxResult, Server } from '../types.ts'
9
+ * @import { Operation } from '../../types.ts'
9
10
  * @import { Result } from '../../../types/result/types.ts'
10
11
  * @import { Error } from '../../../types/result/types.ts'
11
- * @import { Dir, State, _Entity } from './types.ts'
12
+ * @import { Dir, JsModule, State, _Entity, _VirtualListener, _VirtualServer } from './types.ts'
12
13
  */
13
14
  import type { RunInstance } from '../../mock/types.ts';
14
15
  import type { NodeOp, NodeProgramOptions } from '../types.ts';
@@ -18,14 +19,22 @@ export declare const emptyState: State;
18
19
  /**
19
20
  * The virtual runner.
20
21
  *
21
- * **It implements part of `NodeOp`, and says so.** `exec`, `createServer`,
22
- * `listen`, `forever` and `test` have no meaning against an in-memory
23
- * filesystem, and they used to be present as `todo` handlers — entries that
24
- * existed only to satisfy a total operation map and threw when reached. They
25
- * are simply absent now, so a program that asks for one gets
26
- * `error(notImplemented)` back through its own continuation and decides what an
27
- * incompatible runner means for it, which is what `NotImplemented` was
28
- * introduced for. A command that is not a `NodeOp` at all still panics.
22
+ * **It implements part of `NodeOp`, and says so.** `exec`, `forever` and `test`
23
+ * have no meaning against an in-memory filesystem, and they used to be present
24
+ * as `todo` handlers — entries that existed only to satisfy a total operation
25
+ * map and threw when reached. They are simply absent now, so a program that
26
+ * asks for one gets `error(notImplemented)` back through its own continuation
27
+ * and decides what an incompatible runner means for it, which is what
28
+ * `NotImplemented` was introduced for. A command that is not a `NodeOp` at all
29
+ * still panics.
30
+ *
31
+ * `forever` is absent for a reason no implementation could remove: its result
32
+ * type is `Result<never, NotImplemented>`, so `error(notImplemented)` is the
33
+ * *only* value it can produce — a runner that cannot block forever has nothing
34
+ * else to answer, and a server program run here therefore ends where it would
35
+ * otherwise have blocked. `createServer` and `listen` do have meanings without
36
+ * a socket and are implemented above, which is what makes the rest of such a
37
+ * program provable.
29
38
  *
30
39
  * @type {RunInstance<NodeOp, State>}
31
40
  */