Skip to content

Commit e856258

Browse files
committed
add primitive require by string support
1 parent 585d228 commit e856258

File tree

4 files changed

+326
-0
lines changed

4 files changed

+326
-0
lines changed

src/Utils/HotReloader/ApiFix.d.ts

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//Some functions that were missing in Roblox-TS
2+
3+
declare function loadstring(s: string, n?: string): LuaTuple<[Callback?, string?]>;
4+
declare function getfenv(): { script: LuaSourceContainer };
5+
declare function setfenv(f: Callback, env: object): void;
6+
declare function newproxy(): string;
7+
8+
interface NewLuaMetatable<T> {
9+
__index?: ((self: T, index: unknown) => void) | { [K in string]: unknown };
10+
__newindex?: (self: T, index: unknown, value: unknown) => void;
11+
__add?: (self: T, other: T) => T;
12+
__sub?: (self: T, other: T) => T;
13+
__mul?: (self: T, other: T) => T;
14+
__div?: (self: T, other: T) => T;
15+
__mod?: (self: T, other: T) => T;
16+
__pow?: (self: T, other: T) => T;
17+
__unm?: (self: T) => T;
18+
__eq?: (self: T, other: T) => boolean;
19+
__lt?: (self: T, other: T) => boolean;
20+
__le?: (self: T, other: T) => boolean;
21+
__call?: (self: T, ...args: Array<unknown>) => void;
22+
__concat?: (self: T, ...args: Array<unknown>) => string;
23+
__tostring?: (self: T) => string;
24+
__len?: (self: T) => number;
25+
__mode?: "k" | "v" | "kv";
26+
__metatable?: string;
27+
}
28+
29+
declare function setmetatable<T extends object>(object: T, metatable: NewLuaMetatable<T>): T;

src/Utils/HotReloader/Environment.ts

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import Signal from "@rbxts/lemon-signal";
2+
import { LoadVirtualModule } from "./Utils";
3+
4+
const HttpService = game.GetService("HttpService");
5+
6+
type Dependencies = Map<ModuleScript, { Result: unknown }>;
7+
type DependencyLoaders = Map<ModuleScript, Promise<unknown>>;
8+
type Listeners = Map<ModuleScript, RBXScriptConnection>;
9+
10+
export class Environment {
11+
private _ActiveConnections = true;
12+
private _Dependencies: Dependencies = new Map();
13+
private _DependencyLoaders: DependencyLoaders = new Map();
14+
private _Listeners: Listeners = new Map();
15+
16+
readonly EnvironmentUID: string;
17+
private _GlobalInjection?: Record<keyof any, unknown>;
18+
19+
readonly Shared: {} = {};
20+
OnDependencyChanged = new Signal<[module: ModuleScript]>();
21+
private _DestroyedHooked?: () => void;
22+
23+
constructor() {
24+
const uid = HttpService.GenerateGUID(false);
25+
this.EnvironmentUID = uid;
26+
}
27+
EnableGlobalInjection() {
28+
if (!this._GlobalInjection) {
29+
this._GlobalInjection = {};
30+
}
31+
}
32+
InjectGlobal(key: keyof any, value: unknown) {
33+
this.EnableGlobalInjection();
34+
this._GlobalInjection![key] = value;
35+
}
36+
GetGlobalInjection() {
37+
return this._GlobalInjection;
38+
}
39+
40+
private _RegistryDependency(module: ModuleScript, result?: any) {
41+
this._Dependencies.set(module, { Result: result });
42+
}
43+
44+
IsDependency(module: ModuleScript) {
45+
return this._Dependencies.has(module);
46+
}
47+
GetDependencyResult<T = unknown>(module: ModuleScript): T | undefined {
48+
return this._Dependencies.get(module)?.Result as T;
49+
}
50+
51+
ListenDependency(module: ModuleScript) {
52+
if (!this._ActiveConnections) return;
53+
54+
const listener = module.GetPropertyChangedSignal("Source").Connect(() => {
55+
if (!this._ActiveConnections) return;
56+
this.OnDependencyChanged.Fire(module);
57+
});
58+
this._Listeners.set(module, listener);
59+
}
60+
61+
LoadDependency<T = unknown>(dependency: ModuleScript): Promise<T> {
62+
const cached = this.GetDependencyResult(dependency);
63+
if (cached !== undefined) {
64+
return Promise.resolve(cached as T);
65+
}
66+
const cachedLoader = this._DependencyLoaders.get(dependency) as Promise<T>;
67+
if (cachedLoader) {
68+
return cachedLoader.tap(() => {});
69+
}
70+
71+
this.ListenDependency(dependency);
72+
73+
const promise = LoadVirtualModule(dependency, this).tap((result) => {
74+
this._RegistryDependency(dependency, result);
75+
});
76+
this._DependencyLoaders.set(dependency, promise);
77+
78+
return promise as Promise<T>;
79+
}
80+
81+
HookOnDestroyed(callback: () => void) {
82+
this._DestroyedHooked = callback;
83+
}
84+
85+
Destroy() {
86+
if (this._DestroyedHooked) {
87+
this._DestroyedHooked();
88+
}
89+
90+
this._ActiveConnections = false;
91+
this._Listeners.forEach((connection) => {
92+
connection.Disconnect();
93+
});
94+
this.OnDependencyChanged.Destroy();
95+
}
96+
}

