@tilua-types/bridge 1.0.0 → 2.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.
package/lowering.mjs CHANGED
@@ -1,187 +1,228 @@
1
- // @ts-check
2
- /**
3
- * What tilua lowers for `bridge`, and the module it writes.
4
- *
5
- * `bridge.send("Buy", item)` is a call of a handle the file holds in a local:
6
- * `__tilua_bridge.send("Buy", item)`. That local is the first line of every
7
- * file that calls `bridge`, put there as the host's prelude:
8
- *
9
- * local __tilua_bridge = require(<the bridge module>)("<place>", "<token>")
10
- *
11
- * where the token is the HMAC of the file's place under the build's secret. It
12
- * is a local of the file, so neither the module's exports nor the runtime's
13
- * table (`G`, which anything can `require`) ever hold it. The bridge module
14
- * hands a handle to whoever offers a place's token, as often as it is offered:
15
- * the game can clone any script, and it runs again where it lands.
16
- *
17
- * Each build has a new secret, so its tokens and its module go with it: what was
18
- * read from one update is no use on the next. A watching process keeps one, or
19
- * every file would change on every save.
20
- *
21
- * `bridge` read as a value — kept in a variable, exported, passed along — would
22
- * carry the handle out of the file, so it does not build. Nor does calling a
23
- * member a `.client` or `.server` file cannot run.
24
- *
25
- * The layout is `@tilua-types/roblox`'s, with the claim added to its prelude.
26
- */
27
- import { createHmac, randomBytes } from "node:crypto"
28
- import { dirname, join, relative, resolve, sep } from "node:path"
29
- import { luauString } from "@tilua/parser"
30
- import roblox from "@tilua-types/roblox/lowering.mjs"
31
- import { bridgeSource, obfuscateBridge } from "./generate.mjs"
32
-
33
- /** @typedef {import("@tilua/parser").LoweringPlugin} LoweringPlugin */
34
-
35
- const HANDLE = "__tilua_bridge"
36
- const CLIENT_MEMBERS = new Set(["send", "onClient"])
37
- const SERVER_MEMBERS = new Set(["onServer", "sendTo", "sendAll", "onBreach"])
38
- /** Members taking a handler, which runs on a thread of the module's. */
39
- const HANDLERS = new Set(["onClient", "onServer", "onBreach"])
40
- export const MODULE_NAME = "_bridge.luau"
41
-
42
- /** A handler runs on a thread the module starts, away from the `xpcall` that
43
- * reports errors in the project's files: the same guard roblox's `task.spawn`
44
- * gives a callback. */
45
- const GUARD = `local __NAME__ = {}
46
- local fail = __FAIL__
47
-
48
- function __NAME__.guard(callback)
49
- if fail == nil then
50
- return callback
51
- end
52
- return function(...)
53
- local results = table.pack(xpcall(callback, fail, ...))
54
- if not results[1] then
55
- error(results[2], 0)
56
- end
57
- return table.unpack(results, 2, results.n)
58
- end
59
- end
60
- `
61
-
62
- /** The files that called `bridge` in this build, as the call sites name them. */
63
- const used = new Set()
64
-
65
- /** A secret and its module for each project this process builds, so a watching
66
- * build writes the same files until something changes them. */
67
- const projects = new Map()
68
-
69
- /** @param {string} message */
70
- const raise = message => `error(${luauString(message)}, 0)`
71
-
72
- const REFUSED = "bridge: the module refused this file's claim: it is from another build — see the warnings above"
73
-
74
- /** @param {string | undefined} file */
75
- const sideOf = file => (file?.endsWith(".client.tilua") ? "client" : file?.endsWith(".server.tilua") ? "server" : undefined)
76
-
77
- /** @param {string} directory */
78
- function projectOf(directory) {
79
- let project = projects.get(directory)
80
- if (!project) {
81
- project = { secret: randomBytes(32).toString("hex"), modules: new Map() }
82
- projects.set(directory, project)
83
- }
84
- return project
85
- }
86
-
87
- /** @type {LoweringPlugin} */
88
- const plugin = {
89
- runtime: { guard: GUARD },
90
-
91
- globalCall({ name, at, perFile, report, use }) {
92
- if (name === "bridge") {
93
- report("bridge is not a function: call one of its members, as bridge.send(...)")
94
- return { callee: "error" }
95
- }
96
- if (!name.startsWith("bridge.")) return undefined
97
- const member = name.slice("bridge.".length)
98
- // A member that is not one is the type checker's to say
99
- if (!CLIENT_MEMBERS.has(member) && !SERVER_MEMBERS.has(member)) return { callee: `(function() ${raise(`bridge has no member '${member}'`)} end)` }
100
- if (!perFile) report(`bridge.${member}: the bridge needs "output": "files", where each file claims its own handle`)
101
- const side = sideOf(at.file)
102
- if (side === "client" && SERVER_MEMBERS.has(member)) report(`bridge.${member} runs on the server, and this is a client script`)
103
- if (side === "server" && CLIENT_MEMBERS.has(member)) report(`bridge.${member} runs on the client, and this is a server script`)
104
- if (at.file !== undefined) used.add(at.file)
105
- const handle = `(${HANDLE} or ${raise(REFUSED)})`
106
- if (!HANDLERS.has(member)) return { callee: `${handle}.${member}` }
107
- const guard = `${use("guard")}.guard`
108
- return {
109
- callee: member === "onBreach"
110
- ? `(function(handler) ${handle}.${member}(${guard}(handler)) end)`
111
- : `(function(name, handler) ${handle}.${member}(name, ${guard}(handler)) end)`,
112
- }
113
- },
114
-
115
- globalValue({ name, report }) {
116
- if (name === "bridge" || name.startsWith("bridge.")) {
117
- report("bridge can only be called — bridge.send(...) — never kept, passed or exported")
118
- return "nil"
119
- }
120
- return undefined
121
- },
122
-
123
- files(layout) {
124
- // A new build: what calls `bridge` is gathered again as it lowers
125
- used.clear()
126
- const base = roblox.files?.(layout) ?? {}
127
- const problems = [...(base.problems ?? [])]
128
- const project = projectOf(layout.directory)
129
-
130
- // The module sits beside the runtime, where every script reaches it
131
- const modulePath = join(dirname(base.runtime ?? layout.runtime), MODULE_NAME)
132
- const place = base.place?.(modulePath)
133
- if (!place) problems.push({ message: `Nothing in the Rojo project puts ${MODULE_NAME} in the game` })
134
- const locate = place && [
135
- `game:GetService(${luauString(place[0])})`,
136
- ...place.slice(1).map(part => `:WaitForChild(${luauString(part)})`),
137
- ].join("")
138
-
139
- /** @param {string} file */
140
- const callsBridge = file => {
141
- const written = relative(layout.outDir, file).split(sep).join("/")
142
- for (const source of used) {
143
- const output = relative(layout.rootDir, resolve(layout.directory, source)).split(sep).join("/").replace(/\.tilua$/, ".luau")
144
- if (output === written) return true
145
- }
146
- return false
147
- }
148
-
149
- return {
150
- ...base,
151
- problems,
152
- prelude(file) {
153
- const before = base.prelude?.(file)
154
- if (!locate || !callsBridge(file)) return before
155
- const inGame = base.place?.(file) ?? [relative(layout.outDir, file)]
156
- // A module and a script of one name share a place in the game
157
- const kind = /\.(server|client|local|legacy|plugin)\.luau?$/.exec(file)?.[1]
158
- const where = `${inGame.join("/")}${kind ? `.${kind}` : ""}`
159
- const token = createHmac("sha256", project.secret).update(where).digest("hex")
160
- const claim = `local ${HANDLE} = require(${locate})(${luauString(where)}, ${luauString(token)})`
161
- return before === undefined ? claim : `${before}\n${claim}`
162
- },
163
- async output() {
164
- if (!place || !used.size) return { files: [] }
165
- const { bridgeMaxPacket: maxPacket, bridgeRateLimit: rateLimit, bridgeObfuscate } = layout.options
166
- const key = JSON.stringify([maxPacket, rateLimit, bridgeObfuscate])
167
- if (!project.modules.has(key)) {
168
- const source = bridgeSource({
169
- secret: project.secret,
170
- maxPacket: typeof maxPacket === "number" ? maxPacket : undefined,
171
- rateLimit: typeof rateLimit === "number" ? rateLimit : undefined,
172
- })
173
- project.modules.set(key, bridgeObfuscate === false ? source : await obfuscateBridge(source))
174
- }
175
- const clientOnly = [...used].every(file => sideOf(file) === "client")
176
- return {
177
- files: [{ file: modulePath, code: project.modules.get(key) }],
178
- problems: clientOnly
179
- ? [{ severity: /** @type {const} */ ("warning"), message: "Only client scripts call bridge: nothing starts it on the server, so the clients will wait for it" }]
180
- : [],
181
- }
182
- },
183
- }
184
- },
185
- }
186
-
187
- export default plugin
1
+ // @ts-check
2
+ /**
3
+ * What tilua lowers for `bridge`, and the module it writes.
4
+ *
5
+ * `bridge.send("Buy", item)` is a call of a handle the file holds in a local:
6
+ * `__tilua_bridge.send("Buy", item)`. That local is the first line of every
7
+ * file that calls `bridge`, put there as the host's prelude:
8
+ *
9
+ * local __tilua_bridge = require(<the bridge module>)("<place>", "<token>")
10
+ *
11
+ * where the token is the HMAC of the file's place under the build's secret. It
12
+ * is a local of the file, so neither the module's exports nor the runtime's
13
+ * table (`G`, which anything can `require`) ever hold it. The bridge module
14
+ * hands a handle to whoever offers a place's token, as often as it is offered:
15
+ * the game can clone any script, and it runs again where it lands.
16
+ *
17
+ * Each build has a new secret, so its tokens and its module go with it: what was
18
+ * read from one update is no use on the next. A watching process keeps one, or
19
+ * every file would change on every save.
20
+ *
21
+ * `bridge` read as a value — kept in a variable, exported, passed along — would
22
+ * carry the handle out of the file, so it does not build. Nor does calling a
23
+ * member a `.client` or `.server` file cannot run.
24
+ *
25
+ * The layout is `@tilua-types/roblox`'s, with the claim added to its prelude.
26
+ */
27
+ import { createHmac, randomBytes } from "node:crypto"
28
+ import { dirname, join, relative, resolve, sep } from "node:path"
29
+ import { luauString } from "@tilua/parser"
30
+ import roblox from "@tilua-types/roblox/lowering.mjs"
31
+ import { bridgeSource, obfuscateBridge, obfuscateClaim } from "./generate.mjs"
32
+
33
+ /** @typedef {import("@tilua/parser").LoweringPlugin} LoweringPlugin */
34
+
35
+ const HANDLE = "__tilua_bridge"
36
+ const CLIENT_MEMBERS = new Set(["send", "invoke", "onClient"])
37
+ const SERVER_MEMBERS = new Set(["onServer", "onInvoke", "sendTo", "sendAll", "onBreach"])
38
+ /** Members taking a handler, which runs on a thread of the module's. */
39
+ const HANDLERS = new Set(["onClient", "onServer", "onInvoke", "onBreach"])
40
+ export const MODULE_NAME = "_bridge.luau"
41
+
42
+ /** A handler runs on a thread the module starts, away from the `xpcall` that
43
+ * reports errors in the project's files: the same guard roblox's `task.spawn`
44
+ * gives a callback. */
45
+ const GUARD = `local __NAME__ = {}
46
+ local fail = __FAIL__
47
+
48
+ function __NAME__.guard(callback)
49
+ if fail == nil then
50
+ return callback
51
+ end
52
+ return function(...)
53
+ local results = table.pack(xpcall(callback, fail, ...))
54
+ if not results[1] then
55
+ error(results[2], 0)
56
+ end
57
+ return table.unpack(results, 2, results.n)
58
+ end
59
+ end
60
+ `
61
+
62
+ /** The files that called `bridge` in this build, as the call sites name them. */
63
+ const used = new Set()
64
+
65
+ /** A secret, its module and its files' claims for each project this process
66
+ * builds, so a watching build writes the same files until something changes
67
+ * them: obfuscating anew would change every one. */
68
+ const projects = new Map()
69
+
70
+ /** @param {string} message */
71
+ const raise = message => `error(${luauString(message)}, 0)`
72
+
73
+ const REFUSED = "bridge: the module refused this file's claim: it is from another build — see the warnings above"
74
+
75
+ /** @param {string | undefined} file */
76
+ const sideOf = file => (file?.endsWith(".client.tilua") ? "client" : file?.endsWith(".server.tilua") ? "server" : undefined)
77
+
78
+ /** @param {string} directory */
79
+ function projectOf(directory) {
80
+ let project = projects.get(directory)
81
+ if (!project) {
82
+ project = { secret: randomBytes(32).toString("hex"), modules: new Map(), claims: new Map() }
83
+ projects.set(directory, project)
84
+ }
85
+ return project
86
+ }
87
+
88
+ /** @type {LoweringPlugin} */
89
+ const plugin = {
90
+ runtime: { guard: GUARD },
91
+
92
+ globalCall({ name, at, perFile, report, use }) {
93
+ if (name === "bridge") {
94
+ report("bridge is not a function: call one of its members, as bridge.send(...)")
95
+ return { callee: "error" }
96
+ }
97
+ if (!name.startsWith("bridge.")) return undefined
98
+ const member = name.slice("bridge.".length)
99
+ // A member that is not one is the type checker's to say
100
+ if (!CLIENT_MEMBERS.has(member) && !SERVER_MEMBERS.has(member)) return { callee: `(function() ${raise(`bridge has no member '${member}'`)} end)` }
101
+ if (!perFile) report(`bridge.${member}: the bridge needs "output": "files", where each file claims its own handle`)
102
+ const side = sideOf(at.file)
103
+ if (side === "client" && SERVER_MEMBERS.has(member)) report(`bridge.${member} runs on the server, and this is a client script`)
104
+ if (side === "server" && CLIENT_MEMBERS.has(member)) report(`bridge.${member} runs on the client, and this is a server script`)
105
+ if (at.file !== undefined) used.add(at.file)
106
+ const handle = `(${HANDLE} or ${raise(REFUSED)})`
107
+ if (!HANDLERS.has(member)) return { callee: `${handle}.${member}` }
108
+ const guard = `${use("guard")}.guard`
109
+ return {
110
+ callee: member === "onBreach"
111
+ ? `(function(handler) ${handle}.${member}(${guard}(handler)) end)`
112
+ : `(function(name, handler, options) ${handle}.${member}(name, ${guard}(handler), options) end)`,
113
+ }
114
+ },
115
+
116
+ globalValue({ name, report }) {
117
+ if (name === "bridge" || name.startsWith("bridge.")) {
118
+ report("bridge can only be called — bridge.send(...) — never kept, passed or exported")
119
+ return "nil"
120
+ }
121
+ return undefined
122
+ },
123
+
124
+ files(layout) {
125
+ // A new build: what calls `bridge` is gathered again as it lowers
126
+ used.clear()
127
+ const base = roblox.files?.(layout) ?? {}
128
+ const project = projectOf(layout.directory)
129
+
130
+ // The module sits beside the runtime, where every script reaches it
131
+ const modulePath = join(dirname(base.runtime ?? layout.runtime), MODULE_NAME)
132
+ // Where a file is in the game: the Rojo project's place for it, or —
133
+ // with no project to say — its path under outDir, as tilua places it
134
+ /** @param {string} file */
135
+ const placeOf = file => base.place?.(file) ?? placeInFolder(relative(layout.outDir, file))
136
+ const modulePlace = base.place?.(modulePath)
137
+ /** How `file` reaches the module: by its place from `game` when the
138
+ * Rojo project puts it there, by the path between them otherwise.
139
+ * @param {string} file */
140
+ const locate = file => modulePlace
141
+ ? [
142
+ `game:GetService(${luauString(modulePlace[0])})`,
143
+ ...modulePlace.slice(1).map(part => `:WaitForChild(${luauString(part)})`),
144
+ ].join("")
145
+ : luauString(pathBetween(placeOf(file), placeOf(modulePath), base.root))
146
+
147
+ /** @param {string} file */
148
+ const callsBridge = file => {
149
+ const written = relative(layout.outDir, file).split(sep).join("/")
150
+ for (const source of used) {
151
+ const output = relative(layout.rootDir, resolve(layout.directory, source)).split(sep).join("/").replace(/\.tilua$/, ".luau")
152
+ if (output === written) return true
153
+ }
154
+ return false
155
+ }
156
+
157
+ return {
158
+ ...base,
159
+ prelude(file) {
160
+ const before = base.prelude?.(file)
161
+ if (!callsBridge(file)) return before
162
+ // A module and a script of one name share a place in the game
163
+ const kind = /\.(server|client|local|legacy|plugin)\.luau?$/.exec(file)?.[1]
164
+ // A place by path still has the kind in its name: once is enough
165
+ const placed = placeOf(file).join("/")
166
+ const inGame = kind && placed.endsWith(`.${kind}`) ? placed.slice(0, -kind.length - 1) : placed
167
+ const where = `${inGame}${kind ? `.${kind}` : ""}`
168
+ const token = createHmac("sha256", project.secret).update(where).digest("hex")
169
+ const plain = `local ${HANDLE} = require(${locate(file)})(${luauString(where)}, ${luauString(token)})`
170
+ // The token is all a claim needs, so it is not left in the file
171
+ // as written, nor where the module is: hidden as the module is
172
+ let claim = plain
173
+ if (layout.options.bridgeObfuscate !== false) {
174
+ claim = project.claims.get(plain)
175
+ if (claim === undefined) {
176
+ claim = obfuscateClaim(plain)
177
+ project.claims.set(plain, claim)
178
+ }
179
+ }
180
+ return before === undefined ? claim : `${before}\n${claim}`
181
+ },
182
+ async output() {
183
+ if (!used.size) return { files: [] }
184
+ const { bridgeMaxPacket: maxPacket, bridgeRateLimit: rateLimit, bridgeObfuscate } = layout.options
185
+ const key = JSON.stringify([maxPacket, rateLimit, bridgeObfuscate])
186
+ if (!project.modules.has(key)) {
187
+ const source = bridgeSource({
188
+ secret: project.secret,
189
+ maxPacket: typeof maxPacket === "number" ? maxPacket : undefined,
190
+ rateLimit: typeof rateLimit === "number" ? rateLimit : undefined,
191
+ })
192
+ project.modules.set(key, bridgeObfuscate === false ? source : await obfuscateBridge(source))
193
+ }
194
+ const clientOnly = [...used].every(file => sideOf(file) === "client")
195
+ return {
196
+ files: [{ file: modulePath, code: project.modules.get(key) }],
197
+ problems: clientOnly
198
+ ? [{ severity: /** @type {const} */ ("warning"), message: "Only client scripts call bridge: nothing starts it on the server, so the clients will wait for it" }]
199
+ : [],
200
+ }
201
+ },
202
+ }
203
+ },
204
+ }
205
+
206
+ /** A file's place from `outDir`, as tilua gives it when no host does: its path
207
+ * without the extension, and `init.luau` the folder it is in.
208
+ * @param {string} path */
209
+ function placeInFolder(path) {
210
+ const parts = path.split(sep).join("/").replace(/\.luau?$/, "").split("/").filter(part => part !== "")
211
+ if (parts.at(-1) === "init") parts.pop()
212
+ return parts
213
+ }
214
+
215
+ /** What a file at `from` passes to `require` to reach the one at `to`, both as
216
+ * places: tilua's own rule, so the module is reached as the runtime is.
217
+ * @param {readonly string[]} from @param {readonly string[]} to @param {string | undefined} root */
218
+ function pathBetween(from, to, root) {
219
+ if (root !== undefined && from[0] !== to[0]) return `${root}/${to.join("/")}`
220
+ if (to.length > from.length && from.every((part, i) => to[i] === part)) return `@self/${to.slice(from.length).join("/")}`
221
+ const base = from.slice(0, -1)
222
+ let shared = 0
223
+ while (shared < base.length && shared < to.length - 1 && base[shared] === to[shared]) shared++
224
+ const up = base.length - shared
225
+ return `${up === 0 ? "./" : "../".repeat(up)}${to.slice(shared).join("/")}`
226
+ }
227
+
228
+ export default plugin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tilua-types/bridge",
3
- "version": "1.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "bridge: Roblox client-server networking through a per-game, obfuscated module that hands each file its handle once, with AES-GCM packets and decoy remotes",
5
5
  "license": "ISC",
6
6
  "type": "module",
@@ -33,14 +33,14 @@
33
33
  "bridgeObfuscate": {
34
34
  "type": "boolean",
35
35
  "default": true,
36
- "description": "Obfuscate the bridge module. Off only to read it while debugging; never ship that."
36
+ "description": "Obfuscate the bridge module, and the strings of the claim at the top of each file that calls it. Off only to read it while debugging; never ship that."
37
37
  }
38
38
  }
39
39
  },
40
40
  "dependencies": {
41
41
  "@tilua-types/crypto": "^1.0.0",
42
42
  "@tilua-types/roblox": "^10.1.2",
43
- "lua-obfuscator": "^3.0.9"
43
+ "luau-obfuscator": "^1.1.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@tilua/parser": "^9.1.2"