@tilua-types/bridge 2.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.
Files changed (4) hide show
  1. package/README.md +13 -17
  2. package/generate.mjs +93 -70
  3. package/lowering.mjs +228 -187
  4. package/package.json +3 -3
package/README.md CHANGED
@@ -101,6 +101,10 @@ calling a server's member or the other way round, and any output but `"files"`.
101
101
  secret. A token the build did not make is a breach and gets nothing. A real one
102
102
  claims as often as it is offered: the game can clone any script, and it runs
103
103
  again wherever it lands, so a second claim cannot be told from a replay.
104
+ The claim's strings — the module's path, the place and the token — are
105
+ encrypted in the file, so neither reading the script's constants nor searching
106
+ its source turns up a token to replay (`bridgeObfuscate: false` leaves them as
107
+ written).
104
108
  - **Nothing hands it out.** The module's value is that one claim function. The
105
109
  handle is a local of the file: not in its exports, not in tilua's runtime
106
110
  (`G`, which anything can `require`). `bridge` read as a value — kept,
@@ -164,9 +168,12 @@ those. Two more things to know:
164
168
 
165
169
  Each build writes the module beside tilua's runtime — `shared/_bridge.luau` in the
166
170
  output of a project laid out by `tilua init` — with that build's secret inside,
167
- obfuscated with [lua-obfuscator](https://www.npmjs.com/package/lua-obfuscator). The
171
+ obfuscated with [luau-obfuscator](https://www.npmjs.com/package/luau-obfuscator). The
168
172
  secret is kept nowhere else, so there is nothing to set up or check in. `--watch`
169
- keeps one secret while it runs. A project from before this has a
173
+ keeps one secret while it runs. A Rojo project isn't needed: without one the module
174
+ is written as `_bridge.luau` in `outDir`, and each file requires it by its relative
175
+ path (`require("../_bridge")`). With one, files reach it from `game`, as before.
176
+ A project from before this has a
170
177
  `src/shared/_bridge.luau` and a `.tilua` folder to delete.
171
178
 
172
179
  The module runs in one Luau VM: calling `bridge` from an Actor stops with an error
@@ -188,25 +195,14 @@ many numbers as it takes — and one that does not make the type is `nil`. The r
188
195
  with `\1` is the bridge's mark, so a key of the game's that does is escaped on the
189
196
  way and arrives as it was.
190
197
 
191
- The obfuscation hides the secret and every string in the module, but not the
192
- names of members it reads (`.OnServerInvoke`, `crypto.aesGcm`): lua-obfuscator
193
- keeps `a.b`'s `b` as written.
194
-
195
- ## Tested
196
-
197
- `npm test` in the repository compiles a project with it — claims only in the
198
- files that call `bridge`, nothing in the runtime or the exports — and runs the
199
- module as server and client over a fake network, plain and obfuscated: traffic
200
- both ways, one packet a frame each way in order, invokes answered and refused, Instances, value types and `nil`s found again,
201
- each handler's limits, then a late executor making up a token, firing the packet remote with
202
- its own bytes and with a captured packet, asking for the
203
- key, and firing and invoking every decoy. Each is refused and reported. The
204
- network is a fake: nothing here has run in Roblox.
198
+ The obfuscation hides the secret, every string in the module, and the names of
199
+ the members it reads (`.OnServerInvoke`, `crypto.aesGcm`): luau-obfuscator runs the
200
+ whole module in its VM, so none of them is left in the source as written.
205
201
 
206
202
  <!-- compatibility:start -->
207
203
  ## Compatibility
208
204
 
209
- `@tilua-types/bridge@2.0.0` works with these tilua packages. *Released with* is the
205
+ `@tilua-types/bridge@2.1.0` works with these tilua packages. *Released with* is the
210
206
  version it was built and tested against.
211
207
 
212
208
  | Package | Range | Released with | |
package/generate.mjs CHANGED
@@ -1,70 +1,93 @@
1
- // @ts-check
2
- /**
3
- * The game's own bridge module: runtime/bridge.luau with the secret, the limits,
4
- * the decoy count and the crypto runtime filled in, then obfuscated. The build
5
- * writes it; the tests make it the same way.
6
- */
7
- import { existsSync, readFileSync } from "node:fs"
8
- import { createRequire } from "node:module"
9
- import { dirname, join } from "node:path"
10
- import { fileURLToPath } from "node:url"
11
-
12
- const here = dirname(fileURLToPath(import.meta.url))
13
- const require = createRequire(import.meta.url)
14
-
15
- /** @tilua-types/crypto's runtime: from its package, or the checkout beside this one. */
16
- function cryptoRuntime() {
17
- let folder
18
- try {
19
- folder = dirname(require.resolve("@tilua-types/crypto/package.json"))
20
- } catch {
21
- folder = join(here, "..", "crypto")
22
- }
23
- const file = join(folder, "runtime", "crypto.luau")
24
- if (!existsSync(file)) throw new Error("@tilua-types/crypto is not installed")
25
- return readFileSync(file, "utf8")
26
- }
27
-
28
- /** The table the crypto runtime is built into, inside the bridge module. */
29
- const CRYPTO = "BridgeCrypto"
30
-
31
- /**
32
- * The module's Luau before obfuscation.
33
- * @param {{ secret: string, decoys?: number, maxPacket?: number, rateLimit?: number }} options
34
- */
35
- export function bridgeSource({ secret, decoys = 4, maxPacket = 65536, rateLimit = 120 }) {
36
- if (!/^[0-9a-f]{32,}$/.test(secret)) throw new Error("A bridge secret is at least 32 hex characters")
37
- // Function replacers throughout: the crypto runtime holds `$` characters a
38
- // replacement string would read as patterns.
39
- return readFileSync(join(here, "runtime", "bridge.luau"), "utf8")
40
- .replace("__SECRET__", () => JSON.stringify(secret))
41
- .replace("__DECOYS__", () => String(decoys))
42
- .replace("__MAX_PACKET__", () => String(maxPacket))
43
- .replace("__RATE_LIMIT__", () => String(rateLimit))
44
- .replace("__CRYPTO_NAME__", () => CRYPTO)
45
- .replace("__CRYPTO__", () => cryptoRuntime().replace(/__NAME__/g, CRYPTO))
46
- }
47
-
48
- /** What the module goes through: every pass lua-obfuscator has, the VM included. */
49
- export const OBFUSCATION_STEPS = [
50
- { name: "InsertJunk" },
51
- { name: "RenameVariables" },
52
- { name: "Vmify" },
53
- { name: "ConstantArray" },
54
- { name: "GlobalMapping" },
55
- { name: "StringsToExpressions" },
56
- { name: "NumbersToExpressions" },
57
- { name: "EncryptStrings" },
58
- { name: "EncryptNumbers" },
59
- { name: "WrapInFunction" },
60
- ]
61
-
62
- /**
63
- * The module, obfuscated.
64
- * @param {string} source
65
- * @param {{ steps?: readonly object[] }} [options]
66
- */
67
- export async function obfuscateBridge(source, { steps = OBFUSCATION_STEPS } = {}) {
68
- const { obfuscate } = await import("lua-obfuscator")
69
- return obfuscate(source, "luaU", { steps: /** @type {any} */ (steps), minify: true })
70
- }
1
+ // @ts-check
2
+ /**
3
+ * The game's own bridge module: runtime/bridge.luau with the secret, the limits,
4
+ * the decoy count and the crypto runtime filled in, then obfuscated. The build
5
+ * writes it; the tests make it the same way.
6
+ */
7
+ import { existsSync, readFileSync } from "node:fs"
8
+ import { createRequire } from "node:module"
9
+ import { dirname, join } from "node:path"
10
+ import { fileURLToPath } from "node:url"
11
+ import { obfuscate } from "luau-obfuscator"
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url))
14
+ const require = createRequire(import.meta.url)
15
+
16
+ /** @tilua-types/crypto's runtime: from its package, or the checkout beside this one. */
17
+ function cryptoRuntime() {
18
+ let folder
19
+ try {
20
+ folder = dirname(require.resolve("@tilua-types/crypto/package.json"))
21
+ } catch {
22
+ folder = join(here, "..", "crypto")
23
+ }
24
+ const file = join(folder, "runtime", "crypto.luau")
25
+ if (!existsSync(file)) throw new Error("@tilua-types/crypto is not installed")
26
+ return readFileSync(file, "utf8")
27
+ }
28
+
29
+ /** The table the crypto runtime is built into, inside the bridge module. */
30
+ const CRYPTO = "BridgeCrypto"
31
+
32
+ /**
33
+ * The module's Luau before obfuscation.
34
+ * @param {{ secret: string, decoys?: number, maxPacket?: number, rateLimit?: number }} options
35
+ */
36
+ export function bridgeSource({ secret, decoys = 4, maxPacket = 65536, rateLimit = 120 }) {
37
+ if (!/^[0-9a-f]{32,}$/.test(secret)) throw new Error("A bridge secret is at least 32 hex characters")
38
+ // Function replacers throughout: the crypto runtime holds `$` characters a
39
+ // replacement string would read as patterns.
40
+ return readFileSync(join(here, "runtime", "bridge.luau"), "utf8")
41
+ .replace("__SECRET__", () => JSON.stringify(secret))
42
+ .replace("__DECOYS__", () => String(decoys))
43
+ .replace("__MAX_PACKET__", () => String(maxPacket))
44
+ .replace("__RATE_LIMIT__", () => String(rateLimit))
45
+ .replace("__CRYPTO_NAME__", () => CRYPTO)
46
+ .replace("__CRYPTO__", () => cryptoRuntime().replace(/__NAME__/g, CRYPTO))
47
+ }
48
+
49
+ /**
50
+ * What the module goes through: every pass luau-obfuscator has, the VM included,
51
+ * each with the package's own defaults.
52
+ * @type {import("luau-obfuscator").ObfuscatePartialConfig}
53
+ */
54
+ export const OBFUSCATION_CONFIG = {
55
+ InsertJunk: { active: true },
56
+ RenameVariables: { active: true },
57
+ Vmify: { active: true },
58
+ ConstantArray: { active: true },
59
+ GlobalMapping: { active: true },
60
+ StringsToExpressions: { active: true },
61
+ NumbersToExpressions: { active: true },
62
+ EncryptStrings: { active: true },
63
+ EncryptNumbers: { active: true },
64
+ WrapInFunction: { active: true },
65
+ Minify: { active: true },
66
+ }
67
+
68
+ /**
69
+ * The module, obfuscated.
70
+ * @param {string} source
71
+ * @param {{ config?: import("luau-obfuscator").ObfuscatePartialConfig }} [options]
72
+ */
73
+ export async function obfuscateBridge(source, { config = OBFUSCATION_CONFIG } = {}) {
74
+ return obfuscate(source, config)
75
+ }
76
+
77
+ /**
78
+ * What a claim goes through: its strings encrypted — the token, the place and
79
+ * the module's path — and nothing else, so the claim stays a local of the file
80
+ * by its own name, and the file stays small.
81
+ * @type {import("luau-obfuscator").ObfuscatePartialConfig}
82
+ */
83
+ const CLAIM_CONFIG = Object.fromEntries(
84
+ Object.keys(OBFUSCATION_CONFIG).map(pass => [pass, { active: pass === "EncryptStrings" }]),
85
+ )
86
+
87
+ /**
88
+ * A file's claim line, its strings encrypted.
89
+ * @param {string} claim
90
+ */
91
+ export function obfuscateClaim(claim) {
92
+ return obfuscate(claim, CLAIM_CONFIG)
93
+ }
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", "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 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, options) ${handle}.${member}(name, ${guard}(handler), options) 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": "2.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"