src/Utils/HotReloader/HotReloader.ts

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import Signal from "@rbxts/lemon-signal";
2+
import { Environment } from "./Environment";
3+
4+
export class HotReloader<T = unknown> {
5+
/**
6+
* Requires the module and returns a promise that resolves when loaded, and the hot-reloader object
7+
* @param module The module to reload
8+
* @returns [Result: Promise<Result>, Reloader: HotReloader]
9+
*/
10+
11+
readonly Module: ModuleScript;
12+
private _Environment?: Environment;
13+
private _ReloadPromise: Promise<T> | undefined;
14+
private _EnvironmentListener?: RBXScriptConnection;
15+
private _ChangeDefer?: RBXScriptConnection;
16+
17+
readonly OnReloadStarted: Signal<[promise: Promise<T>]>;
18+
readonly OnDependencyChanged: Signal<
19+
[module: ModuleScript, environment: Environment]
20+
>;
21+
private _ReloadBinded?: (environment: Environment) => void;
22+
23+
AutoReload: boolean = true;
24+
25+
constructor(module: ModuleScript) {
26+
this.Module = module;
27+
this.OnReloadStarted = new Signal();
28+
this.OnDependencyChanged = new Signal();
29+
}
30+
31+
private _ClearReloader() {
32+
if (this._ReloadPromise) this._ReloadPromise.cancel();
33+
if (this._EnvironmentListener && this._EnvironmentListener.Connected) {
34+
this._EnvironmentListener.Disconnect();
35+
this._EnvironmentListener = undefined;
36+
}
37+
if (this._ChangeDefer && this._ChangeDefer.Connected) {
38+
this._ChangeDefer.Disconnect();
39+
this._ChangeDefer = undefined;
40+
}
41+
if (this._Environment) {
42+
this._Environment.Destroy();
43+
this._Environment = undefined;
44+
}
45+
}
46+
BeforeReload(bind: (environment: Environment) => void) {
47+
this._ReloadBinded = bind;
48+
}
49+
50+
private _RunBinded(environment: Environment) {
51+
if (this._ReloadBinded) {
52+
this._ReloadBinded(environment);
53+
}
54+
}
55+
GetEnvironment(): Environment | undefined {
56+
return this._Environment;
57+
}
58+
ScheduleReload() {
59+
const isDefered = this._ChangeDefer && this._ChangeDefer.Connected;
60+
if (!isDefered) {
61+
this._ChangeDefer = game.GetService("RunService").Heartbeat.Once(() => {
62+
this.Reload();
63+
});
64+
}
65+
}
66+
Reload(): Promise<T> {
67+
this._ClearReloader();
68+
const environment = new Environment();
69+
this._Environment = environment;
70+
71+
this._RunBinded(environment);
72+
73+
const listener = environment.OnDependencyChanged.Once((module) => {
74+
this.OnDependencyChanged.Fire(module, environment);
75+
if (!this.AutoReload) return;
76+
77+
this.ScheduleReload();
78+
});
79+
this._EnvironmentListener = listener;
80+
81+
const handler = environment.LoadDependency<T>(this.Module);
82+
this._ReloadPromise = handler;
83+
this.OnReloadStarted.Fire(handler);
84+
return handler;
85+
}
86+
87+
Destroy() {
88+
this._ClearReloader();
89+
}
90+
}

