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,413 @@
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
+
7
+ import { assert, assertEq } from '../asserts/module.f.mjs'
8
+ import { exitCode } from '../effects/node/module.f.mjs'
9
+ import { defaultNodeProgramOptions, emptyState, virtual } from '../effects/node/virtual/module.f.mjs'
10
+ import { nodeCommands } from '../effects/node/module.f.mjs'
11
+ import { partialRun } from '../effects/mock/module.f.mjs'
12
+ import { utf8, utf8ToString } from '../text/module.f.mjs'
13
+ import { empty, length, vec } from '../types/bit_vec/module.f.mjs'
14
+ import { unwrap } from '../types/result/module.f.mjs'
15
+ import { main, resolve, respond } from './module.f.mjs'
16
+
17
+ /** @type {string} */
18
+ const page = '<h1>hi</h1>'
19
+
20
+ /** A file system with a page at the root and one in a subdirectory.
21
+ *
22
+ * @type {Dir}
23
+ */
24
+ const site = {
25
+ 'index.html': [utf8(page)],
26
+ 'main.css': [utf8('body {}')],
27
+ docs: { 'index.html': [utf8('docs')] },
28
+ }
29
+
30
+ /** A request as a browser on this machine sends it, `Host` included.
31
+ *
32
+ * @type {(method: string, url: string) => IncomingMessage}
33
+ */
34
+ const request = (method, url) => hosted('127.0.0.1:8080')(method, url)
35
+
36
+ /** @type {(host: string) => (method: string, url: string) => IncomingMessage} */
37
+ const hosted = host => (method, url) => ({ method, url, headers: { host }, body: empty })
38
+
39
+ /**
40
+ * Answers one request against `root`, which every case below is a variation of.
41
+ *
42
+ * @type {(root: Dir) => (method: string, url: string) => ServerResponse}
43
+ */
44
+ const answer = root => (method, url) =>
45
+ unwrap(virtual({ ...emptyState, root })(respond('.')(request(method, url)))[1])
46
+
47
+ const answerSite = answer(site)
48
+
49
+ /** @type {(r: ServerResponse) => string} */
50
+ const body = r => utf8ToString(r.body)
51
+
52
+ /** @type {(r: ServerResponse) => string} */
53
+ const contentType = ({ headers }) => `${headers['content-type']}`
54
+
55
+ /** @type {(r: ServerResponse) => string} */
56
+ const contentLength = ({ headers }) => `${headers['content-length']}`
57
+
58
+ // A file one byte past what a single `Vec` holds, built as chunks of a
59
+ // kibibyte: `stat` sums the chunk sizes, so the size is reached without
60
+ // materializing the bytes.
61
+ /** @type {Vec} */
62
+ const kib = vec(8192n)(0n)
63
+
64
+ /** @type {Dir} */
65
+ const hugeRoot = { 'huge.bin': Array.from({ length: 129 }, () => kib) }
66
+
67
+ export const proof = {
68
+ resolve: {
69
+ // The bare `/` and any directory path are the site's `index.html` —
70
+ // without this a generated site cannot be opened at all.
71
+ index: () => {
72
+ assertEq(unwrap(resolve('.')('/')), './index.html')
73
+ assertEq(unwrap(resolve('.')('/docs/')), './docs/index.html')
74
+ },
75
+ // An absolute-form target is a proxy's spelling of the same request, and
76
+ // RFC 9112 §3.2.2 requires an origin server to accept it. Read as a
77
+ // path it named a file called `http:` and answered `404` for the wrong
78
+ // reason.
79
+ absoluteForm: () => {
80
+ assertEq(unwrap(resolve('.')('http://127.0.0.1:8080/main.css')), './main.css')
81
+ assertEq(unwrap(resolve('.')('https://localhost/docs/')), './docs/index.html')
82
+ // The scheme is case-insensitive, as schemes are.
83
+ assertEq(unwrap(resolve('.')('HTTP://localhost/main.css')), './main.css')
84
+ // No path at all is the root of that authority.
85
+ assertEq(unwrap(resolve('.')('http://localhost')), './index.html')
86
+ // The query still goes, and traversal is still rejected after the
87
+ // authority is taken off.
88
+ assertEq(unwrap(resolve('.')('http://localhost/main.css?v=2')), './main.css')
89
+ },
90
+ file: () => {
91
+ assertEq(unwrap(resolve('.')('/main.css')), './main.css')
92
+ // The query and the fragment are not part of the path.
93
+ assertEq(unwrap(resolve('.')('/main.css?v=2')), './main.css')
94
+ assertEq(unwrap(resolve('.')('/main.css#top')), './main.css')
95
+ // `.` and a collapsible `..` are normalized, not rejected.
96
+ assertEq(unwrap(resolve('.')('/./docs/../main.css')), './main.css')
97
+ },
98
+ // An absolute root stays absolute: `join` does not renormalize, which
99
+ // is why it is used here rather than `concat`.
100
+ absoluteRoot: () => {
101
+ assertEq(unwrap(resolve('/var/www')('/main.css')), '/var/www/main.css')
102
+ },
103
+ // An empty root is the working directory. Left alone it would be the
104
+ // file system root instead — `join('', 'etc')` is `/etc` — and the
105
+ // argument's default cannot catch it, since `''` is a value the caller
106
+ // passed rather than an absent one.
107
+ emptyRoot: () => {
108
+ assertEq(unwrap(resolve('')('/etc/passwd')), './etc/passwd')
109
+ assertEq(unwrap(resolve('')('/')), './index.html')
110
+ },
111
+ percentEncoding: () => {
112
+ assertEq(unwrap(resolve('.')('/a%20b.txt')), './a b.txt')
113
+ // Several escapes spelling one character, which is why the bytes
114
+ // are decoded as a whole rather than per escape.
115
+ assertEq(unwrap(resolve('.')('/%D0%9F.txt')), './П.txt')
116
+ },
117
+ // Every way a URL fails to name a path under the root.
118
+ rejected: () => {
119
+ /** @type {(url: string) => string} */
120
+ const reason = url => {
121
+ const r = resolve('.')(url)
122
+ assert(r[0] === 'error', r)
123
+ return `${r[1].status} ${r[1].message}`
124
+ }
125
+ assertEq(reason('/../secret'), '400 request path escapes the served root')
126
+ assertEq(reason('/docs/../../secret'), '400 request path escapes the served root')
127
+ // A percent escape that is not two hexadecimal digits, at the end
128
+ // of the URL and in the middle of it.
129
+ assertEq(reason('/a%'), '400 malformed request URL')
130
+ assertEq(reason('/a%zz.txt'), '400 malformed request URL')
131
+ assertEq(reason('/a%2z.txt'), '400 malformed request URL')
132
+ // Well-formed escapes spelling bytes that are not UTF-8.
133
+ assertEq(reason('/%ff.txt'), '400 malformed request URL')
134
+ // Targets that are neither origin-form nor absolute-form: the
135
+ // asterisk-form, an authority-form from a `CONNECT`, and nothing.
136
+ assertEq(reason('*'), '400 malformed request URL')
137
+ assertEq(reason('localhost:8080'), '400 malformed request URL')
138
+ assertEq(reason(''), '400 malformed request URL')
139
+ // Traversal is rejected after the authority comes off, not before.
140
+ assertEq(reason('http://localhost/../secret'), '400 request path escapes the served root')
141
+ // An authority carrying userinfo is refused rather than parsed
142
+ // past: it reads as a different host from each end.
143
+ assertEq(reason('http://127.0.0.1:8080@attacker.example/x'), '400 malformed request URL')
144
+ // A scheme is not whatever precedes `://`: these name none, and
145
+ // reading them as absolute-form served the file.
146
+ assertEq(reason('://localhost/x'), '400 malformed request URL')
147
+ assertEq(reason('1://localhost/x'), '400 malformed request URL')
148
+ assertEq(reason('ftp://localhost/x'), '400 malformed request URL')
149
+ // An `http` URI with an empty host is one RFC 9110 §4.2.1 says to
150
+ // reject: this parser would read `/index.html` as the path where a
151
+ // URL parser reads `index.html` as the host.
152
+ assertEq(reason('http:///index.html'), '400 malformed request URL')
153
+ assertEq(reason('http://:80/index.html'), '400 malformed request URL')
154
+ assertEq(reason('http://'), '400 malformed request URL')
155
+ // A NUL is a bad request, not a host failure: left to the file
156
+ // system it comes back as an `ERR_INVALID_ARG_VALUE` and a `500`.
157
+ assertEq(reason('/main.css%00'), '400 malformed request URL')
158
+ // A dot-prefixed segment is `404`, at any depth: whether `.env` or
159
+ // `.git/config` exists is itself what is not being disclosed.
160
+ assertEq(reason('/.env'), '404 not found')
161
+ assertEq(reason('/.git/config'), '404 not found')
162
+ assertEq(reason('/docs/.secret/key'), '404 not found')
163
+ },
164
+ },
165
+ respond: {
166
+ found: () => {
167
+ const r = answerSite('GET', '/')
168
+ assertEq(r.status, 200)
169
+ assertEq(body(r), page)
170
+ assertEq(contentType(r), 'text/html; charset=utf-8')
171
+ // Stated, not left to the runner: Node sends an unmeasured body
172
+ // chunked, and a `HEAD` client would learn neither bytes nor size.
173
+ assertEq(contentLength(r), `${page.length}`)
174
+ // The `Content-Type` is derived from the name, so a browser must
175
+ // not go looking for a second opinion in the bytes.
176
+ assertEq(`${r.headers['x-content-type-options']}`, 'nosniff')
177
+ },
178
+ // `HEAD` is answered exactly like `GET`; Node drops the body itself.
179
+ head: () => {
180
+ const r = answerSite('HEAD', '/main.css')
181
+ assertEq(r.status, 200)
182
+ assertEq(contentType(r), 'text/css; charset=utf-8')
183
+ assertEq(contentLength(r), '7')
184
+ },
185
+ missing: () => {
186
+ const r = answerSite('GET', '/nope.html')
187
+ assertEq(r.status, 404)
188
+ assertEq(body(r), 'not found\n')
189
+ assertEq(contentType(r), 'text/plain; charset=utf-8')
190
+ assertEq(contentLength(r), '10')
191
+ },
192
+ // Binding loopback does not stop a browser from being told that a name
193
+ // the attacker owns lives at 127.0.0.1 — only the `Host` header says
194
+ // which name the request was really for.
195
+ rebinding: () => {
196
+ /** @type {(host: string) => number} */
197
+ const status = host =>
198
+ unwrap(virtual({ ...emptyState, root: site })(
199
+ respond('.')(hosted(host)('GET', '/')))[1]).status
200
+ assertEq(status('attacker.example'), 403)
201
+ assertEq(status('attacker.example:8080'), 403)
202
+ // The names it does answer for, with and without a port, and as an
203
+ // IPv6 literal — whose brackets are part of the name.
204
+ assertEq(status('127.0.0.1:8080'), 200)
205
+ assertEq(status('localhost'), 200)
206
+ // A host name is case-insensitive, and a trailing dot names the DNS
207
+ // root rather than a different machine; refusing either would be a
208
+ // bug, not a defence.
209
+ assertEq(status('LOCALHOST:8080'), 200)
210
+ assertEq(status('localhost.'), 200)
211
+ assertEq(status('localhost.:8080'), 200)
212
+ assertEq(status('[::1]:8080'), 200)
213
+ // Userinfo names a credential, not a host: read from the left,
214
+ // `127.0.0.1:8080@attacker.example` looks like loopback, and the
215
+ // host it actually names is the attacker's.
216
+ assertEq(status('127.0.0.1:8080@attacker.example'), 403)
217
+ assertEq(status('user@localhost'), 403)
218
+ // What follows a name may be a port and nothing else. Reading the
219
+ // prefix and discarding the rest made each of these read as a name
220
+ // this server answers for.
221
+ assertEq(status('localhost:bad'), 403)
222
+ assertEq(status('localhost:8080:999'), 403)
223
+ assertEq(status('localhost:'), 403)
224
+ assertEq(status('[::1]evil'), 403)
225
+ // And a port is digits in range: `65536` is a number and not a
226
+ // port, which is why `new URL` refuses the same authority.
227
+ assertEq(status('localhost:65535'), 200)
228
+ assertEq(status('localhost:65536'), 403)
229
+ assertEq(status('localhost:999999'), 403)
230
+ assertEq(status('[::1]:65536'), 403)
231
+ // Read as a number, not counted: a parser reads `00008080` as
232
+ // `8080`, and a length test would call it five digits too many.
233
+ assertEq(status('localhost:00008080'), 200)
234
+ // A bracket with no closing `]` names nothing.
235
+ assertEq(status('[::1'), 403)
236
+ // An absolute-form target names its own host, and RFC 9112 says to
237
+ // believe it over the header — so a proxy-shaped request for a name
238
+ // this server does not answer for is refused even when the `Host`
239
+ // header says something reassuring.
240
+ // The same trick through the target rather than the header: the
241
+ // authority names the attacker, and the reassuring `Host` does not
242
+ // rescue it.
243
+ const credentialed = unwrap(virtual({ ...emptyState, root: site })(
244
+ respond('.')({
245
+ method: 'GET',
246
+ url: 'http://127.0.0.1:8080@attacker.example/index.html',
247
+ headers: { host: 'localhost:8080' },
248
+ body: empty,
249
+ }))[1])
250
+ assertEq(credentialed.status, 400)
251
+ const spoofed = unwrap(virtual({ ...emptyState, root: site })(
252
+ respond('.')({
253
+ method: 'GET',
254
+ url: 'http://attacker.example/index.html',
255
+ headers: { host: 'localhost:8080' },
256
+ body: empty,
257
+ }))[1])
258
+ assertEq(spoofed.status, 403)
259
+ // And the same target for a name it does answer for is served.
260
+ const proxied = unwrap(virtual({ ...emptyState, root: site })(
261
+ respond('.')({
262
+ method: 'GET',
263
+ url: 'http://localhost:8080/index.html',
264
+ headers: {},
265
+ body: empty,
266
+ }))[1])
267
+ assertEq(proxied.status, 200)
268
+ // HTTP/1.1 requires a `Host`; its absence is not a way around this.
269
+ const noHost = unwrap(virtual({ ...emptyState, root: site })(
270
+ respond('.')({ method: 'GET', url: '/', headers: {}, body: empty }))[1])
271
+ assertEq(noHost.status, 403)
272
+ assertEq(body(noHost), 'host not served\n')
273
+ },
274
+ methodNotAllowed: () => {
275
+ const r = answerSite('POST', '/')
276
+ assertEq(r.status, 405)
277
+ assertEq(body(r), 'only GET and HEAD are supported\n')
278
+ // A refusal that does not say what would be accepted leaves the
279
+ // client to guess; RFC 9110 requires the list.
280
+ assertEq(`${r.headers['allow']}`, 'GET, HEAD')
281
+ },
282
+ traversal: () => {
283
+ const r = answerSite('GET', '/../secret')
284
+ assertEq(r.status, 400)
285
+ assertEq(body(r), 'request path escapes the served root\n')
286
+ },
287
+ // A directory without a trailing slash is not a file, and is answered
288
+ // as absent rather than redirected — this version has no redirect.
289
+ directoryWithoutSlash: () => {
290
+ const r = answerSite('GET', '/docs')
291
+ assertEq(r.status, 404)
292
+ },
293
+ // A dotfile is answered as absent, even when it is right there.
294
+ hidden: () => {
295
+ /** @type {Dir} */
296
+ const root = { '.env': [utf8('KEY=1')] }
297
+ const r = answer(root)('GET', '/.env')
298
+ assertEq(r.status, 404)
299
+ assertEq(body(r), 'not found\n')
300
+ },
301
+ // The size is read before the bytes are, so a file too large for one
302
+ // `Vec` is refused rather than truncated.
303
+ tooLarge: () => {
304
+ const r = answer(hugeRoot)('GET', '/huge.bin')
305
+ assertEq(r.status, 413)
306
+ assertEq(body(r), 'file is 132096 bytes; this server cannot answer with more than 131072\n')
307
+ },
308
+ // An entry that exists and is not a regular file is answered as absent
309
+ // — and, crucially, is never read: a FIFO would block the read forever.
310
+ // A `JsModule` is this file system's non-regular entry.
311
+ notRegular: () => {
312
+ /** @type {Dir} */
313
+ const root = { 'pipe.txt': () => ({}) }
314
+ const r = answer(root)('GET', '/pipe.txt')
315
+ assertEq(r.status, 404)
316
+ assertEq(body(r), 'not found\n')
317
+ },
318
+ // A host failure that is not a missing path is not a 404. A runner that
319
+ // cannot `stat` at all is the sharpest case: nothing looked for the
320
+ // file, so answering "not found" would be a claim nobody checked.
321
+ hostFailure: () => {
322
+ const noFs = partialRun(nodeCommands)({})
323
+ const r = unwrap(noFs(emptyState)(respond('.')(request('GET', '/index.html')))[1])
324
+ assertEq(r.status, 500)
325
+ assertEq(body(r), 'operation not implemented: stat\n')
326
+ },
327
+ },
328
+ main: {
329
+ // The whole program, request in and response out, without a socket:
330
+ // `createServer` stores the listener, `listen` delivers what the
331
+ // fixture queued, and the run ends where the real one would have
332
+ // blocked forever.
333
+ endToEnd: () => {
334
+ /** @type {State} */
335
+ const state = {
336
+ ...emptyState,
337
+ root: site,
338
+ requests: [request('GET', '/'), request('GET', '/docs/'), request('DELETE', '/')],
339
+ }
340
+ const [s, result] = virtual(state)(main({ ...defaultNodeProgramOptions, args: [] }))
341
+ // Loopback, and the URL says so: a server that binds every
342
+ // interface while announcing `localhost` is the trap this avoids.
343
+ assertEq(s.listening.map(b => b.address).join(), '127.0.0.1:8080')
344
+ assertEq(s.stdout, 'serving . on http://127.0.0.1:8080/\n')
345
+ const [first, second, third] = s.responses
346
+ assertEq(s.responses.length, 3)
347
+ assertEq(first.status, 200)
348
+ assertEq(body(first), page)
349
+ assertEq(body(second), 'docs')
350
+ assertEq(third.status, 405)
351
+ // `forever` is the one operation no virtual runner can answer, so
352
+ // the program stops there and reports it — the exit code says the
353
+ // server did not run to completion, which is the truth.
354
+ assertEq(exitCode(result), 1)
355
+ assertEq(s.stderr, 'operation not implemented: forever\n')
356
+ },
357
+ // An empty root argument is the working directory, in the announced
358
+ // line as well as in what gets served.
359
+ emptyRoot: () => {
360
+ const options = { ...defaultNodeProgramOptions, args: [''] }
361
+ const [s] = virtual({ ...emptyState, root: site })(main(options))
362
+ assertEq(s.stdout, 'serving . on http://127.0.0.1:8080/\n')
363
+ },
364
+ // Both arguments given, and a root that is not the working directory.
365
+ arguments: () => {
366
+ /** @type {State} */
367
+ const state = {
368
+ ...emptyState,
369
+ root: { site },
370
+ requests: [request('GET', '/index.html')],
371
+ }
372
+ const options = { ...defaultNodeProgramOptions, args: ['site', '9090'] }
373
+ const [s] = virtual(state)(main(options))
374
+ assertEq(s.listening.map(b => b.address).join(), '127.0.0.1:9090')
375
+ assertEq(s.stdout, 'serving site on http://127.0.0.1:9090/\n')
376
+ assertEq(s.responses[0].status, 200)
377
+ },
378
+ // A port that is not a port is a command-line mistake, not a defect:
379
+ // reported on `stderr` with exit code 1, like every other `fjs` command.
380
+ badPort: () => {
381
+ /** @type {(argument: string) => void} */
382
+ const rejects = argument => {
383
+ const options = { ...defaultNodeProgramOptions, args: ['.', argument] }
384
+ const [s, result] = virtual(emptyState)(main(options))
385
+ assertEq(exitCode(result), 1)
386
+ assertEq(s.stderr, `invalid port "${argument}"\n`)
387
+ // Nothing was bound: the argument is refused before the server
388
+ // is created, let alone listened on.
389
+ assertEq(s.listening.length, 0)
390
+ }
391
+ rejects('http')
392
+ rejects('8080.5')
393
+ rejects('-1')
394
+ rejects('65536')
395
+ // Node reads `0` as "any free port", and nothing here can ask which
396
+ // one it got, so the announced URL would name a dead port.
397
+ rejects('0')
398
+ },
399
+ },
400
+ // `listen` with nothing queued still records the port, and empties the
401
+ // queue so a second call cannot answer the same request twice.
402
+ virtualServer: {
403
+ noRequests: () => {
404
+ const [s] = virtual(emptyState)(main({ ...defaultNodeProgramOptions, args: [] }))
405
+ assertEq(s.listening.map(b => b.address).join(), '127.0.0.1:8080')
406
+ assertEq(s.responses.length, 0)
407
+ assertEq(s.requests.length, 0)
408
+ },
409
+ emptyBody: () => {
410
+ assertEq(length(request('GET', '/').body), 0n)
411
+ },
412
+ },
413
+ }
@@ -0,0 +1,39 @@
1
+ /** Types for the static file server. */
2
+ import type { Effect } from '../effects/types.ts';
3
+ import type { Forever, Fs, Http, IncomingMessage, ServerResponse, Write } from '../effects/node/types.ts';
4
+ import type { Result } from '../types/result/types.ts';
5
+ /** Every operation the server performs: the HTTP setup, the reads it answers with, and its one log line. */
6
+ export type WebOp = Fs | Http | Forever | Write;
7
+ /**
8
+ * Why a URL names no path this server will answer with, as the response it
9
+ * earns: `400` for a URL that is malformed or climbs out of the root, `404` for
10
+ * one this server declines to admit exists.
11
+ *
12
+ * Both the status and the sentence are decided here, where the reason is known,
13
+ * rather than at the point that turns the refusal into a frame — which would
14
+ * then have to re-derive from a message string which kind of refusal it was.
15
+ */
16
+ export type Refusal = {
17
+ readonly status: number;
18
+ readonly message: string;
19
+ };
20
+ /**
21
+ * Maps a request URL to the path of the file that answers it, or says why no
22
+ * path does. Pure — the whole routing decision, with nothing to run.
23
+ *
24
+ * Everything it can fail on is a property of the URL. What is discovered by
25
+ * *reading* — a missing file, one too large to send — is not its business.
26
+ */
27
+ export type Resolve = (root: string) => (url: string) => Result<string, Refusal>;
28
+ /**
29
+ * Answers one request by reading a file under `root`.
30
+ *
31
+ * Effectful but socket-free, which is what makes it provable against the
32
+ * virtual runner's file system: it takes a request frame and returns a response
33
+ * frame, and the sockets on either side belong to `main`.
34
+ *
35
+ * The channel is `never` for the reason {@link ServerResponse} exists — a
36
+ * server that cannot read a file still has a status code to answer with, so
37
+ * every failure becomes a response rather than an error.
38
+ */
39
+ export type Respond = (root: string) => (request: IncomingMessage) => Effect<Fs, ServerResponse, never>;
@@ -0,0 +1,5 @@
1
+ /** Generates the browser proof manifest without importing authored modules. */
2
+ export type _Module = {
3
+ readonly blockers: readonly string[];
4
+ readonly local: readonly URL[];
5
+ };
@@ -0,0 +1,115 @@
1
+ /** Generates the browser proof manifest without importing authored modules. */
2
+
3
+ import { readdir, readFile, writeFile } from 'node:fs/promises'
4
+ import { relative } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ import { run } from '../effects/node/module.mjs'
8
+ import { toPosix } from '../path/module.f.mjs'
9
+ import { exportsProof, local, specifiers } from './browser-source.mjs'
10
+ import { main } from './module.f.mjs'
11
+
12
+ const sourceRoot = new URL('../../', import.meta.url)
13
+ const output = new URL('../emergent_testing/_browser-suite.mjs', import.meta.url)
14
+
15
+ /** @type {(name: string) => boolean} */
16
+ const authored = name => name.endsWith('.f.mjs')
17
+
18
+ /** @type {(directory: URL) => Promise<readonly URL[]>} */
19
+ const files = async directory => {
20
+ const entries = await readdir(directory, { withFileTypes: true })
21
+ return (await Promise.all(entries.map(entry => {
22
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'target') { return [] }
23
+ const url = new URL(entry.isDirectory() ? `${entry.name}/` : entry.name, directory)
24
+ return entry.isDirectory() ? files(url) : authored(entry.name) ? [url] : []
25
+ }))).flat()
26
+ }
27
+
28
+ /** @typedef {{ readonly blockers: readonly string[], readonly local: readonly URL[] }} _Module */
29
+
30
+ /**
31
+ * Reads the modules reachable from `frontier` one level at a time, recording
32
+ * for each the bare and `node:` specifiers that would keep a browser from
33
+ * linking it. Traversal is by level and skips what the graph already holds, so
34
+ * an import cycle terminates.
35
+ *
36
+ * A relative specifier naming no file is dropped rather than treated as a
37
+ * blocker: the scan is textual, so a module that emits source of its own — the
38
+ * website generator embeds the page's entry module — offers up import lines
39
+ * that were never its own. A genuinely missing relative import cannot survive
40
+ * anyway, since the proof suite loads every one of these modules in Node.
41
+ *
42
+ * @type {(frontier: readonly URL[], graph: ReadonlyMap<string, _Module>) => Promise<ReadonlyMap<string, _Module>>}
43
+ */
44
+ const readGraph = async (frontier, graph) => {
45
+ const next = frontier.filter(url => !graph.has(url.href))
46
+ if (next.length === 0) { return graph }
47
+ const read = await Promise.all(next.map(async url => {
48
+ const found = await readFile(url, 'utf8').then(specifiers, () => [])
49
+ return /** @type {const} */ ([url.href, {
50
+ blockers: found.filter(specifier => !local(specifier)),
51
+ local: found.filter(local).map(specifier => new URL(specifier, url)),
52
+ }])
53
+ }))
54
+ return readGraph(
55
+ read.flatMap(([, module]) => [...module.local]),
56
+ new Map([...graph, ...read]))
57
+ }
58
+
59
+ /**
60
+ * The blockers reachable from `root`, deduplicated. Empty means the whole
61
+ * dependency graph is plain relative ES modules, which a browser can link.
62
+ *
63
+ * @type {(graph: ReadonlyMap<string, _Module>, root: URL) => readonly string[]}
64
+ */
65
+ const blockersOf = (graph, root) => {
66
+ /** @type {(frontier: readonly string[], visited: ReadonlySet<string>) => ReadonlySet<string>} */
67
+ const reach = (frontier, visited) => {
68
+ const next = frontier.filter(href => !visited.has(href))
69
+ if (next.length === 0) { return visited }
70
+ return reach(
71
+ next.flatMap(href => (graph.get(href)?.local ?? []).map(url => url.href)),
72
+ new Set([...visited, ...next]))
73
+ }
74
+ return [...new Set([...reach([root.href], new Set())].flatMap(href =>
75
+ [...graph.get(href)?.blockers ?? []]))]
76
+ }
77
+
78
+ /** @type {(url: URL) => string} */
79
+ const sitePath = url => toPosix(relative(fileURLToPath(sourceRoot), fileURLToPath(url)))
80
+
81
+ const candidates = await files(sourceRoot)
82
+ const withProof = (await Promise.all(candidates.map(async url =>
83
+ exportsProof(await readFile(url, 'utf8')) ? [url] : []
84
+ ))).flat().toSorted((a, b) => a.pathname.localeCompare(b.pathname))
85
+
86
+ // A proof module is valid FunctionalScript whether or not it can be linked in
87
+ // a browser: external packages and repository-owned `.mjs` are both allowed at
88
+ // runtime, and either may reach `node:` further down. Emitting an import for
89
+ // such a module would fail the page while linking the suite — before the
90
+ // runner can publish a report — so unsupported graphs are dropped here, with
91
+ // the reason printed, rather than breaking the page.
92
+ const graph = await readGraph(withProof, new Map())
93
+ const classified = withProof.map(url =>
94
+ /** @type {const} */ ([url, blockersOf(graph, url)]))
95
+ const selected = classified.flatMap(([url, blockers]) => blockers.length === 0 ? [url] : [])
96
+
97
+ for (const [url, blockers] of classified) {
98
+ if (blockers.length !== 0) {
99
+ console.log(`skipped ${sitePath(url)}: not linkable in a browser (${blockers.join(', ')})`)
100
+ }
101
+ }
102
+ console.log(`browser proof modules: ${selected.length} of ${classified.length}`)
103
+
104
+ const manifest = [
105
+ '/** Generated browser proof source map. Modules are loaded after the page renders. */',
106
+ '',
107
+ '/** @type {readonly string[]} */',
108
+ 'export const browserProofSources = [',
109
+ ...selected.map(url => ` './${sitePath(url)}',`),
110
+ ']',
111
+ '',
112
+ ].join('\n')
113
+
114
+ await writeFile(output, manifest)
115
+ await run(main)
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Static reading of authored source text: whether a module exports `proof`,
3
+ * and which modules a browser would have to link to load it.
4
+ *
5
+ * The manifest generator classifies modules without importing them — that is
6
+ * the point of reading them as text — and TypeScript 7 exposes no compiler API,
7
+ * so both questions are answered here by reading the source as tokens, with
8
+ * their own proofs, rather than by patterns over lines: whether a declaration
9
+ * fits on one line, and how it is spaced, are not part of the syntax.
10
+ */
11
+ export type _Token = {
12
+ readonly kind: 'name' | 'string' | 'punctuation';
13
+ readonly text: string;
14
+ };
15
+ /**
16
+ * Whether `source` exports a binding named `proof`. A mention inside a comment,
17
+ * a string, or a template is not one — the website generator embeds the page's
18
+ * entry module as source text — because none of them reaches the words below.
19
+ *
20
+ * @type {(source: string) => boolean}
21
+ */
22
+ export declare const exportsProof: (source: string) => boolean;
23
+ /**
24
+ * Every static module specifier in `source`: the string literal following the
25
+ * `from` of a declaration, or an `import` naming its module directly.
26
+ *
27
+ * A dynamic `import(...)` is left out, and left out structurally — its string
28
+ * follows a `(`, not the keyword. That is the reading the manifest wants: a
29
+ * dynamic import fails inside the test that reaches it rather than while the
30
+ * page links. A `from` written in prose or inside a string is left out for the
31
+ * same reason: neither is a sequence of tokens.
32
+ *
33
+ * @type {(source: string) => readonly string[]}
34
+ */
35
+ export declare const specifiers: (source: string) => readonly string[];
36
+ /** @type {(specifier: string) => boolean} */
37
+ export declare const local: (specifier: string) => boolean;