@tilua-types/roblox 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,31 @@ tree, down to `script.Parent`.
43
43
  Deprecated members are left out. Deprecated globals such as `wait` and
44
44
  `spawn` stay.
45
45
 
46
+ ## Errors in callbacks
47
+
48
+ A bundle's entry runs under `xpcall`, so an uncaught error names the project's
49
+ files, not lines of the bundle. A callback that Roblox runs later on its own
50
+ thread is outside that `xpcall`. This package's lowering (`lowering.mjs`) wraps
51
+ those callbacks in the same handler:
52
+
53
+ - `signal:Connect(f)`, `signal:Once(f)`, `signal:ConnectParallel(f)`
54
+ - `task.spawn`, `task.defer`, `task.delay`, including `const spawn = task.spawn`
55
+ - `RunService:BindToRenderStep`, `game:BindToClose`,
56
+ `ContextActionService:BindAction` and `BindActionAtPriority`
57
+
58
+ ```
59
+ main.tilua:5: attempt to index nil with 'Name'
60
+ at main.tilua:5 (touched)
61
+ at main.tilua:7
62
+ ```
63
+
64
+ The error is still raised on the callback's thread, so the callback stops the
65
+ same way it did before. A callback that runs synchronously (a `table.sort`
66
+ comparator, `names:map(f)`) is not wrapped, because it is already inside its
67
+ caller's `xpcall`. `coroutine` is not wrapped either: `coroutine.resume`
68
+ returning `false, err` is part of how it behaves. Outside a bundle, nothing is
69
+ wrapped.
70
+
46
71
  ## Where it comes from
47
72
 
48
73
  `index.d.tilua` is generated. Don't edit it by hand; change
package/lowering.mjs ADDED
@@ -0,0 +1,84 @@
1
+ // @ts-check
2
+ /**
3
+ * What tilua lowers for Roblox: callbacks that Roblox runs later, on a thread
4
+ * of their own.
5
+ *
6
+ * The entry of a bundle runs under `xpcall`, which is what reports an error
7
+ * in the project's files instead of in the bundle's lines. A callback given
8
+ * to `task.spawn` or to `signal:Connect` is not under it when it runs, so its
9
+ * callback is wrapped in the same handler first (see runtime/roblox.luau):
10
+ *
11
+ * part.Touched:Connect(onTouched) -> tilua_roblox.Connect(part.Touched, onTouched)
12
+ * task.spawn(work, 1) -> tilua_roblox.task.spawn(work, 1)
13
+ *
14
+ * Only where Roblox starts a thread. A callback run synchronously — a
15
+ * `table.sort` comparator, `names:map(f)` — has its caller's `xpcall` already,
16
+ * and `coroutine` is left alone: `coroutine.resume` answering `false, err` is
17
+ * part of what it promises.
18
+ *
19
+ * `print`, `error` and the console come from `@tilua-types/lua`, which this
20
+ * package depends on.
21
+ */
22
+ import { readFileSync } from "node:fs"
23
+ import { dirname, join } from "node:path"
24
+ import { fileURLToPath } from "node:url"
25
+
26
+ /** @typedef {import("@tilua/parser").LoweringPlugin} LoweringPlugin */
27
+ /** @typedef {import("@tilua/parser").Type} Type */
28
+
29
+ const here = dirname(fileURLToPath(import.meta.url))
30
+ const roblox = readFileSync(join(here, "runtime", "roblox.luau"), "utf8")
31
+
32
+ const SIGNAL_METHODS = new Set(["Connect", "Once", "ConnectParallel"])
33
+
34
+ /** A service's methods that keep a callback, by the class that has them. */
35
+ const SERVICE_METHODS = new Map([
36
+ ["BindToRenderStep", "RunService"],
37
+ ["BindToClose", "DataModel"],
38
+ ["BindAction", "ContextActionService"],
39
+ ["BindActionAtPriority", "ContextActionService"],
40
+ ])
41
+
42
+ /** The members of a type that may be nil: a check the analyzer asked for
43
+ * elsewhere, not a reason to lower differently here.
44
+ * @type {(type: Type | undefined) => Type[]} */
45
+ function parts(type) {
46
+ if (!type) return []
47
+ if (type.kind !== "union") return [type]
48
+ return type.types.filter(m => !(m.kind === "primitive" && m.name === "nil"))
49
+ }
50
+
51
+ /** `RBXScriptSignal<T...>` is structural once it is resolved: an object that
52
+ * can be connected to and waited on.
53
+ * @type {(type: Type | undefined) => boolean} */
54
+ function isSignal(type) {
55
+ const members = parts(type)
56
+ return members.length > 0 && members.every(m =>
57
+ m.kind === "object" && m.properties.has("Connect") && m.properties.has("Once") && m.properties.has("Wait"))
58
+ }
59
+
60
+ /** @type {(type: Type | undefined, className: string) => boolean} */
61
+ function isInstanceOf(type, className) {
62
+ const members = parts(type)
63
+ return members.length > 0 && members.every(m =>
64
+ m.kind === "object" && m.class !== undefined && m.class.ancestors.includes(className))
65
+ }
66
+
67
+ /** @type {LoweringPlugin} */
68
+ const plugin = {
69
+ runtime: { roblox },
70
+
71
+ methodCall({ method, receiver, use }) {
72
+ if (SIGNAL_METHODS.has(method) && isSignal(receiver)) return { callee: `${use("roblox")}.${method}` }
73
+ const service = SERVICE_METHODS.get(method)
74
+ if (service !== undefined && isInstanceOf(receiver, service)) return { callee: `${use("roblox")}.${method}` }
75
+ return undefined
76
+ },
77
+
78
+ globalValue({ name, use }) {
79
+ if (name === "task") return `${use("roblox")}.task`
80
+ return undefined
81
+ },
82
+ }
83
+
84
+ export default plugin
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@tilua-types/roblox",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Luau and the whole Roblox API as tilua types: every class, enum, data type, service and global",
5
5
  "license": "ISC",
