diff --git a/code/framework/src/integrations/client/instance.cpp b/code/framework/src/integrations/client/instance.cpp index 3bf7dfbad..f780a81de 100644 --- a/code/framework/src/integrations/client/instance.cpp +++ b/code/framework/src/integrations/client/instance.cpp @@ -13,6 +13,7 @@ #include "networking/rpc/rpc.h" #include "networking/rpc/chat_message.h" #include "networking/rpc/client_identity.h" +#include "networking/rpc/resource_refresh.h" #include "networking/rpc/server_resources.h" #include "scripting/resource/resource_manager.h" @@ -350,6 +351,59 @@ namespace Framework::Integrations::Client { DownloadsAssetsFromConnectedServer(); }); + // Server hot-reloaded a client resource (dev mode): re-sync its files + // (delta) and restart just that resource, leaving the rest running. + net->RegisterRPC([this](const Framework::Networking::RPC::ResourceRefresh &payload, MafiaNet::Packet *) { + if (payload.resources.empty()) { + return; + } + // Ignore until connected with a running module, else we'd cancel + // the initial download (which already fetches current files). + auto *sm = GetScriptingModule(); + if (!sm || !sm->GetScriptingEngine() || !sm->GetScriptingEngine()->IsInitialized()) { + return; + } + // Accumulate (deduped): one reload arrives as several RPCs and a + // single delta download covers them all; overwriting would drop all but the last. + for (const auto &r : payload.resources) { + bool known = false; + for (const auto &e : _pendingRefreshResources) { + if (e.name == r.name) { known = true; break; } + } + if (!known) { + _pendingRefreshResources.push_back(r); + } + } + Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->debug("Server hot-reloaded {} resource(s); re-syncing", payload.resources.size()); + // An in-flight re-sync already covers these; it drains the full set on complete. + if (!_downloadStatus.downloading) { + SyncResourceUpdatesFromServer(); + } + }); + + // Server stopped a client resource at runtime: stop it here too. + net->RegisterRPC([this](const Framework::Networking::RPC::ResourceStop &payload, MafiaNet::Packet *) { + if (payload.resources.empty()) { + return; + } + auto *sm = GetScriptingModule(); + if (!sm || !sm->GetScriptingEngine() || !sm->GetScriptingEngine()->IsInitialized()) { + return; + } + auto *rm = sm->GetResourceManager(); + if (!rm) { + return; + } + for (const auto &res : payload.resources) { + if (rm->IsResourceRunning(res.name)) { + auto result = rm->StopResource(res.name); + if (!result) { + Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Failed to stop client resource '{}': {}", res.name, result.GetError()); + } + } + } + }); + // Spawn barrier complete: activate replication and report the connection final. net->SetOnConnectionReadyCallback([this, net](int eventId) { // Only the event the server assigned in ServerResources finalizes this connection, and @@ -484,12 +538,68 @@ namespace Framework::Integrations::Client { _downloadStatus.setID = streamer->DownloadFromSubdirectory(nullptr, nullptr, true, net->GetPeer()->GetSystemAddressFromIndex(0), new AssetDownloadFileProgress(this), MafiaNet::Priority::High, 2, nullptr); } + void Instance::SyncResourceUpdatesFromServer() { + const auto net = GetNetworkingEngine()->GetNetworkClient(); + if (net->GetConnectionState() != Framework::Networking::PeerState::CONNECTED) { + Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Cannot re-sync resources while not connected"); + _pendingRefreshResources.clear(); + return; + } + + // Unlike DownloadsAssetsFromConnectedServer, does NOT stop all resources + // or tear down web views; cache path is already set from connect. + const auto streamer = net->GetAssetStreamer(); + const auto cacheDir = GetAssetCachePath(); + if (cacheDir.empty()) { + Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("No asset cache path set; cannot re-sync resources"); + _pendingRefreshResources.clear(); + return; + } + streamer->SetApplicationDirectory(cacheDir.c_str()); + + if (_downloadStatus.downloading) { + net->GetFileListTransfer()->CancelReceive(_downloadStatus.setID); + _downloadStatus = {}; + } + _downloadStatus.downloading = true; + _downloadStatus.setID = streamer->DownloadFromSubdirectory(nullptr, nullptr, true, net->GetPeer()->GetSystemAddressFromIndex(0), new AssetDownloadFileProgress(this), MafiaNet::Priority::High, 2, nullptr); + } + void Instance::OnAssetsDownloaded(bool success) { const auto net = GetNetworkingEngine()->GetNetworkClient(); if (success) { Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->debug("All the assets have been downloaded!"); auto scriptingModule = GetScriptingModule(); + + // Hot-reload path: module already running, refresh just the flagged + // resources instead of re-initializing everything. + if (scriptingModule && scriptingModule->GetScriptingEngine() + && scriptingModule->GetScriptingEngine()->IsInitialized() + && !_pendingRefreshResources.empty()) { + if (auto *rm = scriptingModule->GetResourceManager()) { + for (const auto &res : _pendingRefreshResources) { + // Newly started server-side: discover from cache first. + if (!rm->HasResource(res.name)) { + const std::string resPath = GetAssetCachePath() + "/" + res.name; + if (!rm->DiscoverResource(resPath)) { + Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Could not discover new client resource '{}' at {}", res.name, resPath); + continue; + } + } + // Reload if running, start if newly discovered/stopped. + auto result = rm->IsResourceRunning(res.name) ? rm->RefreshResource(res.name) : rm->StartResource(res.name); + if (!result) { + Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Failed to sync client resource '{}': {}", res.name, result.GetError()); + } + } + } + _pendingRefreshResources.clear(); + return; + } + // A refresh that raced an initial connect falls through to full init. + _pendingRefreshResources.clear(); + if (scriptingModule) { // Set resource cache path before init scriptingModule->SetResourceCachePath(GetAssetCachePath()); diff --git a/code/framework/src/integrations/client/instance.h b/code/framework/src/integrations/client/instance.h index 88a34bb50..a6c02d54a 100644 --- a/code/framework/src/integrations/client/instance.h +++ b/code/framework/src/integrations/client/instance.h @@ -109,6 +109,10 @@ namespace Framework::Integrations::Client { // Pending resources from server (stored here to survive scripting module reset) std::vector _pendingServerResources; + // Client resources the server hot-reloaded; refreshed after the next + // asset re-sync completes (dev mode). Empty on a normal connect. + std::vector _pendingRefreshResources; + // Handshake state carried from ServerResources until the ReadyEvent spawn barrier completes. int _readyEventId {}; float _serverTickRate {}; @@ -118,6 +122,8 @@ namespace Framework::Integrations::Client { void InitNetworkingMessages(); void InitAssetDownloader(); void OnAssetsDownloaded(bool success); + // Targeted delta re-sync for a hot-reload (does not stop all resources). + void SyncResourceUpdatesFromServer(); void InitCacheAssetFolders(); void RegisterScriptingBuiltins(Framework::Scripting::Engine *); diff --git a/code/framework/src/integrations/server/instance.cpp b/code/framework/src/integrations/server/instance.cpp index 0fd0309db..dd22fdb74 100644 --- a/code/framework/src/integrations/server/instance.cpp +++ b/code/framework/src/integrations/server/instance.cpp @@ -18,6 +18,7 @@ #include "networking/replication/replication_manager.h" #include "networking/rpc/chat_message.h" #include "networking/rpc/client_identity.h" +#include "networking/rpc/resource_refresh.h" #include "networking/rpc/server_resources.h" #include "networking/connection.h" @@ -152,6 +153,7 @@ namespace Framework::Integrations::Server { // Initialize the scripting engine _scriptingModule->SetResourcesPath(_opts.resourcesPath); + _scriptingModule->SetDevMode(_opts.developmentMode); if (_scriptingModule->Init(sdkCallback) != Framework::Scripting::ScriptingError::SCRIPTING_NONE) { return Error("Failed to initialize the scripting engine"); } @@ -177,11 +179,25 @@ namespace Framework::Integrations::Server { // Initialize asset streamer (needs discovered resources to know client files) InitAssetStreamer(); + // Mirror runtime resource start/stop to clients. Gated on boot (the + // StartAll below predates any connection) and shutdown. + _scriptingModule->GetResourceManager()->SetOnResourceStarted([this](const std::string &name) { + if (_resourcesBooted && !_shuttingDown) { + BroadcastResourceRefresh(name); + } + }); + _scriptingModule->GetResourceManager()->SetOnResourceStopped([this](const std::string &name) { + if (_resourcesBooted && !_shuttingDown) { + BroadcastResourceStop(name); + } + }); + // Start all resources (ES modules load asynchronously via normal Update cycle) auto startResult = _scriptingModule->GetResourceManager()->StartAll(); if (!startResult) { Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to start resources: {}", startResult.GetError()); } + _resourcesBooted = true; Logging::GetLogger(FRAMEWORK_INNER_SERVER)->flush(); Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Host:\t{}", _opts.bindHost); @@ -431,6 +447,72 @@ namespace Framework::Integrations::Server { Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Asset streamer ready with {} client files", streamer->GetNumberOfFilesForUpload()); } + void Instance::BroadcastResourceRefresh(const std::string &name) { + if (!_scriptingModule || !_networkingEngine) { + return; + } + auto *rm = _scriptingModule->GetResourceManager(); + if (!rm) { + return; + } + const auto *resource = rm->GetResource(name); + if (!resource) { + return; + } + // Only resources with a client entry point need a client-side refresh. + if (resource->GetManifest().GetMafiaHubConfig().client.empty()) { + return; + } + + const auto net = _networkingEngine->GetNetworkServer(); + if (!net) { + return; + } + + // Rebuild the streamer's upload list so changed files get fresh hashes; + // the delta transfer compares stored hashes. See docs/resource_hot_reload.md. + if (auto *streamer = net->GetAssetStreamer()) { + streamer->ClearUploads(); + } + InitAssetStreamer(); + + Framework::Networking::RPC::ResourceRefresh refresh; + refresh.resources.push_back({resource->GetName(), resource->GetVersion()}); + net->BroadcastRPC(refresh); + + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Broadcasting hot-reload of resource '{}' to clients", name); + } + + void Instance::BroadcastResourceStop(const std::string &name) { + if (!_scriptingModule || !_networkingEngine) { + return; + } + auto *rm = _scriptingModule->GetResourceManager(); + if (!rm) { + return; + } + const auto *resource = rm->GetResource(name); + if (!resource) { + return; + } + // Only client resources need a client-side stop. + if (resource->GetManifest().GetMafiaHubConfig().client.empty()) { + return; + } + + const auto net = _networkingEngine->GetNetworkServer(); + if (!net) { + return; + } + + // No streamer rebuild: stopping ships no files. + Framework::Networking::RPC::ResourceStop stop; + stop.resources.push_back({resource->GetName(), resource->GetVersion()}); + net->BroadcastRPC(stop); + + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Broadcasting stop of resource '{}' to clients", name); + } + void Instance::InitCommandListener() { Logging::GetLogger(FRAMEWORK_INNER_SERVER)->debug("Setting up command listener and processor..."); @@ -450,13 +532,36 @@ namespace Framework::Integrations::Server { "Show this help message"); _commandProcessor->RegisterCommand( - "stop", {}, + "quit", {}, [this](cxxopts::ParseResult &) { Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Stopping server..."); Shutdown(); }, "Stop the server"); + // No argument: stop the server (back-compat). With one: stop a resource. + _commandProcessor->RegisterCommand( + "stop", {}, + [this](cxxopts::ParseResult &result) { + const auto &args = result.unmatched(); + if (args.empty()) { + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Stopping server..."); + Shutdown(); + return; + } + auto *rm = _scriptingModule ? _scriptingModule->GetResourceManager() : nullptr; + if (!rm) { + return; + } + auto res = rm->StopResource(args[0]); + if (res) { + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Stopped resource '{}'", args[0]); + } else { + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to stop '{}': {}", args[0], res.GetError()); + } + }, + "Stop a resource (stop ), or the server if no resource is given"); + _commandProcessor->RegisterCommand( "status", {}, [this](cxxopts::ParseResult &) { @@ -471,7 +576,83 @@ namespace Framework::Integrations::Server { } }, "Show server status"); - + + // Resource lifecycle commands. Helper folds the shared boilerplate + // (resolve manager, require a name, log the result). + auto resourceCommand = [this](std::string_view verb, auto op) { + return [this, verb, op](cxxopts::ParseResult &result) { + auto *rm = _scriptingModule ? _scriptingModule->GetResourceManager() : nullptr; + if (!rm) { + return; + } + const auto &args = result.unmatched(); + if (args.empty()) { + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->warn("Usage: {} ", verb); + return; + } + auto res = op(rm, args[0]); + if (res) { + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("{}: '{}'", verb, args[0]); + } else { + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to {} '{}': {}", verb, args[0], res.GetError()); + } + }; + }; + + _commandProcessor->RegisterCommand( + "start", {}, + resourceCommand("start", [](Framework::Scripting::ResourceManager *rm, const std::string &n) { + return rm->StartResource(n); + }), + "Start a resource: start "); + + _commandProcessor->RegisterCommand( + "restart", {}, + resourceCommand("restart", [](Framework::Scripting::ResourceManager *rm, const std::string &n) { + if (!rm->IsResourceRunning(n)) { + return Framework::Scripting::ResourceOperationResult(std::string("resource is not running (use start)")); + } + return rm->RestartResource(n); + }), + "Reload a running resource's code: restart "); + + // Start-or-reload — the canonical verb FiveM/MTASA operators expect. + _commandProcessor->RegisterCommand( + "ensure", {}, + resourceCommand("ensure", [](Framework::Scripting::ResourceManager *rm, const std::string &n) { + return rm->IsResourceRunning(n) ? rm->RefreshResource(n) : rm->StartResource(n); + }), + "Start or reload a resource: ensure "); + + // Re-scan for new/changed resources (manifests), without restarting. + _commandProcessor->RegisterCommand( + "refresh", {}, + [this](cxxopts::ParseResult &) { + auto *rm = _scriptingModule ? _scriptingModule->GetResourceManager() : nullptr; + if (!rm) { + return; + } + auto added = rm->Rescan(); + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Refreshed resources ({} new)", added.size()); + }, + "Re-scan the resources directory for new/changed resources"); + + _commandProcessor->RegisterCommand( + "refreshall", {}, + [this](cxxopts::ParseResult &) { + auto *rm = _scriptingModule ? _scriptingModule->GetResourceManager() : nullptr; + if (!rm) { + return; + } + auto res = rm->RefreshAll(); + if (res) { + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Refreshed all resources ({} affected)", res.GetValue().size()); + } else { + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to refresh all resources: {}", res.GetError()); + } + }, + "Re-scan and reload all running resources from disk"); + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->debug("Command listener and processor initialized"); } diff --git a/code/framework/src/integrations/server/instance.h b/code/framework/src/integrations/server/instance.h index b6792d9f9..82c357645 100644 --- a/code/framework/src/integrations/server/instance.h +++ b/code/framework/src/integrations/server/instance.h @@ -45,6 +45,10 @@ namespace Framework::Integrations::Server { std::string modConfigFile = "server.json"; std::string resourcesPath = "resources"; + // Development mode: watch resource files and hot-reload on change. + // Leave off in production. + bool developmentMode = false; + // networked game metadata (required) std::string gameName; std::string gameVersion; @@ -104,6 +108,8 @@ namespace Framework::Integrations::Server { class Instance : public Framework::Lifecycle { private: std::atomic _shuttingDown; + // Set after the initial StartAll; gates runtime broadcasts to clients. + bool _resourcesBooted = false; std::chrono::time_point _nextTick; InstanceOptions _opts; @@ -119,6 +125,10 @@ namespace Framework::Integrations::Server { void InitEndpoints(); void InitNetworkingMessages(); void InitAssetStreamer(); + // Re-sync a hot-reloaded/started client resource to connected clients. + void BroadcastResourceRefresh(const std::string &name); + // Tell connected clients to stop a client resource. + void BroadcastResourceStop(const std::string &name); void InitCommandListener(); bool LoadConfigFromJSON(); void RegisterScriptingBuiltins(Framework::Scripting::Engine *); diff --git a/code/framework/src/integrations/server/scripting/module.cpp b/code/framework/src/integrations/server/scripting/module.cpp index 7157f8b1b..297053dc2 100644 --- a/code/framework/src/integrations/server/scripting/module.cpp +++ b/code/framework/src/integrations/server/scripting/module.cpp @@ -56,6 +56,7 @@ namespace Framework::Integrations::Server::Scripting { config.resourcesPath = _resourcesPath; config.isClient = false; config.cascadeStopDependents = true; + config.devMode = _devMode; _resourceManager = std::make_unique( _nodeEngine.get(), config); @@ -77,6 +78,7 @@ namespace Framework::Integrations::Server::Scripting { v8::Context::Scope contextScope(context); _nodeEngine->InstallUncaughtExceptionHandler(_resourcesPath); + _nodeEngine->InstallResourceTimerTracking(); } Logging::GetLogger(FRAMEWORK_INNER_SCRIPTING)->info( @@ -180,6 +182,8 @@ namespace Framework::Integrations::Server::Scripting { if (_resourceManager) { // Process scheduled restarts _resourceManager->ProcessScheduledRestarts(); + // Dev-mode: poll resource files and hot-reload on change + _resourceManager->ProcessFileWatch(); } // Process pending message responses @@ -204,6 +208,15 @@ namespace Framework::Integrations::Server::Scripting { } } + void ServerScriptingModule::SetDevMode(bool enabled) { + _devMode = enabled; + if (_resourceManager) { + Framework::Scripting::ResourceManagerConfig config = _resourceManager->GetConfig(); + config.devMode = enabled; + _resourceManager->SetConfig(config); + } + } + std::vector ServerScriptingModule::GetClientResourceList() const { std::vector result; diff --git a/code/framework/src/integrations/server/scripting/module.h b/code/framework/src/integrations/server/scripting/module.h index 182bc7ce7..a339a276b 100644 --- a/code/framework/src/integrations/server/scripting/module.h +++ b/code/framework/src/integrations/server/scripting/module.h @@ -89,6 +89,12 @@ namespace Framework::Integrations::Server::Scripting { */ std::string GetResourcesPath() const { return _resourcesPath; } + /** + * Enable/disable development mode (file-watch hot-reload). Must be set + * before Init(); also updates an already-created resource manager. + */ + void SetDevMode(bool enabled); + /** * Get list of resources to send to clients. * Only includes resources with client entry points defined. @@ -110,6 +116,7 @@ namespace Framework::Integrations::Server::Scripting { std::unique_ptr _resourceManager; std::string _resourcesPath = "resources"; + bool _devMode = false; }; } // namespace Framework::Integrations::Server::Scripting diff --git a/code/framework/src/networking/rpc/resource_refresh.h b/code/framework/src/networking/rpc/resource_refresh.h new file mode 100644 index 000000000..a3b795121 --- /dev/null +++ b/code/framework/src/networking/rpc/resource_refresh.h @@ -0,0 +1,76 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2024, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "rpc.h" +#include "server_resources.h" // ResourceInfo + +#include +#include +#include +#include + +namespace Framework::Networking::RPC { + // Server -> client: these client resources were (re)started; the client + // re-syncs their files (delta) and reloads/starts them. + struct ResourceRefresh { + static constexpr const char *kIdentifier = "Framework::ResourceRefresh"; + static constexpr uint16_t kMaxResources = 1000; // bound untrusted input + + std::vector resources; + + void Serialize(MafiaNet::BitStream *bs, bool write) { + if (write && resources.size() > std::numeric_limits::max()) { + Logging::GetLogger(FRAMEWORK_INNER_NETWORKING)->error("ResourceRefresh holds {} resources, exceeding the wire limit; truncating", resources.size()); + } + + uint16_t count = static_cast(std::min(resources.size(), std::numeric_limits::max())); + bs->Serialize(write, count); + if (!write) { + resources.clear(); + resources.resize(std::min(count, kMaxResources)); + } + for (uint16_t i = 0; i < count; ++i) { + // Entries past the sane cap are still consumed so the bitstream stays aligned. + if (!write && i >= kMaxResources) { + ResourceInfo discard; + discard.Serialize(bs, write); + continue; + } + resources[i].Serialize(bs, write); + } + } + }; + + // Server -> client: stop these resources (no files). Sent when a client + // resource stops at runtime. + struct ResourceStop { + static constexpr const char *kIdentifier = "Framework::ResourceStop"; + static constexpr uint16_t kMaxResources = 1000; + + std::vector resources; + + void Serialize(MafiaNet::BitStream *bs, bool write) { + uint16_t count = static_cast(std::min(resources.size(), std::numeric_limits::max())); + bs->Serialize(write, count); + if (!write) { + resources.clear(); + resources.resize(std::min(count, kMaxResources)); + } + for (uint16_t i = 0; i < count; ++i) { + if (!write && i >= kMaxResources) { + ResourceInfo discard; + discard.Serialize(bs, write); + continue; + } + resources[i].Serialize(bs, write); + } + } + }; +} // namespace Framework::Networking::RPC diff --git a/code/framework/src/scripting/engine.h b/code/framework/src/scripting/engine.h index 8446d33a9..a7adffc2d 100644 --- a/code/framework/src/scripting/engine.h +++ b/code/framework/src/scripting/engine.h @@ -64,6 +64,14 @@ namespace Framework::Scripting { */ virtual bool ExecuteFile(std::string_view filepath) = 0; + // Evict cached modules under a resource dir so hot-reload re-reads + // edited files. No-op without a module cache; call only after stop. + virtual void EvictModulesUnderPath(const std::string &rootPath) {} + + // Cancel timers the resource created (setTimeout/setInterval) so the + // shared runtime doesn't keep firing them after stop. Call after stop. + virtual void ClearResourceTimers(const std::string &resourceName) {} + /** * Register framework SDK bindings. * Called after Init() to set up Framework.* APIs. diff --git a/code/framework/src/scripting/node_engine.cpp b/code/framework/src/scripting/node_engine.cpp index 516e5e977..aaa065095 100644 --- a/code/framework/src/scripting/node_engine.cpp +++ b/code/framework/src/scripting/node_engine.cpp @@ -9,6 +9,7 @@ #include "node_engine.h" #include "engine_helpers.h" #include "builtins/messages.h" +#include "resource/resource_manager.h" #include @@ -205,6 +206,64 @@ namespace Framework::Scripting { "globalThis.require = publicRequire;" "globalThis.Framework = {};" "globalThis.Core = {};" + // Capture the real require.cache before sandboxing hides it, so + // C++ can evict a resource's modules on reload. + "Object.defineProperty(globalThis, '__fw_evictModulesUnderPath', {" + " value: function(root, ci) {" + " try {" + " const cache = publicRequire.cache; if (!cache) return 0;" + " let r = String(root).replace(/\\\\/g, '/'); if (ci) r = r.toLowerCase();" + " let removed = 0;" + " for (const k of Object.keys(cache)) {" + " let nk = k.replace(/\\\\/g, '/'); if (ci) nk = nk.toLowerCase();" + " if (nk.indexOf(r) === 0) { delete cache[k]; removed++; }" + " }" + " return removed;" + " } catch (e) { return 0; }" + " }, writable: false, configurable: false, enumerable: false" + "});" + // Wrap global timers to track them per resource (attributed via + // __fw_ownerOf) so a resource's timers can be cancelled on stop. + // The real handle is returned, so clearTimeout/unref/promisify work. + "(function(){" + " const reg = new Map();" + " const _st = globalThis.setTimeout, _si = globalThis.setInterval," + " _ct = globalThis.clearTimeout, _ci = globalThis.clearInterval;" + " function ownerSet(n){ let s = reg.get(n); if (!s) { s = new Set(); reg.set(n, s); } return s; }" + " function ownerOf(fn){ try { return (typeof fn === 'function' && typeof globalThis.__fw_ownerOf === 'function') ? globalThis.__fw_ownerOf(fn) : ''; } catch (e) { return ''; } }" + " globalThis.setTimeout = function(fn){" + " const name = ownerOf(fn);" + " let handle;" + " const a = Array.prototype.slice.call(arguments);" + " if (typeof fn === 'function' && name) {" + " a[0] = function(){ const s = reg.get(name); if (s) s.delete(handle); return fn.apply(this, arguments); };" + " }" + " handle = _st.apply(this, a);" + " if (name) ownerSet(name).add(handle);" + " return handle;" + " };" + " globalThis.setInterval = function(fn){" + " const name = ownerOf(fn);" + " const handle = _si.apply(this, arguments);" + " if (name) ownerSet(name).add(handle);" + " return handle;" + " };" + " globalThis.clearTimeout = function(h){ for (const s of reg.values()) s.delete(h); return _ct(h); };" + " globalThis.clearInterval = function(h){ for (const s of reg.values()) s.delete(h); return _ci(h); };" + " try {" + " const PCS = Symbol.for('nodejs.util.promisify.custom');" + " if (_st[PCS]) globalThis.setTimeout[PCS] = _st[PCS];" + " if (_si[PCS]) globalThis.setInterval[PCS] = _si[PCS];" + " } catch (e) {}" + " Object.defineProperty(globalThis, '__fw_clearResourceTimers', {" + " value: function(name){" + " const s = reg.get(name); if (!s) return 0;" + " let n = 0;" + " for (const h of s) { try { _ct(h); _ci(h); } catch (e) {} n++; }" + " reg.delete(name); return n;" + " }, writable: false, configurable: false, enumerable: false" + " });" + "})();" "process.setUncaughtExceptionCaptureCallback((err) => {" " try {" " const msg = err instanceof Error ? (err.stack || err.message) : String(err);" @@ -320,6 +379,45 @@ namespace Framework::Scripting { return Execute(code, absPathStr); } + void NodeEngine::EvictModulesUnderPath(const std::string &rootPath) { + if (!_initialized) { + return; + } + + std::error_code ec; + std::filesystem::path absRoot = std::filesystem::weakly_canonical(rootPath, ec); + std::string rootStr = (ec ? std::filesystem::path(rootPath) : absRoot).generic_string(); + if (rootStr.empty()) { + return; + } + // Trailing slash so /res/a doesn't match /res/ab in the JS prefix check. + if (rootStr.back() != '/') { + rootStr += '/'; + } + +#ifdef _WIN32 + const char *caseInsensitive = "true"; +#else + const char *caseInsensitive = "false"; +#endif + std::string escaped = EscapeForSingleQuotedJSString(rootStr); + std::string code = + "if (typeof globalThis.__fw_evictModulesUnderPath === 'function')" + " globalThis.__fw_evictModulesUnderPath('" + escaped + "', " + caseInsensitive + ");"; + Execute(code, ""); + } + + void NodeEngine::ClearResourceTimers(const std::string &resourceName) { + if (!_initialized || resourceName.empty()) { + return; + } + std::string escaped = EscapeForSingleQuotedJSString(resourceName); + std::string code = + "if (typeof globalThis.__fw_clearResourceTimers === 'function')" + " globalThis.__fw_clearResourceTimers('" + escaped + "');"; + Execute(code, ""); + } + v8::Local NodeEngine::GetContext() const { if (_setup) { return _setup->context(); @@ -345,6 +443,35 @@ namespace Framework::Scripting { context->Global()->Set(context, key, fn).Check(); } + void NodeEngine::InstallResourceTimerTracking() { + if (!_setup) { + return; + } + v8::Local context = _setup->context(); + v8::Local data = v8::External::New(_isolate, this); + v8::Local tmpl = v8::FunctionTemplate::New( + _isolate, OnTimerOwnerLookup, data); + v8::Local fn = tmpl->GetFunction(context).ToLocalChecked(); + + v8::Local key = v8::String::NewFromUtf8( + _isolate, "__fw_ownerOf").ToLocalChecked(); + context->Global()->Set(context, key, fn).Check(); + } + + void NodeEngine::OnTimerOwnerLookup(const v8::FunctionCallbackInfo &info) { + v8::Isolate *isolate = info.GetIsolate(); + auto *engine = static_cast( + v8::Local::Cast(info.Data())->Value()); + + std::string name; + if (info.Length() > 0 && info[0]->IsFunction() && engine->GetResourceManager()) { + name = engine->GetResourceManager()->GetResourceNameFromFunction( + isolate, info[0].As()); + } + info.GetReturnValue().Set( + v8::String::NewFromUtf8(isolate, name.c_str()).ToLocalChecked()); + } + void NodeEngine::OnUncaughtError(const v8::FunctionCallbackInfo &info) { v8::Isolate *isolate = info.GetIsolate(); auto *engine = static_cast( diff --git a/code/framework/src/scripting/node_engine.h b/code/framework/src/scripting/node_engine.h index c56a064f8..c28de795e 100644 --- a/code/framework/src/scripting/node_engine.h +++ b/code/framework/src/scripting/node_engine.h @@ -86,6 +86,16 @@ namespace Framework::Scripting { void Shutdown() override; bool ExecuteFile(std::string_view filepath) override; + // Evict CommonJS modules cached under rootPath (require.cache). + void EvictModulesUnderPath(const std::string &rootPath) override; + + // Cancel timers the named resource created (via the bootstrap shim). + void ClearResourceTimers(const std::string &resourceName) override; + + // Install the privileged __fw_ownerOf(fn) helper for the timer shim. + // Call once after Init() with V8 scopes active. + void InstallResourceTimerTracking(); + /** * Process pending Node.js events (non-blocking). * Call this from game loop to process async operations. @@ -157,6 +167,9 @@ namespace Framework::Scripting { static void OnUncaughtError(const v8::FunctionCallbackInfo &info); + // __fw_ownerOf(fn): resource owning a function, from its script origin. + static void OnTimerOwnerLookup(const v8::FunctionCallbackInfo &info); + NodeEngineOptions _options; static std::unique_ptr _platform; diff --git a/code/framework/src/scripting/resource/resource_manager.cpp b/code/framework/src/scripting/resource/resource_manager.cpp index a3a57ee28..f80954984 100644 --- a/code/framework/src/scripting/resource/resource_manager.cpp +++ b/code/framework/src/scripting/resource/resource_manager.cpp @@ -12,6 +12,8 @@ #include #include +#include +#include #include #include #include @@ -39,6 +41,44 @@ namespace Framework::Scripting { } return true; } + + // Newest file mtime under a resource directory, in impl-defined clock + // ticks (monotonic for comparison). 0 if the directory can't be scanned. + int64_t ComputeResourceMTime(const std::string &path) { + std::error_code ec; + int64_t newest = 0; + std::filesystem::recursive_directory_iterator it( + path, std::filesystem::directory_options::skip_permission_denied, ec); + const std::filesystem::recursive_directory_iterator end; + if (ec) { + return newest; + } + for (; it != end; it.increment(ec)) { + if (ec) { + break; + } + // Skip large, non-hand-edited trees. + if (it->is_directory(ec) && !ec) { + const auto dirName = it->path().filename().string(); + if (dirName == "node_modules" || dirName == ".git") { + it.disable_recursion_pending(); + } + continue; + } + if (!it->is_regular_file(ec) || ec) { + continue; + } + auto t = std::filesystem::last_write_time(it->path(), ec); + if (ec) { + continue; + } + int64_t ticks = static_cast(t.time_since_epoch().count()); + if (ticks > newest) { + newest = ticks; + } + } + return newest; + } } // anonymous namespace ResourceManager::ResourceManager(Engine *jsEngine, const ResourceManagerConfig &config) @@ -364,6 +404,11 @@ namespace Framework::Scripting { // Call cleanup (removes handlers) CallResourceStop(name); + // Cancel timers the resource left running. + if (_jsEngine) { + _jsEngine->ClearResourceTimers(std::string(name)); + } + // Clear exports resource->ClearExports(); @@ -378,11 +423,28 @@ namespace Framework::Scripting { } ResourceOperationResult ResourceManager::RestartResource(std::string_view name) { + // Evict the resource's modules between stop and start: in a shared + // runtime a plain stop+start would re-run against stale caches and not + // reload code. See docs/resource_hot_reload.md. + std::string resourceRoot; + { + std::scoped_lock lock(_resourcesMutex); + auto it = _resources.find(name); + if (it != _resources.end() && it->second) { + resourceRoot = it->second->GetPath(); + } + } + auto stopResult = StopResource(name); if (!stopResult) { return stopResult; } + // Evict only after stop (engine cache contract). + if (_jsEngine && !resourceRoot.empty()) { + _jsEngine->EvictModulesUnderPath(resourceRoot); + } + return StartResource(name); } @@ -390,6 +452,165 @@ namespace Framework::Scripting { return RestartResource(name); } + ResourceOperationResult ResourceManager::RefreshResource(std::string_view name) { + std::string path; + bool wasRunning = false; + { + std::scoped_lock lock(_resourcesMutex); + auto it = _resources.find(name); + if (it == _resources.end() || !it->second) { + return ResourceOperationResult("Resource not found: " + std::string(name)); + } + path = it->second->GetPath(); + wasRunning = it->second->IsRunning(); + } + + // Stop first (cascades to dependents); remember what went down to + // restart it all afterwards. + std::vector toRestart; + if (wasRunning) { + auto stopResult = StopResource(name); + if (!stopResult) { + return stopResult; + } + toRestart = stopResult.GetValue(); + } + + if (_jsEngine && !path.empty()) { + _jsEngine->EvictModulesUnderPath(path); + } + + // Re-parse package.json so manifest edits (deps, entry points) apply. + auto reparsed = std::make_unique(path); + if (!reparsed->IsManifestValid()) { + return ResourceOperationResult("Invalid package.json in " + path + ": " + reparsed->GetErrorMessage()); + } + const std::string oldName = std::string(name); + std::string newName = reparsed->GetName(); + { + std::scoped_lock lock(_resourcesMutex); + if (newName != oldName) { + _resources.erase(oldName); + } + _resources[newName] = std::move(reparsed); + } + BuildDependencyGraph(); + + if (!wasRunning) { + return ResourceOperationResult::Ok({newName}); + } + + // Restart the resource plus the dependents that cascaded down. + std::vector affected; + for (const auto &stoppedName : toRestart) { + const std::string startName = (stoppedName == oldName) ? newName : stoppedName; + auto result = StartResource(startName); + if (result) { + const auto &a = result.GetValue(); + affected.insert(affected.end(), a.begin(), a.end()); + } + } + return ResourceOperationResult::Ok(affected); + } + + ResourceOperationResult ResourceManager::RefreshAll() { + // Remember who was running so we restart exactly them. + auto running = GetRunningResourceNames(); + + // Stop everything currently running (reverse dependency order). + StopAll(); + + // Evict + re-parse every known resource so code/manifest edits apply. + for (const auto &name : GetAllResourceNames()) { + std::string path; + { + std::scoped_lock lock(_resourcesMutex); + auto it = _resources.find(name); + if (it != _resources.end() && it->second) { + path = it->second->GetPath(); + } + } + if (path.empty()) { + continue; + } + if (_jsEngine) { + _jsEngine->EvictModulesUnderPath(path); + } + auto reparsed = std::make_unique(path); + if (!reparsed->IsManifestValid()) { + Logging::GetLogger(FRAMEWORK_INNER_SCRIPTING) + ->warn("Skipping invalid package.json in {}: {}", path, reparsed->GetErrorMessage()); + continue; + } + std::string newName = reparsed->GetName(); + std::scoped_lock lock(_resourcesMutex); + if (newName != name) { + _resources.erase(name); + } + _resources[newName] = std::move(reparsed); + } + + // Pick up brand-new resource directories, then rebuild the graph once. + RescanResources(); + BuildDependencyGraph(); + + // Restart exactly what was running before. + std::vector affected; + for (const auto &name : running) { + auto result = StartResource(name); + if (result) { + const auto &a = result.GetValue(); + affected.insert(affected.end(), a.begin(), a.end()); + } + } + return ResourceOperationResult::Ok(affected); + } + + std::vector ResourceManager::Rescan() { + auto added = RescanResources(); + BuildDependencyGraph(); + return added; + } + + std::vector ResourceManager::RescanResources() { + std::vector added; + + std::error_code ec; + std::filesystem::path resourcesDir(_config.resourcesPath); + if (!std::filesystem::exists(resourcesDir, ec)) { + return added; + } + + for (const auto &entry : std::filesystem::directory_iterator(resourcesDir, ec)) { + if (ec) { + break; + } + if (!entry.is_directory()) { + continue; + } + if (!std::filesystem::exists(entry.path() / "package.json")) { + continue; + } + + Resource probe(entry.path().string()); + if (!probe.IsManifestValid()) { + continue; + } + + bool exists; + { + std::scoped_lock lock(_resourcesMutex); + exists = _resources.contains(probe.GetName()); + } + if (!exists && DiscoverResource(entry.path().string())) { + added.push_back(probe.GetName()); + Logging::GetLogger(FRAMEWORK_INNER_SCRIPTING) + ->info("Discovered new JS resource: {}", added.back()); + } + } + return added; + } + bool ResourceManager::ExecuteResourceScript(Resource &resource, std::string &outError) { if (!_jsEngine || !_jsEngine->IsInitialized()) { outError = "JavaScript engine not initialized"; @@ -794,6 +1015,52 @@ namespace Framework::Scripting { } } + void ResourceManager::ProcessFileWatch() { + if (!_config.devMode) { + return; + } + + // Throttle polls to the configured interval. + const auto now = std::chrono::steady_clock::now(); + if (_lastFileWatchPoll.time_since_epoch().count() != 0) { + const auto elapsed = std::chrono::duration_cast( + now - _lastFileWatchPoll).count(); + if (elapsed < _config.fileWatchIntervalMs) { + return; + } + } + _lastFileWatchPoll = now; + + for (const auto &name : GetRunningResourceNames()) { + std::string path; + { + std::scoped_lock lock(_resourcesMutex); + auto it = _resources.find(name); + if (it != _resources.end() && it->second) { + path = it->second->GetPath(); + } + } + if (path.empty()) { + continue; + } + + const int64_t current = ComputeResourceMTime(path); + auto snapIt = _watchSnapshots.find(name); + if (snapIt == _watchSnapshots.end()) { + // First time we've seen this resource: seed, don't reload. + _watchSnapshots[name] = current; + continue; + } + if (current > snapIt->second) { + Logging::GetLogger(FRAMEWORK_INNER_SCRIPTING) + ->info("Detected change in resource '{}', hot-reloading", name); + // Update before reloading so we don't re-trigger on it. + snapIt->second = current; + RefreshResource(name); + } + } + } + void ResourceManager::FireOnResourceStarted(const std::string &name) { if (_onResourceStarted) { _onResourceStarted(name); diff --git a/code/framework/src/scripting/resource/resource_manager.h b/code/framework/src/scripting/resource/resource_manager.h index a9c281063..7bb76812c 100644 --- a/code/framework/src/scripting/resource/resource_manager.h +++ b/code/framework/src/scripting/resource/resource_manager.h @@ -47,6 +47,12 @@ namespace Framework::Scripting { // Whether to warn (instead of error) when a dependency is missing bool warnOnMissingDependency = false; + + // Dev mode: poll resource files and auto-reload on change (off in prod). + bool devMode = false; + + // Minimum interval between file-change polls, in milliseconds. + int fileWatchIntervalMs = 1000; }; /** @@ -143,6 +149,32 @@ namespace Framework::Scripting { */ ResourceOperationResult ReloadResource(std::string_view name); + /** + * Re-parse a resource's package.json from disk and, if it was running, + * restart it (evicting its cached modules first). Picks up both code + * and manifest edits. Resources stopped beforehand stay stopped. + * Backs the `refresh` console command. + * @param name Resource name + * @return Result with the list of affected resources + */ + ResourceOperationResult RefreshResource(std::string_view name); + + /** + * Re-scan the resources directory, re-parse every known resource, and + * restart exactly those that were running. Newly discovered resources + * are registered but left stopped. Backs the `refreshall` command. + * @return Result with the list of affected resources + */ + ResourceOperationResult RefreshAll(); + + /** + * Re-scan the resources directory for newly-added resources and rebuild + * the dependency graph, without starting or restarting anything (the + * FiveM-style `refresh`). New resources are left stopped. + * @return Names of resources newly discovered + */ + std::vector Rescan(); + // Registry Queries /** @@ -315,6 +347,10 @@ namespace Framework::Scripting { */ void ProcessScheduledRestarts(); + // Dev mode: poll running resources for file changes and hot-reload + // them. No-op unless devMode. Call from the scripting update tick. + void ProcessFileWatch(); + private: // Internal resource access (mutable) Resource *GetResourceMutable(std::string_view name); @@ -325,6 +361,10 @@ namespace Framework::Scripting { // Build dependency graph from discovered resources void BuildDependencyGraph(); + // Scan the resources directory for resources not already registered and + // register them. Returns the names added. Used by RefreshAll. + std::vector RescanResources(); + // Validate all dependencies can be satisfied bool ValidateDependencies(std::string &outError) const; @@ -374,6 +414,10 @@ namespace Framework::Scripting { std::vector _scheduledRestarts; mutable std::mutex _scheduledRestartsMutex; + // Dev-mode file watcher: resource name -> newest file mtime seen. + std::map _watchSnapshots; + std::chrono::steady_clock::time_point _lastFileWatchPoll{}; + // Events instance owned by this manager Events _events; }; diff --git a/code/framework/src/scripting/v8_engine.cpp b/code/framework/src/scripting/v8_engine.cpp index 35b4f4e4e..af88daf8d 100644 --- a/code/framework/src/scripting/v8_engine.cpp +++ b/code/framework/src/scripting/v8_engine.cpp @@ -65,6 +65,74 @@ namespace Framework::Scripting { _requireDataStore.clear(); } + void V8Engine::EvictModulesUnderPath(const std::string &rootPath) { + if (!_isolate) { + return; + } + + // Hold a Locker to dispose Global handles (engine runs in locking mode). + v8::Locker locker(_isolate); + v8::Isolate::Scope isolateScope(_isolate); + + namespace fs = std::filesystem; + std::error_code ec; + fs::path canonicalRoot = fs::weakly_canonical(fs::path(rootPath), ec); + std::string rootStr = (ec ? fs::path(rootPath) : canonicalRoot).string(); + if (rootStr.empty()) { + return; + } + + auto insideRoot = [&](const std::string &path) { + std::string p = path; + std::string r = rootStr; +#ifdef _WIN32 + // Windows filesystems are case-insensitive; normalize both sides. + std::transform(p.begin(), p.end(), p.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + std::transform(r.begin(), r.end(), r.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); +#endif + if (p.size() < r.size() || p.compare(0, r.size(), r) != 0) { + return false; + } + // Require a directory boundary so /res/a doesn't match /res/ab. + if (p.size() > r.size()) { + char next = p[r.size()]; + char sep = static_cast(fs::path::preferred_separator); + if (next != sep && next != '/' && r.back() != sep) { + return false; + } + } + return true; + }; + + for (auto it = _moduleCache.begin(); it != _moduleCache.end();) { + if (insideRoot(it->first)) { + it = _moduleCache.erase(it); + } else { + ++it; + } + } + + _requireDataStore.erase( + std::remove_if(_requireDataStore.begin(), _requireDataStore.end(), + [&](const std::unique_ptr &d) { + return d && insideRoot(d->currentDir); + }), + _requireDataStore.end()); + } + + void V8Engine::ClearResourceTimers(const std::string &resourceName) { + if (resourceName.empty()) { + return; + } + for (auto &timer : _timers) { + if (timer && timer->ownerResource == resourceName) { + timer->cancelled = true; + } + } + } + void V8Engine::Shutdown() { if (!_initialized) { return; diff --git a/code/framework/src/scripting/v8_engine.h b/code/framework/src/scripting/v8_engine.h index 88e450dda..9de450753 100644 --- a/code/framework/src/scripting/v8_engine.h +++ b/code/framework/src/scripting/v8_engine.h @@ -84,6 +84,12 @@ namespace Framework::Scripting { */ void ClearModuleCache(); + // Evict cached modules (and require() backing data) under rootPath. + void EvictModulesUnderPath(const std::string &rootPath) override; + + // Cancel all timers created by the named resource. + void ClearResourceTimers(const std::string &resourceName) override; + // Module loader internals (used by require callback) v8::MaybeLocal LoadModule(std::string_view requestedPath, std::string_view referencingDir); @@ -98,6 +104,7 @@ namespace Framework::Scripting { std::chrono::steady_clock::time_point fireTime; int intervalMs = 0; // 0 = setTimeout, >0 = setInterval bool cancelled = false; + std::string ownerResource; // resource that created the timer, for cleanup on stop }; static constexpr uint32_t kMaxTimers = 10000; diff --git a/code/framework/src/scripting/v8_engine_callbacks.h b/code/framework/src/scripting/v8_engine_callbacks.h index 778d67adc..7c7fec6d0 100644 --- a/code/framework/src/scripting/v8_engine_callbacks.h +++ b/code/framework/src/scripting/v8_engine_callbacks.h @@ -13,6 +13,7 @@ // Included only by v8_engine.cpp — not part of the public API. #include "v8_engine.h" +#include "resource/resource_manager.h" #include @@ -138,12 +139,26 @@ namespace Framework::Scripting::V8EngineCallbacks { if (delayMs < 0) delayMs = 0; } + v8::Local callbackFn = v8::Local::Cast(args[0]); + auto entry = std::make_unique(); - entry->callback.Reset(isolate, v8::Local::Cast(args[0])); + entry->callback.Reset(isolate, callbackFn); entry->fireTime = std::chrono::steady_clock::now() + std::chrono::milliseconds(delayMs); entry->intervalMs = data->isInterval ? (delayMs > 0 ? delayMs : 1) : 0; entry->cancelled = false; + // Owner for cleanup on stop: callback script origin, then context, then stack. + if (auto *mgr = data->engine->GetResourceManager()) { + std::string owner = mgr->GetResourceNameFromFunction(isolate, callbackFn); + if (owner.empty()) { + owner = mgr->GetCurrentResourceContext(); + } + if (owner.empty()) { + owner = mgr->GetResourceContextFromStack(isolate); + } + entry->ownerResource = std::move(owner); + } + // Capture extra arguments for (int i = 2; i < args.Length(); ++i) { v8::Global arg; diff --git a/docs/resource_hot_reload.md b/docs/resource_hot_reload.md new file mode 100644 index 000000000..1c8c72b85 --- /dev/null +++ b/docs/resource_hot_reload.md @@ -0,0 +1,126 @@ +# Resource Hot-Reload + +The scripting layer can reload a JavaScript resource from disk without +restarting the server, and propagate that reload to connected clients. This is +a development aid: edit a resource's `.js`, and the running resource picks up +the change. + +## Enabling + +Hot-reload triggers come in two forms: + +- **Console commands** (always available), registered on the server `Instance` + (FiveM-style verbs): + - `start ` — start a stopped resource. + - `stop ` — stop a running resource. With **no** argument, `stop` + shuts down the server (back-compat); `quit` always shuts down the server. + - `restart ` — reload a running resource's code. + - `ensure ` — start if stopped, reload if running. The canonical + reload verb. + - `refresh` — re-scan the resources directory for new resources (no restart). + - `refreshall` — re-scan and reload everything that was running. +- **Automatic file watcher** (opt-in): set `InstanceOptions.developmentMode = + true` on the server. The watcher polls running resources' files and reloads + any that change. It is **off by default** — leave it off in production. + +```cpp +Framework::Integrations::Server::InstanceOptions opts; +opts.developmentMode = true; // enable the file watcher +``` + +The watcher interval defaults to 1s (`ResourceManagerConfig::fileWatchIntervalMs`) +and skips `node_modules`/`.git`. + +## What a reload does + +`ResourceManager::RefreshResource(name)` (and `RefreshAll`): + +1. **Stops** the resource (and any dependents that cascade), firing + `resourceStop` and running `Events::CleanupResource` so the resource's + framework event listeners are removed. +2. **Cancels the resource's timers.** `Engine::ClearResourceTimers` cancels any + `setTimeout`/`setInterval` the resource created, so a shared runtime doesn't + keep firing — or duplicate — them across reloads. +3. **Evicts the resource's cached modules.** `Engine::EvictModulesUnderPath` + removes the resource's entries from the module cache (Node `require.cache` + on the server, the V8 module cache on the client), so re-execution re-reads + the edited files instead of returning stale exports. +4. **Re-parses `package.json`** from disk (manifest edits — entry points, + dependencies — take effect) and rebuilds the dependency graph. +5. **Restarts** the resource and the dependents that were stopped. + +`RefreshAll` additionally rescans the resources directory and registers +newly-added resource directories (left stopped — `start` them explicitly). + +## Client propagation + +Whenever a client resource **starts at runtime** — a reload restart, an error +auto-restart, or a newly started resource — the server notifies connected +clients so they re-sync and apply it in place: + +1. Any `StartResource` after the initial boot fires + `ResourceManager::SetOnResourceStarted`; the server `Instance` reacts (boot + starts are skipped — clients get the full list on connect). +2. The server rebuilds the asset streamer's upload list (`ClearUploads` + + `InitAssetStreamer`). This is required: MafiaNet's `DirectoryDeltaTransfer` + compares the file hashes captured when files were added, so without + rebuilding, an edited file looks up-to-date and is never re-sent. +3. The server broadcasts a `ResourceRefresh` RPC (affected resource + names/versions), for resources with a client entry point. +4. The client re-runs a **targeted** delta download (only changed files + transfer; unlike the connect-time download it does not stop all resources), + then for each flagged resource: reloads it if running, or — if the client + doesn't know it yet (newly added on the server) — discovers it from the + just-synced cache and starts it. + +Symmetrically, when a client resource **stops** at runtime (operator `stop`, +error-stop, or the transient stop within a reload), the server broadcasts a +`ResourceStop` RPC and clients stop it — no files transfer. A reload therefore +mirrors as stop-then-start on the client. + +Clients that haven't finished connecting ignore `ResourceRefresh`/`ResourceStop` +— their initial asset sync already fetches the current files. + +## Limitations + +- **CommonJS only.** Module-cache eviction covers the framework's CJS load path. + Resources loaded via dynamic ESM `import()` are not in `require.cache` and + won't be re-read on reload. +- **Raw `EventEmitter` listeners leak.** Only framework event listeners + (`on(...)`) and engine timers are cleaned up. Listeners a resource adds to its + own emitters (or `process.on`) must be removed in a `resourceStop` handler. +- **`require('timers')` bypasses timer tracking.** Timer cleanup wraps the + global `setTimeout`/`setInterval`; code importing the `timers` module + directly is not tracked. + +## Design notes (vs FiveM / MTASA) + +FiveM and MTASA run **each resource in its own script runtime** (Lua state / V8 +isolate / Mono domain). Reloading destroys and recreates that runtime, so +modules, timers, and event listeners are freed automatically — there is no +cache to evict or timer to cancel. + +This framework runs **one shared Node runtime** for all resources. Everything in +the reload sequence above (module eviction, timer cancellation, listener +cleanup) exists to compensate for that shared runtime. A consequence worth +knowing: a plain stop+start does **not** reload code here — the entry point +re-executes against stale module caches. `RestartResource` therefore evicts the +resource's modules between stop and start, so "restart" reloads code the way it +does in a per-resource-runtime engine. + +The robust long-term direction, if reload correctness becomes a recurring +concern, is **per-resource isolation** (a dedicated V8 context per resource): +reload would drop the context and rebuild it, eliminating the module-eviction, +timer-leak, and listener-leak classes at the source — the same property FiveM +and MTASA rely on. + +Where this framework is already ahead of FiveM: reloading a dependency restarts +the dependents that cascaded down (FiveM leaves them stopped — see its +`#TODO: restarting behavior of stopped dependencies at runtime`). + +## Versioning + +Client propagation is a netcode change (new RPC, both client and server) — a +**MAJOR** change requiring matching client and server builds. The server-only +pieces (eviction, `refresh` commands, watcher, timer cleanup) do not affect the +wire protocol.