diff --git a/cli/beamable.common/Runtime/Constants/Implementations/PortalExtensionConstants.cs b/cli/beamable.common/Runtime/Constants/Implementations/PortalExtensionConstants.cs index ab36c64110..e5a87d5714 100644 --- a/cli/beamable.common/Runtime/Constants/Implementations/PortalExtensionConstants.cs +++ b/cli/beamable.common/Runtime/Constants/Implementations/PortalExtensionConstants.cs @@ -7,6 +7,7 @@ public static partial class Features public static partial class PortalExtension { public const string EXTENSION_DEPENDENCIES_PROPERTY_NAME = "microserviceDependencies"; + public const string EXTENSION_BEAMABLE_PROPERTY_NAME = "beamable"; public const string EXTENSION_NAME_PROPERTY_NAME = "name"; } } diff --git a/cli/beamable.templates/templates/PortalExtensionReactApp/tsconfig.json b/cli/beamable.templates/templates/PortalExtensionReactApp/tsconfig.json index cd9de40850..9472ad83f1 100644 --- a/cli/beamable.templates/templates/PortalExtensionReactApp/tsconfig.json +++ b/cli/beamable.templates/templates/PortalExtensionReactApp/tsconfig.json @@ -14,5 +14,5 @@ "noEmit": true, "types": ["vite/client"] }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "beamable/**/*.ts"] } diff --git a/cli/cli/Commands/Portal/PortalExtensionAddDependencyCommand.cs b/cli/cli/Commands/Portal/PortalExtensionAddDependencyCommand.cs index d59e9b6084..4b5e2cc983 100644 --- a/cli/cli/Commands/Portal/PortalExtensionAddDependencyCommand.cs +++ b/cli/cli/Commands/Portal/PortalExtensionAddDependencyCommand.cs @@ -1,7 +1,9 @@ +using Beamable.Server; using cli.Services; using cli.Services.Web; using Newtonsoft.Json.Linq; using System.CommandLine; +using static Beamable.Common.Constants.Features.PortalExtension; namespace cli.Portal; @@ -62,7 +64,13 @@ public override Task Handle(PortalExtensionAddDependencyCommandArgs args) extension.MicroserviceDependencies.Add(microservice.BeamoId); - root[Beamable.Common.Constants.Features.PortalExtension.EXTENSION_DEPENDENCIES_PROPERTY_NAME] = + if (root[EXTENSION_BEAMABLE_PROPERTY_NAME] == null) + { + throw new CliException( + $"Field {EXTENSION_BEAMABLE_PROPERTY_NAME} expected in extension package.json file"); + } + + root[EXTENSION_BEAMABLE_PROPERTY_NAME][EXTENSION_DEPENDENCIES_PROPERTY_NAME] = JToken.FromObject(extension.MicroserviceDependencies); File.WriteAllText(packagePath, root.ToString(Newtonsoft.Json.Formatting.Indented)); @@ -86,12 +94,18 @@ public static void GenerateDependenciesClients(string extensionPath, BeamoLocalM foreach ((string beamId, HttpMicroserviceLocalProtocol localProtocol) in manifest.HttpMicroserviceLocalProtocols) { - if (!dependencies.Contains(beamId) || localProtocol.OpenApiDoc == null) + if (!dependencies.Contains(beamId)) + { + continue; + } + + if (localProtocol.OpenApiDoc == null) { + Log.Warning($"Client generation for {beamId} is being skipped because there is no API doc. Try running {beamId} once to make it available and try again."); continue; } - var generator = new WebClientCodeGenerator(localProtocol.OpenApiDoc, "js"); + var generator = new WebClientCodeGenerator(localProtocol.OpenApiDoc, "ts"); var clientsOutputDirectory = Path.Combine(extensionPath, "beamable/clients"); generator.GenerateClientCode(clientsOutputDirectory); } @@ -104,11 +118,10 @@ public static List GetDependenciesFromPath(string extensionPath) JObject root = JObject.Parse(jsonContent); - JToken deps = root[Beamable.Common.Constants.Features.PortalExtension.EXTENSION_DEPENDENCIES_PROPERTY_NAME]; + JToken deps = root[EXTENSION_BEAMABLE_PROPERTY_NAME][EXTENSION_DEPENDENCIES_PROPERTY_NAME]; if (deps is { Type: JTokenType.Array }) { - // Convert the JArray directly to a List return deps.ToObject>(); } diff --git a/cli/cli/Services/PortalExtension/PortalExtensionDiscoveryService.cs b/cli/cli/Services/PortalExtension/PortalExtensionDiscoveryService.cs index e13eb22c51..a63027a9c0 100644 --- a/cli/cli/Services/PortalExtension/PortalExtensionDiscoveryService.cs +++ b/cli/cli/Services/PortalExtension/PortalExtensionDiscoveryService.cs @@ -21,7 +21,8 @@ public class ExtensionBuildMetaData public class PortalExtensionDiscoveryService : Microservice { - [ClientCallable] + // Note: this creates source code leaking, even tho the service has an ever changing Guid + [Callable] public ExtensionBuildData RequestPortalExtensionData(string currentHash = "") { var observer = Provider.GetService(); diff --git a/cli/cli/Services/Web/WebClientCodeGenerator.cs b/cli/cli/Services/Web/WebClientCodeGenerator.cs index d2baa4aaf7..bdded5e11c 100644 --- a/cli/cli/Services/Web/WebClientCodeGenerator.cs +++ b/cli/cli/Services/Web/WebClientCodeGenerator.cs @@ -1,4 +1,4 @@ -using Beamable.Server; +using Beamable.Server; using cli.Services.Web.CodeGen; using cli.Services.Web.Helpers; using Microsoft.OpenApi.Any; @@ -74,7 +74,7 @@ public WebClientCodeGenerator(OpenApiDocument document, string langType) if (_schemas.Count > 0) { var tsBeamSchemaImport = - new TsImport("beamable-sdk/schema", defaultImport: "* as Schemas", typeImportOnly: true); + new TsImport("@beamable/sdk/schema", defaultImport: "* as Schemas", typeImportOnly: true); _clientFile.AddImport(tsBeamSchemaImport); } @@ -283,7 +283,7 @@ private static void GenerateBeamSDKModuleDeclaration(TsFile clientFile, TsClass var className = tsClass.Name; var camelCaseClassName = StringHelper.ToCamelCaseIdentifier(className); var serviceName = tsClass.Name.Replace("Client", string.Empty); - var tsModule = new TsModule("beamable-sdk"); + var tsModule = new TsModule("@beamable/sdk"); var tsInterface = new TsInterface("BeamBase"); var tsProperty = new TsProperty(camelCaseClassName, TsType.Of(className)); var tsComment = new TsComment( diff --git a/client/Packages/com.beamable/Editor/BeamCli/BeamCliUtil.cs b/client/Packages/com.beamable/Editor/BeamCli/BeamCliUtil.cs index d93f72e198..547a3b8b71 100644 --- a/client/Packages/com.beamable/Editor/BeamCli/BeamCliUtil.cs +++ b/client/Packages/com.beamable/Editor/BeamCli/BeamCliUtil.cs @@ -135,34 +135,30 @@ static bool InstallTool() return true; } - var proc = new Process(); var installCommand = $"tool install Beamable.Tools --create-manifest-if-needed --allow-downgrade"; - if (Application.isBatchMode) + // Only add the local folder feed for the local dev version (0.0.123.*). For a published + // version, installing from a local folder feed extracts into the global packages folder + // without copying the .nupkg, which then crashes finalization; let it resolve from nuget.org. + if (Application.isBatchMode && BeamableEnvironment.NugetPackageVersion.ToString().StartsWith("0.0.123")) { installCommand += " --add-source BeamableNugetSource "; } - if (!BeamableEnvironment.NugetPackageVersion.ToString().Equals("0.0.123")) + // Lowercase the version: dotnet tool install builds the global-packages path from the raw --version string, so an uppercase pre-release label (7.2.0-PREVIEW.RC1) mismatches NuGet's lowercased cache folder on case-sensitive (Linux) filesystems. + var versionArg = BeamableEnvironment.NugetPackageVersion.ToString().ToLowerInvariant(); + if (!versionArg.Equals("0.0.123")) { - installCommand += $" --version {BeamableEnvironment.NugetPackageVersion}"; + installCommand += $" --version {versionArg}"; } else { - installCommand += $" --version {BeamableEnvironment.NugetPackageVersion}.*"; + installCommand += $" --version {versionArg}.*"; } - proc.StartInfo = new ProcessStartInfo - { - FileName = "dotnet", - WorkingDirectory = Path.GetFullPath("."), - Arguments = installCommand, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - proc.StartInfo.Environment.Add("DOTNET_CLI_UI_LANGUAGE", "en"); + var proc = NewInstallProcess(); + Debug.Log($"[BeamCLI] Installing Beam CLI version [{BeamableEnvironment.NugetPackageVersion}]. " + + $"Command: [dotnet {installCommand}]. A first-time install with a cold NuGet cache can be slow while packages download."); TryRunWithTimeout(1); const string errorGuide = @@ -170,6 +166,22 @@ static bool InstallTool() var output = proc.StandardOutput.ReadToEnd(); var error = proc.StandardError.ReadToEnd(); + + // On some SDKs the local tool installer caches the package without its .nupkg and then crashes finalizing it. Only when we see that exact failure, pre-download the package via a normal restore (which keeps the .nupkg) and retry once, so unaffected SDKs never pay the cost. + var installLog = (output ?? string.Empty) + (error ?? string.Empty); + if (proc.ExitCode != 0 && installLog.Contains(".nupkg") && + (installLog.Contains("Could not find file") || installLog.Contains("FileNotFoundException"))) + { + Debug.LogWarning("[BeamCLI] Install failed with a missing .nupkg in the global packages cache (known local tool-install issue). Healing the cache and pre-downloading the package, then retrying once."); + HealCorruptGlobalPackagesEntry(); + WarmGlobalPackagesCache(); + + proc = NewInstallProcess(); + TryRunWithTimeout(1); + output = proc.StandardOutput.ReadToEnd(); + error = proc.StandardError.ReadToEnd(); + } + if (!string.IsNullOrWhiteSpace(error) || proc.ExitCode != 0) { StringBuilder message = new StringBuilder("Unable to install BeamCLI"); @@ -180,6 +192,7 @@ static bool InstallTool() message.AppendLine($"Error: {error}"); message.Append(errorGuide); + Debug.Log("Failed Install Command: " + installCommand); Debug.LogError(message.ToString()); var tryAgainButtonInfo = new OptionDialogWindow.ButtonInfo() { @@ -218,16 +231,55 @@ static bool InstallTool() return proc.ExitCode == 0; + Process NewInstallProcess() + { + var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = Path.GetFullPath("."), + Arguments = installCommand, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + } + }; + p.StartInfo.Environment.Add("DOTNET_CLI_UI_LANGUAGE", "en"); + return p; + } + bool TryRunWithTimeout(int currentTry) { + var timeoutMs = 30 * 1000 * currentTry; + Debug.Log($"[BeamCLI] Install attempt {currentTry} starting (timeout {timeoutMs / 1000}s)..."); + var stopwatch = Stopwatch.StartNew(); proc.Start(); - if (proc.WaitForExit(10 * 1000 * currentTry)) + if (proc.WaitForExit(timeoutMs)) { + Debug.Log($"[BeamCLI] Install attempt {currentTry} exited with code {proc.ExitCode} after {stopwatch.Elapsed.TotalSeconds:0.#}s."); return true; } + // Kill the still-running process before retrying; a concurrent retry corrupts the NuGet cache (a version folder left without its .nupkg), which then fails every later restore. Debug.LogError( - "dotnet tool install command did not finish fast enough; timed out. Trying again with longer timeout"); + $"[BeamCLI] Install attempt {currentTry} did not finish within {timeoutMs / 1000}s. " + + "Killing the dotnet process before retrying to avoid corrupting the NuGet cache."); + try + { + if (!proc.HasExited) + { + proc.Kill(); + } + proc.WaitForExit(); + Debug.Log($"[BeamCLI] Killed the timed-out dotnet process from attempt {currentTry}."); + } + catch (Exception killEx) + { + Debug.LogWarning($"[BeamCLI] Failed to kill the timed-out dotnet install process: {killEx.Message}"); + } + const int maxRetries = 5; if (currentTry > maxRetries) { @@ -245,9 +297,104 @@ bool TryRunWithTimeout(int currentTry) return false; } + Debug.Log($"[BeamCLI] Retrying install with a longer timeout (attempt {currentTry + 1})."); return TryRunWithTimeout(++currentTry); } + + void WarmGlobalPackagesCache() + { + var version = BeamableEnvironment.NugetPackageVersion.ToString(); + // The local dev flow installs 0.0.123.* from the local folder feed, which already + // lands the .nupkg in the cache, so there is nothing to warm (and it is not on nuget.org). + if (version.StartsWith("0.0.123")) + { + return; + } + + try + { + var warmDir = Path.Combine(Path.GetFullPath("Library/BeamableEditor"), "cli-cache-warm"); + Directory.CreateDirectory(warmDir); + var csprojPath = Path.Combine(warmDir, "warm.csproj"); + File.WriteAllText(csprojPath, + "\n" + + " net8.0\n" + + $" \n" + + "\n"); + + var warm = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = warmDir, + Arguments = $"restore \"{csprojPath}\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + } + }; + warm.StartInfo.Environment.Add("DOTNET_CLI_UI_LANGUAGE", "en"); + Debug.Log($"[BeamCLI] Warming the NuGet cache with Beamable.Tools [{version}] via PackageDownload restore..."); + warm.Start(); + if (!warm.WaitForExit(120 * 1000)) + { + try { if (!warm.HasExited) warm.Kill(); } + catch { /* ignore */ } + Debug.LogWarning("[BeamCLI] Cache-warm restore timed out; continuing to the install anyway."); + return; + } + + var output = warm.StandardOutput.ReadToEnd(); + var error = warm.StandardError.ReadToEnd(); + Debug.Log($"[BeamCLI] Cache-warm restore exited with code {warm.ExitCode}. {output} {error}"); + } + catch (Exception warmEx) + { + Debug.LogWarning($"[BeamCLI] Cache-warm failed (continuing to the install anyway): {warmEx.Message}"); + } + } + + void HealCorruptGlobalPackagesEntry() + { + try + { + var packageId = "beamable.tools"; + var version = BeamableEnvironment.NugetPackageVersion.ToString().ToLowerInvariant(); + var globalPackages = System.Environment.GetEnvironmentVariable("NUGET_PACKAGES"); + if (string.IsNullOrEmpty(globalPackages)) + { + var home = System.Environment.GetEnvironmentVariable("HOME"); + if (string.IsNullOrEmpty(home)) + { + home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile); + } + globalPackages = Path.Combine(home, ".nuget", "packages"); + } + + var packageDir = Path.Combine(globalPackages, packageId, version); + if (!Directory.Exists(packageDir)) + { + Debug.Log($"[BeamCLI] No pre-existing global-packages entry at [{packageDir}]."); + return; + } + + var nupkgCount = Directory.GetFiles(packageDir, "*.nupkg").Length; + var totalFiles = Directory.GetFiles(packageDir, "*", SearchOption.AllDirectories).Length; + Debug.Log($"[BeamCLI] Found global-packages entry [{packageDir}]: {nupkgCount} .nupkg, {totalFiles} total files."); + if (nupkgCount == 0) + { + Debug.LogWarning($"[BeamCLI] Entry [{packageDir}] is missing its .nupkg (corrupt). Deleting it so the install can re-download a complete copy."); + Directory.Delete(packageDir, true); + } + } + catch (Exception healEx) + { + Debug.LogWarning($"[BeamCLI] Auto-heal of the global-packages cache failed: {healEx.Message}"); + } + } } } } diff --git a/dev-web.sh b/dev-web.sh index 51ee41fc86..5b77fa5852 100755 --- a/dev-web.sh +++ b/dev-web.sh @@ -77,8 +77,8 @@ echo " [cmd] pnpm version $VERSION --no-git-tag-version" pnpm version "$VERSION" --no-git-tag-version echo " [cmd] pnpm build" pnpm build -echo " [cmd] pnpm publish --registry $REGISTRY --no-git-checks" -pnpm publish --registry "$REGISTRY" --no-git-checks +echo " [cmd] pnpm publish --registry $REGISTRY --no-git-checks --tag local" +pnpm publish --registry "$REGISTRY" --no-git-checks --tag local cp package.json.devbak package.json && rm package.json.devbak SDK_BACKUP=false @@ -119,8 +119,8 @@ pnpm version "$VERSION" --no-git-tag-version echo " [cmd] pnpm build" pnpm build -echo " [cmd] pnpm publish --registry $REGISTRY --no-git-checks" -pnpm publish --registry "$REGISTRY" --no-git-checks +echo " [cmd] pnpm publish --registry $REGISTRY --no-git-checks --tag local" +pnpm publish --registry "$REGISTRY" --no-git-checks --tag local cp package.json.devbak package.json && rm package.json.devbak TOOLKIT_BACKUP=false diff --git a/template_updater/update-template.cs b/template_updater/update-template.cs index facadd084e..b2e3527713 100644 --- a/template_updater/update-template.cs +++ b/template_updater/update-template.cs @@ -5,7 +5,7 @@ using System.Text.RegularExpressions; // ── Args ─────────────────────────────────────────────────────────────────────── -var validTemplates = new[] { "BeamService", "BeamStorage", "PortalExtensionApp" }; +var validTemplates = new[] { "BeamService", "BeamStorage", "PortalExtensionApp", "PortalExtensionReactApp" }; if (args.Length < 1) { Usage(); return; } @@ -85,6 +85,7 @@ break; } case "PortalExtensionApp": + case "PortalExtensionReactApp": { foreach (string f in EnumerateFiles(searchPath, "package.json", ignorePaths, [templatesDir])) { diff --git a/web/src/services/ContentService.ts b/web/src/services/ContentService.ts index e579f419ee..2861591547 100644 --- a/web/src/services/ContentService.ts +++ b/web/src/services/ContentService.ts @@ -398,49 +398,66 @@ export class ContentService const contentStorage = await this.contentStoragePromise; const checksumKey = this.getManifestChecksumKey(manifestId); - // Fetch the locally stored checksum and the latest checksum from the API in parallel. - const [cachedChecksum, { body: latestChecksum }] = await Promise.all([ - contentStorage.get(checksumKey), - contentGetManifestChecksumBasic( - this.requester, - manifestId, - undefined, - this.accountId, - ), - ]); - - // Compare the checksums to see if an update is needed. - if (cachedChecksum?.checksum === latestChecksum.checksum) { - // Checksums match. The local version is up-to-date. - const entriesKey = this.getManifestEntriesKey(manifestId); - const manifestEntries = - await contentStorage.get(entriesKey); - - if (!manifestEntries) { - // Manifest entries not found in storage. Fetch them from the API. + let latestChecksum; + try { + // Fetch the locally stored checksum and the latest checksum from the API in parallel. + const [cachedChecksum, checksumResponse] = await Promise.all([ + contentStorage.get(checksumKey), + contentGetManifestChecksumBasic( + this.requester, + manifestId, + undefined, + this.accountId, + ), + ]); + latestChecksum = checksumResponse.body; + + // Compare the checksums to see if an update is needed. + if (cachedChecksum?.checksum === latestChecksum.checksum) { + // Checksums match. The local version is up-to-date. + const entriesKey = this.getManifestEntriesKey(manifestId); + const manifestEntries = + await contentStorage.get(entriesKey); + + if (!manifestEntries) { + // Manifest entries not found in storage. Fetch them from the API. + await this.fetchAndCacheManifestEntries(manifestId, { + id: latestChecksum.id, + checksum: latestChecksum.checksum, + created: latestChecksum.createdAt, + uid: latestChecksum.uid, + }); + return; + } + + // Manifest entries found in storage. Load them into the in-memory cache. + ContentService._manifestChecksumsCache[manifestId] = cachedChecksum; + ContentService._manifestEntriesCache[manifestId] = manifestEntries; + } else { + // Checksums differ. Fetch the manifest entries from the API and cache it. await this.fetchAndCacheManifestEntries(manifestId, { id: latestChecksum.id, checksum: latestChecksum.checksum, created: latestChecksum.createdAt, uid: latestChecksum.uid, }); + } + } catch (err) { + if (ContentService.isManifestNotFound(err)) { + // Realm has no published content — treat as empty manifest. + ContentService._manifestEntriesCache[manifestId] = []; return; } - - // Manifest entries found in storage. Load them into the in-memory cache. - ContentService._manifestChecksumsCache[manifestId] = cachedChecksum; - ContentService._manifestEntriesCache[manifestId] = manifestEntries; - } else { - // Checksums differ. Fetch the manifest entries from the API and cache it. - await this.fetchAndCacheManifestEntries(manifestId, { - id: latestChecksum.id, - checksum: latestChecksum.checksum, - created: latestChecksum.createdAt, - uid: latestChecksum.uid, - }); + throw err; } } + private static isManifestNotFound(err: unknown): boolean { + if (!BeamError.is(err)) return false; + const status = (err.context?.response as { status?: number })?.status; + return status === 404; + } + /** Fetches a content manifest entries from the API and caches it locally. */ private async fetchAndCacheManifestEntries( manifestId: string,