commit 5adc33c769f2538f9d957b705e6f30c1f22ff9ec Author: Tobias Klika Date: Thu Nov 10 17:02:40 2022 +0100 hi diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7aba641 --- /dev/null +++ b/.gitignore @@ -0,0 +1,355 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- Backup*.rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# configuration +Web.Release.config +Web.Debug.config + +# umbraco +*/log.txt +Install/ +*/uSync.Archive/ +Gugg/wwwroot/Media/ +Gugg/wwwroot/Uploads/ + +# project +package-lock.json +deps/* +cache/* \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..86c9463 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,24 @@ +Copyright (c) 2022 brothers Klika OG + +MIT License + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a54f8b --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +**ViteProxy** is a proxy for vite projects within ASP.NET Core. + +## Introduction + +This project is meant to be used within server-side applications and not within SPAs (single-page applications). +
+Without ViteProxy you would have to start your server application (e.g. dotnet run) and additionally the vite dev server (e.g. npm run dev). The proxy is registered as a hosted service and started in tandem with the server application. + +## Usage + +### 1. Register service + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddViteProxy(); +``` + +### 2. Deliver additional static files + +```csharp +var app = builder.Build(); + +app.UseStaticFiles(); +app.UseViteStaticFiles(); +``` + +### 3. Configure vite.config + +```js +export default { + ..., + server: { + port: process.env.PORT || 2341, + cors: true + }, + ... +}; +``` + +## License + +[MIT License](https://github.com/ceee/PocketSharp/blob/master/LICENSE-MIT) diff --git a/ViteProxy.sln b/ViteProxy.sln new file mode 100644 index 0000000..f819e89 --- /dev/null +++ b/ViteProxy.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33103.184 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ViteProxy", "src\ViteProxy.csproj", "{3E986E84-FEFF-4035-82B7-D4E9254017A3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3E986E84-FEFF-4035-82B7-D4E9254017A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E986E84-FEFF-4035-82B7-D4E9254017A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E986E84-FEFF-4035-82B7-D4E9254017A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E986E84-FEFF-4035-82B7-D4E9254017A3}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D88C335C-52D9-4F5F-A3D5-F712E358AC86} + EndGlobalSection +EndGlobal diff --git a/src/EventedStreamReader.cs b/src/EventedStreamReader.cs new file mode 100644 index 0000000..2ed3385 --- /dev/null +++ b/src/EventedStreamReader.cs @@ -0,0 +1,190 @@ +// Copyright (c) .NET Foundation Contributors. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Original Source: https://github.com/aspnet/JavaScriptServices + +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +namespace ViteProxy; + +/// +/// Wraps a to expose an evented API, issuing notifications +/// when the stream emits partial lines, completed lines, or finally closes. +/// +internal class EventedStreamReader +{ + public delegate void OnReceivedChunkHandler(ArraySegment chunk); + public delegate void OnReceivedLineHandler(string line); + public delegate void OnStreamClosedHandler(); + + public event OnReceivedChunkHandler OnReceivedChunk; + public event OnReceivedLineHandler OnReceivedLine; + public event OnStreamClosedHandler OnStreamClosed; + + private readonly StreamReader _streamReader; + private readonly StringBuilder _linesBuffer; + + public EventedStreamReader(StreamReader streamReader) + { + _streamReader = streamReader ?? throw new ArgumentNullException(nameof(streamReader)); + _linesBuffer = new StringBuilder(); + Task.Factory.StartNew(Run); + } + + public Task WaitForMatch(Regex regex) + { + var tcs = new TaskCompletionSource(); + var completionLock = new object(); + + OnReceivedLineHandler onReceivedLineHandler = null; + OnStreamClosedHandler onStreamClosedHandler = null; + + void ResolveIfStillPending(Action applyResolution) + { + lock (completionLock) + { + if (!tcs.Task.IsCompleted) + { + OnReceivedLine -= onReceivedLineHandler; + OnStreamClosed -= onStreamClosedHandler; + applyResolution(); + } + } + } + + onReceivedLineHandler = line => + { + var match = regex.Match(line); + if (match.Success) + { + ResolveIfStillPending(() => tcs.SetResult(match)); + } + }; + + onStreamClosedHandler = () => + { + ResolveIfStillPending(() => tcs.SetException(new EndOfStreamException())); + }; + + OnReceivedLine += onReceivedLineHandler; + OnStreamClosed += onStreamClosedHandler; + + return tcs.Task; + } + + + public Task WaitForFinish() + { + var tcs = new TaskCompletionSource(); + var completionLock = new object(); + + OnStreamClosedHandler onStreamClosedHandler = null; + + void ResolveIfStillPending(Action applyResolution) + { + lock (completionLock) + { + if (!tcs.Task.IsCompleted) + { + OnStreamClosed -= onStreamClosedHandler; + applyResolution(); + } + } + } + + onStreamClosedHandler = () => + { + ResolveIfStillPending(() => tcs.SetResult()); + }; + + OnStreamClosed += onStreamClosedHandler; + + return tcs.Task; + } + + + private async Task Run() + { + var buf = new char[8 * 1024]; + while (true) + { + var chunkLength = await _streamReader.ReadAsync(buf, 0, buf.Length); + if (chunkLength == 0) + { + OnClosed(); + break; + } + + OnChunk(new ArraySegment(buf, 0, chunkLength)); + + int lineBreakPos = -1; + int startPos = 0; + + // get all the newlines + while ((lineBreakPos = Array.IndexOf(buf, '\n', startPos, chunkLength - startPos)) >= 0 && startPos < chunkLength) + { + var length = lineBreakPos - startPos; + _linesBuffer.Append(buf, startPos, length); + OnCompleteLine(_linesBuffer.ToString()); + _linesBuffer.Clear(); + startPos = lineBreakPos + 1; + } + + // get the rest + if (lineBreakPos < 0 && startPos < chunkLength) + { + _linesBuffer.Append(buf, startPos, chunkLength - startPos); + } + } + } + + private void OnChunk(ArraySegment chunk) + { + var dlg = OnReceivedChunk; + dlg?.Invoke(chunk); + } + + private void OnCompleteLine(string line) + { + var dlg = OnReceivedLine; + dlg?.Invoke(line); + } + + private void OnClosed() + { + var dlg = OnStreamClosed; + dlg?.Invoke(); + } +} + +/// +/// Captures the completed-line notifications from a , +/// combining the data into a single . +/// +internal class EventedStreamStringReader : IDisposable +{ + private EventedStreamReader _eventedStreamReader; + private bool _isDisposed; + private StringBuilder _stringBuilder = new StringBuilder(); + + public EventedStreamStringReader(EventedStreamReader eventedStreamReader) + { + _eventedStreamReader = eventedStreamReader + ?? throw new ArgumentNullException(nameof(eventedStreamReader)); + _eventedStreamReader.OnReceivedLine += OnReceivedLine; + } + + public string ReadAsString() => _stringBuilder.ToString(); + + private void OnReceivedLine(string line) => _stringBuilder.AppendLine(line); + + public void Dispose() + { + if (!_isDisposed) + { + _eventedStreamReader.OnReceivedLine -= OnReceivedLine; + _isDisposed = true; + } + } +} diff --git a/src/PidUtils.cs b/src/PidUtils.cs new file mode 100644 index 0000000..871ed6a --- /dev/null +++ b/src/PidUtils.cs @@ -0,0 +1,201 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; + +namespace ViteProxy; + +public static class PidUtils +{ + const string ssPidRegex = @"(?:^|"",|"",pid=)(\d+)"; + const string portRegex = @"[^]*[.:](\\d+)$"; + + public static int GetPortPid(ushort port) + { + int pidOut = -1; + + int portColumn = 1; // windows + int pidColumn = 4; // windows + string pidRegex = null; + + List results = null; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + results = RunProcessReturnOutputSplit("netstat", "-anv -p tcp"); + results.AddRange(RunProcessReturnOutputSplit("netstat", "-anv -p udp")); + portColumn = 3; + pidColumn = 8; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + results = RunProcessReturnOutputSplit("ss", "-tunlp"); + portColumn = 4; + pidColumn = 6; + pidRegex = ssPidRegex; + } + else + { + results = RunProcessReturnOutputSplit("netstat", "-ano"); + } + + + foreach (var line in results) + { + if (line.Length <= portColumn || line.Length <= pidColumn) continue; + try + { + // split lines to words + var portMatch = Regex.Match(line[portColumn], $"[.:]({port})"); + if (portMatch.Success) + { + int portValue = int.Parse(portMatch.Groups[1].Value); + + if (pidRegex == null) + { + pidOut = int.Parse(line[pidColumn]); + return pidOut; + } + else + { + var pidMatch = Regex.Match(line[pidColumn], pidRegex); + if (pidMatch.Success) + { + pidOut = int.Parse(pidMatch.Groups[1].Value); + } + } + } + } + catch (Exception) + { + // ignore line error + } + } + + return pidOut; + } + + private static List RunProcessReturnOutputSplit(string fileName, string arguments) + { + string result = RunProcessReturnOutput(fileName, arguments); + if (result == null) return new List(); + + string[] lines = result.Split(new string[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries); + var lineWords = new List(); + foreach (var line in lines) + { + lineWords.Add(line.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)); + } + return lineWords; + } + + private static string RunProcessReturnOutput(string fileName, string arguments) + { + Process process = null; + try + { + var si = new ProcessStartInfo(fileName, arguments) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + process = Process.Start(si); + var stdOutT = process.StandardOutput.ReadToEndAsync(); + var stdErrorT = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(10000)) + { + try { process?.Kill(); } catch { } + } + + if (Task.WaitAll(new Task[] { stdOutT, stdErrorT }, 10000)) + { + // if success, return data + return (stdOutT.Result + Environment.NewLine + stdErrorT.Result).Trim(); + } + return null; + } + catch (Exception) + { + return null; + } + finally + { + process?.Close(); + } + } + + public static bool Kill(string process, bool ignoreCase = true, bool force = false, bool tree = true) + { + var args = new List(); + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + if (force) { args.Add("-9"); } + if (ignoreCase) { args.Add("-i"); } + args.Add(process); + RunProcessReturnOutput("pkill", string.Join(" ", args)); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + if (force) { args.Add("-9"); } + if (ignoreCase) { args.Add("-I"); } + args.Add(process); + RunProcessReturnOutput("killall", string.Join(" ", args)); + } + else + { + if (force) { args.Add("/f"); } + if (tree) { args.Add("/T"); } + args.Add("/im"); + args.Add(process); + return RunProcessReturnOutput("taskkill", string.Join(" ", args))?.StartsWith("SUCCESS") ?? false; + } + return true; + } + catch (Exception) + { + + } + return false; + } + + public static bool Kill(int pid, bool force = false, bool tree = true) + { + if (pid == -1) { return false; } + + var args = new List(); + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + if (force) { args.Add("-9"); } + args.Add(pid.ToString()); + RunProcessReturnOutput("kill", string.Join(" ", args)); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + if (force) { args.Add("-9"); } + args.Add(pid.ToString()); + RunProcessReturnOutput("kill", string.Join(" ", args)); + } + else + { + if (force) { args.Add("/f"); } + if (tree) { args.Add("/T"); } + args.Add("/PID"); + args.Add(pid.ToString()); + return RunProcessReturnOutput("taskkill", string.Join(" ", args))?.StartsWith("SUCCESS") ?? false; + } + return true; + } + catch (Exception) + { + } + return false; + } + + public static bool KillPort(ushort port, bool force = false, bool tree = true) => Kill(GetPortPid(port), force: force, tree: tree); + +} diff --git a/src/ProcessProxy.cs b/src/ProcessProxy.cs new file mode 100644 index 0000000..8a11737 --- /dev/null +++ b/src/ProcessProxy.cs @@ -0,0 +1,248 @@ +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; + +namespace ViteProxy; + +public class ProcessProxy +{ + string workingDirectory; + string script; + Action onProcessConfigure = null; + Action captureLog = null; + bool isWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + Dictionary envVars = new(); + HashSet arguments = new(); + bool forwardLog = false; + + Process process = null; + EventedStreamReader stdOut; + EventedStreamReader stdErr; + + + + + public ProcessProxy(string workingDirectory, string script, bool forwardLog = false) + { + this.workingDirectory = workingDirectory; + this.script = script; + this.forwardLog = forwardLog; + } + + + /// + /// Adds an environment variable + /// + public ProcessProxy EnvVar(string key, string value) + { + envVars.Add(key, value); + return this; + } + + + /// + /// Adds an argument + /// + public ProcessProxy Argument(string argument) + { + arguments.Add(argument); + return this; + } + + + /// + /// Configures the process start info + /// + public ProcessProxy Configure(Action onProcessConfigure) + { + this.onProcessConfigure = onProcessConfigure; + return this; + } + + + /// + /// Capture the log instead of outputting it to the console + /// + public ProcessProxy Capture(Action action) + { + this.captureLog = action; + return this; + } + + + /// + /// Run the script and wait for completion. + /// This is only recommended for scripts which finish automatically and have no user interaction. + /// + public async Task ExecuteAsync(TimeSpan timeout = default) + { + StartProcess(); + + using var stdErrReader = new EventedStreamStringReader(stdErr); + try + { + // TODO implement timeout + // https://stackoverflow.com/questions/18760252/timeout-an-async-method-implemented-with-taskcompletionsource + await stdOut.WaitForFinish(); + } + catch (EndOfStreamException ex) + { + throw new InvalidOperationException($"The script '{script}' exited without indicating that the server was listening for requests.\nThe error output was: " + $"{stdErrReader.ReadAsString()}", ex); + } + } + + + /// + /// Run the script and return as soon as a condition is met. + /// The script will not cancel and will continue to run as a sub-process as long as it does not auto-close. + /// + public async Task RunAsync(string startupCondition, TimeSpan startupTimeout = default) + { + StartProcess(); + + using var stdErrReader = new EventedStreamStringReader(stdErr); + try + { + await stdOut.WaitForMatch(new Regex(startupCondition, RegexOptions.IgnoreCase, startupTimeout == default ? TimeSpan.FromMinutes(5) : startupTimeout)); + } + catch (EndOfStreamException ex) + { + throw new InvalidOperationException($"The script '{script}' exited without indicating that the server was listening for requests.\nThe error output was: " + $"{stdErrReader.ReadAsString()}", ex); + } + } + + + public void Exit() + { + try { process?.Kill(); } catch { } + try { process?.WaitForExit(); } catch { } + + AppDomain.CurrentDomain.DomainUnload -= UnloadHandler; + AppDomain.CurrentDomain.ProcessExit -= UnloadHandler; + AppDomain.CurrentDomain.UnhandledException -= UnloadHandler; + } + + + /// + /// Run + /// + Process StartProcess() + { + string executable = script; + + StringBuilder command = new(); + command.Append(script); + command.Append(' '); + foreach (string arg in arguments) + { + command.Append(arg); + } + string argumentList = command.ToString(); + + if (isWindows) + { + argumentList = $"/c {argumentList}"; + executable = "cmd"; + } + + ProcessStartInfo startInfo = new(executable) + { + Arguments = argumentList, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = workingDirectory + }; + + foreach (var envVar in envVars) + { + startInfo.Environment[envVar.Key] = envVar.Value; + } + + onProcessConfigure?.Invoke(startInfo); + + try + { + process = Process.Start(startInfo); + process.EnableRaisingEvents = true; + } + catch (Exception ex) + { + var message = $"Failed to start '{startInfo.FileName}'. To resolve this:.\n\n" + + $"[1] Ensure that '{startInfo.FileName}' is installed and can be found in one of the PATH directories.\n" + + $" Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }\n" + + " Make sure the executable is in one of those directories, or update your PATH.\n\n" + + "[2] See the InnerException for further details of the cause."; + throw new InvalidOperationException(message, ex); + } + + AttachLogger(); + + AppDomain.CurrentDomain.DomainUnload += UnloadHandler; + AppDomain.CurrentDomain.ProcessExit += UnloadHandler; + AppDomain.CurrentDomain.UnhandledException += UnloadHandler; + + return process; + } + + + void UnloadHandler(object sender, EventArgs e) + { + Exit(); + } + + + void AttachLogger(bool isStream = false) + { + stdOut = new EventedStreamReader(process.StandardOutput); + stdErr = new EventedStreamReader(process.StandardError); + + stdOut.OnReceivedLine += line => WriteToLog(line); + stdOut.OnReceivedChunk += chunk => WriteToLog(chunk); + stdErr.OnReceivedLine += line => WriteToLog(line, true); + stdErr.OnReceivedChunk += chunk => WriteToLog(chunk, true); + } + + + void WriteToLog(string line, bool isError = false) + { + if (String.IsNullOrWhiteSpace(line)) + { + return; + } + + line = line.StartsWith("") ? line.Substring(3) : line; + + if (captureLog != null) + { + captureLog(line, isError); + } + if (forwardLog) + { + (isError ? Console.Error : Console.Out).WriteLine(line); + } + } + + + void WriteToLog(ArraySegment chunk, bool isError = false) + { + bool containsNewline = Array.IndexOf(chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0; + + if (containsNewline) + { + return; + } + + if (captureLog != null) + { + captureLog(new String(chunk.Array, chunk.Offset, chunk.Count), isError); + } + if (forwardLog) + { + (isError ? Console.Error : Console.Out).Write(chunk.Array, chunk.Offset, chunk.Count); + } + } +} diff --git a/src/ViteProxy.csproj b/src/ViteProxy.csproj new file mode 100644 index 0000000..59197d5 --- /dev/null +++ b/src/ViteProxy.csproj @@ -0,0 +1,12 @@ + + + + net6.0 + enable + + + + + + + \ No newline at end of file diff --git a/src/ViteProxyApplicationBuilderExtensions.cs b/src/ViteProxyApplicationBuilderExtensions.cs new file mode 100644 index 0000000..e35bdb2 --- /dev/null +++ b/src/ViteProxyApplicationBuilderExtensions.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; + +namespace ViteProxy; + +public static class ViteProxyApplicationBuilderExtensions +{ + public static IApplicationBuilder UseViteStaticFiles(this IApplicationBuilder app) + { + IWebHostEnvironment env = app.ApplicationServices.GetRequiredService(); + IOptions options = app.ApplicationServices.GetRequiredService>(); + string workingDirectory = (options.Value.WorkingDirectory ?? String.Empty).Trim('/'); + + if (workingDirectory == null) + { + return app; + } + + app.UseStaticFiles(); + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(Path.Combine(env.ContentRootPath, workingDirectory)) + }); + + return app; + } + + + public static IApplicationBuilder UseViteStaticFiles(this IApplicationBuilder app, string path) + { + IWebHostEnvironment env = app.ApplicationServices.GetRequiredService(); + + if (path == null) + { + return app; + } + + app.UseStaticFiles(); + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(Path.Combine(env.ContentRootPath, path)) + }); + + return app; + } +} diff --git a/src/ViteProxyOptions.cs b/src/ViteProxyOptions.cs new file mode 100644 index 0000000..a7ebba3 --- /dev/null +++ b/src/ViteProxyOptions.cs @@ -0,0 +1,40 @@ +namespace ViteProxy; + +public class ViteProxyOptions +{ + /// + /// Port which is used for the vite dev server + /// + public int Port { get; set; } = 5123; + + /// + /// Whether to display forward npm console logs to dotnet logger + /// + public bool ForwardLog { get; set; } = false; + + /// + /// Directory where the frontend files are located + /// + public string WorkingDirectory { get; set; } + + /// + /// Whether to enable the proxy or not + /// + public bool Enabled { get; set; } = true; + + /// + /// Startup timeout for the node service + /// + public int TimeoutInSeconds { get; set; } = 60; + + /// + /// Name of the npm script to run + /// + public string ScriptName { get; set; } = "dev"; + + /// + /// The service waits for console input from the npm script + /// And continues if it could find the supplied text + /// + public string StartupCondition { get; set; } = "ready in"; +} diff --git a/src/ViteProxyService.cs b/src/ViteProxyService.cs new file mode 100644 index 0000000..8418af6 --- /dev/null +++ b/src/ViteProxyService.cs @@ -0,0 +1,111 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ViteProxy; + +public class ViteProxyService : IHostedService +{ + ViteProxyOptions options; + ILogger logger; + string workingDirectory; + bool isRunning = false; + + + public ViteProxyService(IWebHostEnvironment env, ILogger logger, IOptions options) + { + this.options = options.Value ?? new(); + this.workingDirectory = Path.Combine(env.ContentRootPath, (this.options.WorkingDirectory ?? String.Empty).Trim('/')); + this.logger = logger; + } + + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + if (!this.options.Enabled) + { + return; + } + + // locate npm version and throw if it is not installed + Version npmVersion = await FindNpmVersion(); + + if (npmVersion == null) + { + throw new Exception("Please install node+npm to use the vite dev service (https://www.npmjs.com/)"); + } + + // start vite server + ProcessProxy viteProcess = await StartDevServer(options.Port); + logger.LogInformation("vite listening on: http://localhost:{port}", options.Port); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + + /// + /// Get the node version from the system + /// + async Task FindNpmVersion() + { + Version version = null; + + ProcessProxy process = new ProcessProxy(workingDirectory, "npm").Argument("-v").Capture((value, err) => + { + if (version == null && !value.Contains("not recognized") && Version.TryParse(value, out Version _version)) + { + version = _version; + } + }); + + await process.ExecuteAsync(); + + return version; + } + + + /// + /// Starts the vite dev server which also support HMR + /// + async Task StartDevServer(int port) + { + // if the port we want to use is occupied, terminate the process utilizing that port. + // this occurs when "stop" is used from the debugger and the middleware does not have the opportunity to kill the process + PidUtils.KillPort((ushort)port, true); + + // create and run the vite script + ProcessProxy process = new ProcessProxy(workingDirectory, "npm", options.ForwardLog) + .Argument("run " + options.ScriptName) + .EnvVar("PORT", port.ToString()) + .Capture(CaptureLog); + + await process.RunAsync(options.StartupCondition, TimeSpan.FromSeconds(options.TimeoutInSeconds)); + + isRunning = true; + + return process; + } + + + void CaptureLog(string line, bool isError) + { + if (!isRunning) + { + return; + } + + if (isError) + { + logger.LogWarning(line); + } + else + { + logger.LogInformation(line); + } + } +} diff --git a/src/ViteProxyServiceCollectionExtensions.cs b/src/ViteProxyServiceCollectionExtensions.cs new file mode 100644 index 0000000..adcc432 --- /dev/null +++ b/src/ViteProxyServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ViteProxy; + +public static class ViteProxyServiceCollectionExtensions +{ + public static IServiceCollection AddViteProxy(this IServiceCollection services) + { + services.AddLogging(); + services.AddOptions(); + services.AddHostedService(); + services.AddOptions().BindConfiguration("Vite"); + + return services; + } + + + public static IServiceCollection AddViteProxy(this IServiceCollection services, Action configure) + { + services.AddLogging(); + services.AddOptions(); + services.AddHostedService(); + services.AddOptions().BindConfiguration("Vite"); + services.PostConfigure(configure); + + return services; + } + + + public static IServiceCollection AddViteProxy(this IServiceCollection services, IConfiguration namedConfigurationSection) + { + services.AddLogging(); + services.AddOptions(); + services.AddHostedService(); + services.AddOptions().Bind(namedConfigurationSection); + + return services; + } +}