src/Utils/HotReloader/Utils.ts

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { Environment } from "./Environment";
2+
3+
/**
4+
* Replaces the environment of a loadstring'ed function
5+
* @param virtualModule function result of loadstring()
6+
* @param module module that was loaded with loadstring()
7+
* @param environment Environment handler object
8+
*/
9+
export function SetEnvironment(
10+
virtualModule: Callback,
11+
module: ModuleScript,
12+
environment: Environment
13+
) {
14+
const globals = {
15+
require: (dependency: ModuleScript | string) => {
16+
let resolved: ModuleScript | undefined = undefined;
17+
const depType = typeOf(dependency);
18+
if (typeIs(dependency, "string")) {
19+
const stringResolved = ResolveStringPath(module, dependency);
20+
if (stringResolved === undefined) {
21+
error(`Could not resolve require ${dependency} in ${module}`, 2);
22+
}
23+
if (!stringResolved.IsA("ModuleScript")) {
24+
error(`Resolved dependency ${dependency} is not a ModuleScript`, 2);
25+
}
26+
resolved = stringResolved;
27+
} else if (depType === "Instance") {
28+
if (dependency.IsA("ModuleScript")) {
29+
if (dependency === module) {
30+
error(`Circular dependency detected: ${module}`, 2);
31+
}
32+
resolved = dependency;
33+
} else {
34+
error(`Dependency ${dependency} is not a ModuleScript`, 2);
35+
}
36+
}
37+
if (resolved === undefined) {
38+
error(`Could not resolve require ${dependency} in ${module}`, 2);
39+
}
40+
41+
return environment.LoadDependency(resolved).expect();
42+
},
43+
script: module,
44+
_G: environment.Shared
45+
};
46+
const env = getfenv();
47+
const injection = environment.GetGlobalInjection();
48+
const index = injection ? setmetatable(injection, { __index: env }) : env;
49+
50+
const newEnvironment = setmetatable(globals, {
51+
__index: index //defaults any global variables to the current global environment
52+
});
53+
setfenv(virtualModule, newEnvironment);
54+
}
55+
56+
/**
57+
* Requires a module by using loadstring, this also replaces the _G table and the function "require()"
58+
* @param module the module to laod
59+
* @param environment Environment handler object
60+
*/
61+
export async function LoadVirtualModule(
62+
module: ModuleScript,
63+
environment: Environment
64+
) {
65+
const [virtualModule, err] = loadstring(module.Source, module.GetFullName());
66+
67+
if (virtualModule === undefined) {
68+
throw err;
69+
}
70+
71+
SetEnvironment(virtualModule, module, environment);
72+
73+
const [sucess, result] = pcall(virtualModule);
74+
if (sucess) {
75+
return result as unknown;
76+
} else {
77+
throw result;
78+
}
79+
}
80+
81+
export function ResolveStringPath(root: Instance, path: string) {
82+
const parts = path.split("/");
83+
let current: Instance = root;
84+
85+
if (parts.size() === 0) throw `Invalid relative path: ${path}`;
86+
if (parts[0] !== "." && parts[0] !== "..") {
87+
throw `Invalid path start: "${parts[0]}" in ${path}`;
88+
}
89+
90+
for (let i = 0; i < parts.size(); i++) {
91+
const part = parts[i];
92+
if (part === "") {
93+
throw `Double slashes are not allowed in path: ${path}`;
94+
}
95+
96+
if (part === "..") {
97+
let parent = current.Parent?.Parent;
98+
if (parent === undefined) throw `No parent found in: ${current}`;
99+
current = parent;
100+
} else if (part === ".") {
101+
const parent = current.Parent;
102+
if (parent === undefined) throw `No parent found in: ${current}`;
103+
current = parent;
104+
} else {
105+
const child = current.FindFirstChild(part);
106+
if (child === undefined) throw `Unknown script ${part} in: ${current}`;
107+
current = child;
108+
}
109+
}
110+
return current;
111+
}

0 commit comments

Comments
 (0)