6
6
  "files": [
7
7
  "index.d.tilua",
8
+ "lowering.mjs",
9
+ "runtime",
8
10
  "README.md",
9
11
  "THIRD_PARTY_NOTICES.md"
10
12
  ],
11
13
  "tilua": {
12
- "types": "index.d.tilua"
14
+ "types": "index.d.tilua",
15
+ "lowering": "lowering.mjs"
13
16
  },
14
17
  "dependencies": {
15
- "@tilua-types/lua": "^1.0.0"
18
+ "@tilua-types/lua": "^2.0.0"
16
19
  },
17
20
  "keywords": [
18
21
  "tilua",
@@ -0,0 +1,77 @@
1
+ -- Callbacks Roblox runs on a thread of their own, reported in the project's
2
+ -- files.
3
+ --
4
+ -- A bundle's entry runs under `xpcall`, so an error nothing caught names the
5
+ -- file and line the author wrote. A callback handed to `task.spawn` or to
6
+ -- `signal:Connect` runs later, on a new thread, where that `xpcall` is not on
7
+ -- the stack: its errors would name lines of the bundle. `guard` gives such a
8
+ -- callback the same handler — `__FAIL__`, the bundle's own — and raises what
9
+ -- it returns on the callback's thread, where Roblox reports it as before.
10
+ --
11
+ -- The error is raised again, never swallowed: code that relied on it
12
+ -- stopping the callback still sees it stop. Outside a bundle there is nothing
13
+ -- to map, and callbacks are passed through untouched.
14
+
15
+ local __NAME__ = {}
16
+ local fail = __FAIL__
17
+
18
+ local function guard(callback)
19
+ if type(callback) ~= "function" or fail == nil then
20
+ return callback
21
+ end
22
+ return function(...)
23
+ local results = table.pack(xpcall(callback, fail, ...))
24
+ if not results[1] then
25
+ error(results[2], 0)
26
+ end
27
+ return table.unpack(results, 2, results.n)
28
+ end
29
+ end
30
+
31
+ __NAME__.guard = guard
32
+
33
+ -- `signal:Connect(f)` and its siblings.
34
+ function __NAME__.Connect(signal, callback)
35
+ return signal:Connect(guard(callback))
36
+ end
37
+
38
+ function __NAME__.Once(signal, callback)
39
+ return signal:Once(guard(callback))
40
+ end
41
+
42
+ function __NAME__.ConnectParallel(signal, callback)
43
+ return signal:ConnectParallel(guard(callback))
44
+ end
45
+
46
+ -- Services that keep a callback and run it later.
47
+ function __NAME__.BindToRenderStep(service, name, priority, callback)
48
+ return service:BindToRenderStep(name, priority, guard(callback))
49
+ end
50
+
51
+ function __NAME__.BindToClose(game, callback)
52
+ return game:BindToClose(guard(callback))
53
+ end
54
+
55
+ function __NAME__.BindAction(service, name, callback, ...)
56
+ return service:BindAction(name, guard(callback), ...)
57
+ end
58
+
59
+ function __NAME__.BindActionAtPriority(service, name, callback, ...)
60
+ return service:BindActionAtPriority(name, guard(callback), ...)
61
+ end
62
+
63
+ -- `task`, with the functions that start a thread guarding what they start.
64
+ -- A thread passed instead of a function is left as it is. Everything else is
65
+ -- `task`'s own. Standing in for the global, rather than rewriting calls,
66
+ -- covers `local spawn = task.spawn` too.
67
+ __NAME__.task = setmetatable({
68
+ spawn = function(target, ...)
69
+ return task.spawn(guard(target), ...)
70
+ end,
71
+ defer = function(target, ...)
72
+ return task.defer(guard(target), ...)
73
+ end,
74
+ delay = function(duration, target, ...)
75
+ return task.delay(duration, guard(target), ...)
76
+ end,
77
+ }, { __index = task })