@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/README.md +86 -22
- package/generate.mjs +93 -70
- package/index.d.tilua +31 -7
- package/lowering.mjs +228 -187
- package/package.json +3 -3
- package/runtime/bridge.luau +830 -103
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
|
|
66
|
-
* build writes the same files until something changes
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (!
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (side === "
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
-
"
|
|
43
|
+
"luau-obfuscator": "^1.1.0"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@tilua/parser": "^9.1.2"
|