@noita-ts/wininet 0.0.0-stage → 0.1.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.
@@ -0,0 +1,382 @@
1
+ local ____lualib = require("lualib_bundle")
2
+ local __TS__ArrayForEach = ____lualib.__TS__ArrayForEach
3
+ local __TS__Class = ____lualib.__TS__Class
4
+ local Error = ____lualib.Error
5
+ local RangeError = ____lualib.RangeError
6
+ local ReferenceError = ____lualib.ReferenceError
7
+ local SyntaxError = ____lualib.SyntaxError
8
+ local TypeError = ____lualib.TypeError
9
+ local URIError = ____lualib.URIError
10
+ local __TS__New = ____lualib.__TS__New
11
+ local __TS__ArrayMap = ____lualib.__TS__ArrayMap
12
+ local ____exports = {}
13
+ local ____ffi = require("@noita-ts/ffi")
14
+ local ffi = ____ffi.default
15
+ local ____schema = require("@noita-ts/ffi/schema")
16
+ local c = ____schema.default
17
+ local MAX_ARGS = 8
18
+ ffi.cdef("\n void* CreateThread(void* attributes, size_t stack_size, void* start, void* param, uint32_t flags, uint32_t* id);\n int CloseHandle(void* handle);\n uint32_t GetLastError();\n void* GetProcessHeap();\n void* HeapAlloc(void* heap, uint32_t flags, size_t bytes);\n int HeapFree(void* heap, uint32_t flags, void* mem);\n")
19
+ local C = ffi.C
20
+ --- The thread runs the call.
21
+ local RUNNING = 0
22
+ --- The thread is done; Lua frees the memory of the call.
23
+ local DONE = 1
24
+ --- The pool is released; the thread frees the memory of the call.
25
+ local ABANDONED = 2
26
+ --- `entry` is the thread procedure, `DWORD WINAPI proc(call*)`: it pushes the
27
+ -- arguments, the last one first, calls `fn`, stores its result and the
28
+ -- `GetLastError` of the thread, and swaps `state` to DONE (`xchg` with memory
29
+ -- is atomic, and orders the stores before it). If the state was ABANDONED,
30
+ -- the pool is gone, so the thread frees the `owned` memory and the call itself.
31
+ -- `esp` comes back from `ebp`, so that `fn` can be stdcall or cdecl.
32
+ --
33
+ -- `swap_state` is `uint32_t __cdecl swap_state(call*, uint32_t state)`: it
34
+ -- swaps the state of a call atomically and returns the old one, for the Lua
35
+ -- side, which has no atomic operations.
36
+ local CODE = setmetatable(
37
+ {raw = {
38
+ 85,
39
+ 137,
40
+ 229,
41
+ 83,
42
+ 86,
43
+ 139,
44
+ 93,
45
+ 8,
46
+ 139,
47
+ 75,
48
+ 4,
49
+ 133,
50
+ 201,
51
+ 116,
52
+ 7,
53
+ 255,
54
+ 116,
55
+ 139,
56
+ 4,
57
+ 73,
58
+ 117,
59
+ 249,
60
+ 255,
61
+ 19,
62
+ 137,
63
+ 67,
64
+ 40,
65
+ 255,
66
+ 83,
67
+ 52,
68
+ 137,
69
+ 67,
70
+ 44,
71
+ 141,
72
+ 101,
73
+ 248,
74
+ 184,
75
+ 1,
76
+ 0,
77
+ 0,
78
+ 0,
79
+ 135,
80
+ 67,
81
+ 48,
82
+ 131,
83
+ 248,
84
+ 2,
85
+ 117,
86
+ 31,
87
+ 139,
88
+ 115,
89
+ 64,
90
+ 133,
91
+ 246,
92
+ 116,
93
+ 15,
94
+ 78,
95
+ 255,
96
+ 116,
97
+ 179,
98
+ 68,
99
+ 106,
100
+ 0,
101
+ 255,
102
+ 115,
103
+ 60,
104
+ 255,
105
+ 83,
106
+ 56,
107
+ 235,
108
+ 237,
109
+ 83,
110
+ 106,
111
+ 0,
112
+ 255,
113
+ 115,
114
+ 60,
115
+ 255,
116
+ 83,
117
+ 56,
118
+ 94,
119
+ 91,
120
+ 49,
121
+ 192,
122
+ 93,
123
+ 194,
124
+ 4,
125
+ 0,
126
+ 139,
127
+ 76,
128
+ 36,
129
+ 4,
130
+ 139,
131
+ 68,
132
+ 36,
133
+ 8,
134
+ 135,
135
+ 65,
136
+ 48,
137
+ 195
138
+ }, vars = {}, labels = {entry = 0, swap_state = 88}},
139
+ {__call = require("asm_link").link}
140
+ )
141
+ --- A 32-bit FNV-1a hash of `bytes`, as an unsigned decimal number.
142
+ local function hash(bytes)
143
+ local h = bit.bor(2166136261, 0)
144
+ for ____, b in ipairs(bytes) do
145
+ h = bit.bxor(h, b)
146
+ h = bit.bor(
147
+ h * 403 + bit.lshift(h, 24),
148
+ 0
149
+ )
150
+ end
151
+ return tostring(h < 0 and h + 4294967296 or h)
152
+ end
153
+ --- A call on a thread. The thread procedure has the offsets of its fields.
154
+ local Call = c.declare(
155
+ "NoitaTsWininetCall",
156
+ {
157
+ c.field("fn", c.voidptr),
158
+ c.field("argc", c.u32),
159
+ c.field(
160
+ "args",
161
+ c.u32:arr(MAX_ARGS)
162
+ ),
163
+ c.field("result", c.u32),
164
+ c.field("error", c.u32),
165
+ c.field(
166
+ "state",
167
+ c.escape("volatile uint32_t", 4, 4)
168
+ ),
169
+ c.field("get_last_error", c.voidptr),
170
+ c.field("heap_free", c.voidptr),
171
+ c.field("heap", c.voidptr),
172
+ c.field("owned_count", c.u32),
173
+ c.field(
174
+ "owned",
175
+ c.voidptr:arr(MAX_ARGS)
176
+ )
177
+ }
178
+ )
179
+ local code
180
+ --- The address of the code. The name in the store comes from the bytes of the
181
+ -- code, which also fix the layout of the call, so another version of this
182
+ -- package that changes either one makes its own.
183
+ local function getCode()
184
+ if code == nil then
185
+ code = ffi.store.shared(
186
+ "wininet.proc." .. hash(CODE.raw),
187
+ function()
188
+ local mem = ffi.allocExec(#CODE.raw, {persistent = true})
189
+ __TS__ArrayForEach(
190
+ CODE.raw,
191
+ function(____, b, i)
192
+ mem[i] = b
193
+ end
194
+ )
195
+ return tonumber(ffi.cast("uint32_t", mem))
196
+ end
197
+ )
198
+ end
199
+ return code
200
+ end
201
+ local swapState
202
+ --- Swaps the state of `call` atomically, and returns the old one.
203
+ local function swap(call, state)
204
+ swapState = swapState or ffi.cast(
205
+ "uint32_t (__cdecl *)(void*, uint32_t)",
206
+ getCode() + CODE.labels.swap_state
207
+ )
208
+ local fn = swapState
209
+ return fn(
210
+ ffi.cast("void*", call),
211
+ state
212
+ )
213
+ end
214
+ local getLastError = ffi.cast("void*", C.GetLastError)
215
+ local heapFree = ffi.cast("void*", C.HeapFree)
216
+ local processHeap = C.GetProcessHeap()
217
+ local HEAP_ZERO_MEMORY = 8
218
+ --- The address of a pointer, as a key of a table.
219
+ local function address(ptr)
220
+ return tonumber(ffi.cast("uintptr_t", ptr))
221
+ end
222
+ --- Memory on the process heap and the calls on threads that use it, released
223
+ -- together.
224
+ ____exports.Pool = __TS__Class()
225
+ local Pool = ____exports.Pool
226
+ Pool.name = "Pool"
227
+ function Pool.prototype.____constructor(self)
228
+ self.blocks = {}
229
+ self.calls = {}
230
+ self.released = false
231
+ end
232
+ function Pool.prototype.alloc(self, ctype, size)
233
+ if self.released then
234
+ error(
235
+ __TS__New(Error, "the pool is released"),
236
+ 0
237
+ )
238
+ end
239
+ local mem = C.HeapAlloc(processHeap, HEAP_ZERO_MEMORY, size)
240
+ if mem == nil then
241
+ error(
242
+ __TS__New(
243
+ Error,
244
+ ("cannot allocate " .. tostring(size)) .. " bytes"
245
+ ),
246
+ 0
247
+ )
248
+ end
249
+ self.blocks[address(mem)] = mem
250
+ return ffi.cast(ctype, mem)
251
+ end
252
+ function Pool.prototype.free(self, mem)
253
+ local key = address(mem)
254
+ local block = self.blocks[key]
255
+ if block ~= nil then
256
+ self.blocks[key] = nil
257
+ C.HeapFree(processHeap, 0, block)
258
+ end
259
+ end
260
+ function Pool.prototype.run(self, fn, ...)
261
+ local args = {...}
262
+ if #args > MAX_ARGS then
263
+ error(
264
+ __TS__New(
265
+ Error,
266
+ ("a call on a thread takes at most " .. tostring(MAX_ARGS)) .. " arguments"
267
+ ),
268
+ 0
269
+ )
270
+ end
271
+ local call = self:alloc(
272
+ Call:ptr().name,
273
+ Call.size
274
+ )
275
+ call.fn = ffi.cast("void*", fn)
276
+ call.argc = #args
277
+ call.get_last_error = getLastError
278
+ call.heap_free = heapFree
279
+ call.heap = processHeap
280
+ local copies = {}
281
+ local owned = {}
282
+ __TS__ArrayForEach(
283
+ args,
284
+ function(____, arg, i)
285
+ if type(arg) == "number" then
286
+ call.args[i] = arg
287
+ elseif type(arg) == "string" then
288
+ local copy = self:alloc("char*", #arg + 1)
289
+ ffi.copy(copy, arg)
290
+ copies[#copies + 1] = copy
291
+ owned[#owned + 1] = copy
292
+ call.args[i] = ffi.cast("uintptr_t", copy)
293
+ else
294
+ call.args[i] = ffi.cast("uintptr_t", arg)
295
+ if self.blocks[address(arg)] ~= nil then
296
+ owned[#owned + 1] = arg
297
+ end
298
+ end
299
+ end
300
+ )
301
+ __TS__ArrayForEach(
302
+ owned,
303
+ function(____, mem, i)
304
+ call.owned[i] = mem
305
+ end
306
+ )
307
+ call.owned_count = #owned
308
+ local pending = {
309
+ abandoned = false,
310
+ owned = __TS__ArrayMap(
311
+ owned,
312
+ function(____, mem) return address(mem) end
313
+ )
314
+ }
315
+ local function done()
316
+ for ____, copy in ipairs(copies) do
317
+ self:free(copy)
318
+ end
319
+ self:free(call)
320
+ end
321
+ local thread = C.CreateThread(
322
+ nil,
323
+ 0,
324
+ ffi.cast(
325
+ "void*",
326
+ getCode() + CODE.labels.entry
327
+ ),
328
+ ffi.cast("void*", call),
329
+ 0,
330
+ nil
331
+ )
332
+ if thread == nil then
333
+ local ____error = C.GetLastError()
334
+ done()
335
+ error(
336
+ __TS__New(
337
+ Error,
338
+ "cannot create a thread: error " .. tostring(____error)
339
+ ),
340
+ 0
341
+ )
342
+ end
343
+ C.CloseHandle(thread)
344
+ self.calls[call] = pending
345
+ while true do
346
+ if pending.abandoned then
347
+ error(
348
+ __TS__New(Error, "the call was cancelled"),
349
+ 0
350
+ )
351
+ end
352
+ if call.state ~= RUNNING then
353
+ break
354
+ end
355
+ coroutine.yield()
356
+ end
357
+ self.calls[call] = nil
358
+ local result = {call.result, call.error}
359
+ done()
360
+ return result
361
+ end
362
+ function Pool.prototype.release(self)
363
+ if self.released then
364
+ return
365
+ end
366
+ self.released = true
367
+ for call, pending in pairs(self.calls) do
368
+ pending.abandoned = true
369
+ if swap(call, ABANDONED) == RUNNING then
370
+ self.blocks[address(call)] = nil
371
+ for ____, mem in ipairs(pending.owned) do
372
+ self.blocks[mem] = nil
373
+ end
374
+ end
375
+ end
376
+ self.calls = {}
377
+ for ____, block in pairs(self.blocks) do
378
+ C.HeapFree(processHeap, 0, block)
379
+ end
380
+ self.blocks = {}
381
+ end
382
+ return ____exports
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@noita-ts/wininet",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "An HTTP client for unsafe Noita mods that uses WinINet on native threads, so the game does not stop.",
5
+ "author": "Anton Bulakh <him@necauq.ua> (https://necauq.ua/)",
6
+ "license": "MIT",
7
+ "files": [
8
+ "dist/**/*.{lua,d.ts}"
9
+ ],
10
+ "type": "module",
11
+ "main": "dist/index",
12
+ "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "tstl -p .",
21
+ "prepublishOnly": "rm -rf dist && npm run build"
22
+ },
23
+ "dependencies": {
24
+ "@noita-ts/ffi": "^0.7.0",
25
+ "@typescript-to-lua/language-extensions": "^1.19.0",
26
+ "lua-types": "^2.13.1"
27
+ },
28
+ "devDependencies": {
29
+ "@noita-ts/nasm": "^0.1.0",
30
+ "typescript": "^6.0.2",
31
+ "typescript-to-lua": "^1.36.0"
32
+ }
33
+ }
package/readme.md ADDED
@@ -0,0 +1,66 @@
1
+ # @noita-ts/wininet
2
+
3
+ An HTTP client for unsafe [noita-ts](https://github.com/necauqua/noita-ts) mods
4
+ that uses WinINet through the LuaJIT FFI. WinINet is part of Windows (and of
5
+ Wine), so the package ships no DLL. The blocking WinINet calls run on native
6
+ threads, so the game does not stop while a request waits.
7
+
8
+ ## Why
9
+
10
+ A mod that only makes HTTP requests does not have to ship the pollnet DLL of
11
+ `@noita-ts/pollnet` (3.5 MB) any more. This package is only Lua, and WinINet is
12
+ already on every Windows (and in Wine). Use pollnet when you need its other
13
+ parts, such as WebSockets, TCP or servers.
14
+
15
+ There is also a bug that WinINet does not have: in Wine (Proton), the HTTPS of
16
+ pollnet (rust `schannel` on the `schannel` of Wine) stops in a response that
17
+ has more than one TLS record (24 KB is enough). It waits until the server
18
+ closes the connection (65 s for nginx), and then it often fails. With WinINet,
19
+ a 1.6 MB download takes about 1 s, with the game running.
20
+
21
+ ## Usage
22
+
23
+ The API is the same as `HttpClient` of `@noita-ts/pollnet`, so a mod can change
24
+ only the import:
25
+
26
+ ```ts
27
+ import Scheduler from "@noita-ts/base/async";
28
+ import { HttpClient } from "@noita-ts/wininet";
29
+
30
+ const http = new HttpClient(Scheduler.get());
31
+
32
+ const response = await http.get("https://example.com/", { Accept: "text/html" });
33
+ print(response.status, response.parseHeaders()["Content-Type"]);
34
+
35
+ await http.post("https://example.com/api", { "Content-Type": "application/json" }, "{}");
36
+ ```
37
+
38
+ - `new HttpClient(scheduler, options?)`: `scheduler` is anything with
39
+ `spawn` (a `Scheduler` of `@noita-ts/base/async`). The options are
40
+ `userAgent` (default `noita-ts/wininet`) and `timeoutMs`, the timeout of a
41
+ connection, of a send and of a receive (default 30 s). A client keeps a
42
+ WinINet session until the Lua state is closed, so make one and keep it.
43
+ - `get(url, headers?)` and `post(url, headers?, body?)` resolve to an
44
+ `HttpResponse` with `status`, `headers` (`Key: value` lines separated by
45
+ `\n`), `body` and `parseHeaders()`. Headers are a map (an array value
46
+ sends the header more than once) or a string of `\n`-separated lines. Without
47
+ headers, a POST sends `content-type: application/x-www-form-urlencoded`, as
48
+ in pollnet.
49
+ - An error of WinINet rejects the promise with an `Error` that has the method,
50
+ the URL and the WinINet error, for example
51
+ `GET http://127.0.0.1:1/: cannot send the request: WinINet error 12029 (cannot connect)`.
52
+ - Redirects are followed, also between http and https.
53
+
54
+ ## Limits
55
+
56
+ - Only `http` and `https` URLs with a host name or an IPv4 address. Use
57
+ `127.0.0.1`, not `localhost`: WinINet can take `localhost` as `::1`.
58
+ - The whole body is kept in memory, as one string.
59
+ - Each blocking call starts a new native thread; the Lua side looks at the
60
+ result once per frame, so every request takes at least a few frames.
61
+ - When the Lua state is closed (for example a new game starts), every client
62
+ closes its WinINet handles and frees its memory. A request that waits at
63
+ that moment cannot be stopped in Wine: its thread runs until the call
64
+ returns or times out, and then frees the memory of the call itself. Windows
65
+ cancels such a call when its handle is closed.
66
+ - Wine gives a timeout as WinINet error 12030 (connection aborted), not 12002.
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.