diff --git a/zero.Debug/Startup.cs b/zero.Debug/Startup.cs index c48fca8d..69228f9e 100644 --- a/zero.Debug/Startup.cs +++ b/zero.Debug/Startup.cs @@ -52,15 +52,10 @@ namespace zero.Debug // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { - if (env.IsDevelopment()) - { - app.UseZeroDevEnvironment(); - } - app.UseRouting(); app.UseAuthentication(); - app.UseZero(devPath: Path.Combine(Environment.CurrentDirectory, "..", "zero.Web.UI")); + app.UseZero(); app.UseEndpoints(endpoints => { diff --git a/zero.Debug/appsettings.Development.json b/zero.Debug/appsettings.Development.json index 9afd4250..f4a0247d 100644 --- a/zero.Debug/appsettings.Development.json +++ b/zero.Debug/appsettings.Development.json @@ -3,13 +3,18 @@ "Raven": { "Url": "http://127.0.0.1:9800", "Database": "laolazero" + }, + "DevServer": { + "Port": 2333, + "ForwardLog": true } }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" + "Microsoft.Hosting.Lifetime": "Information", + "Zero": "Debug" } } } diff --git a/zero.Web.UI/App/components/forms/rte.pell.dependency.js b/zero.Web.UI/App/components/forms/rte.pell.dependency.js index 28a0b8be..04ba169c 100644 --- a/zero.Web.UI/App/components/forms/rte.pell.dependency.js +++ b/zero.Web.UI/App/components/forms/rte.pell.dependency.js @@ -1,262 +1,247 @@ -(function (global, factory) + +var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; + +var defaultParagraphSeparatorString = 'defaultParagraphSeparator'; +var formatBlock = 'formatBlock'; +var addEventListener = function addEventListener(parent, type, listener) { - typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : - typeof define === 'function' && define.amd ? define(['exports'], factory) : - (factory((global.pell = {}))); -}(this, (function (exports) + return parent.addEventListener(type, listener); +}; +var appendChild = function appendChild(parent, child) { - 'use strict'; + return parent.appendChild(child); +}; +var createElement = function createElement(tag) +{ + return document.createElement(tag); +}; +var queryCommandState = function queryCommandState(command) +{ + return document.queryCommandState(command); +}; +var queryCommandValue = function queryCommandValue(command) +{ + return document.queryCommandValue(command); +}; - var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; +var exec = function exec(command) +{ + var value = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; + return document.execCommand(command, false, value); +}; - var defaultParagraphSeparatorString = 'defaultParagraphSeparator'; - var formatBlock = 'formatBlock'; - var addEventListener = function addEventListener(parent, type, listener) - { - return parent.addEventListener(type, listener); - }; - var appendChild = function appendChild(parent, child) - { - return parent.appendChild(child); - }; - var createElement = function createElement(tag) - { - return document.createElement(tag); - }; - var queryCommandState = function queryCommandState(command) - { - return document.queryCommandState(command); - }; - var queryCommandValue = function queryCommandValue(command) - { - return document.queryCommandValue(command); - }; +var defaultActions = { + bold: { + icon: 'B', + title: 'Bold', + state: function state() + { + return queryCommandState('bold'); + }, + result: function result() + { + return exec('bold'); + } + }, + italic: { + icon: 'I', + title: 'Italic', + state: function state() + { + return queryCommandState('italic'); + }, + result: function result() + { + return exec('italic'); + } + }, + underline: { + icon: 'U', + title: 'Underline', + state: function state() + { + return queryCommandState('underline'); + }, + result: function result() + { + return exec('underline'); + } + }, + strikethrough: { + icon: 'S', + title: 'Strike-through', + state: function state() + { + return queryCommandState('strikeThrough'); + }, + result: function result() + { + return exec('strikeThrough'); + } + }, + heading1: { + icon: 'H1', + title: 'Heading 1', + result: function result() + { + return exec(formatBlock, '

'); + } + }, + heading2: { + icon: 'H2', + title: 'Heading 2', + result: function result() + { + return exec(formatBlock, '

'); + } + }, + paragraph: { + icon: '¶', + title: 'Paragraph', + result: function result() + { + return exec(formatBlock, '

'); + } + }, + quote: { + icon: '“ ”', + title: 'Quote', + result: function result() + { + return exec(formatBlock, '

'); + } + }, + olist: { + icon: '', + title: 'Ordered List', + result: function result() + { + return exec('insertOrderedList'); + } + }, + ulist: { + icon: '', + title: 'Unordered List', + result: function result() + { + return exec('insertUnorderedList'); + } + }, + code: { + icon: '</>', + title: 'Code', + result: function result() + { + return exec(formatBlock, '
');
+    }
+  },
+  line: {
+    icon: '',
+    title: 'Horizontal Line',
+    result: function result()
+    {
+      return exec('insertHorizontalRule');
+    }
+  },
+  link: {
+    icon: '',
+    title: 'Link',
+    result: function result()
+    {
+      var url = window.prompt('Enter the link URL');
+      if (url) exec('createLink', url);
+    }
+  },
+  image: {
+    icon: '📷',
+    title: 'Image',
+    result: function result()
+    {
+      var url = window.prompt('Enter the image URL');
+      if (url) exec('insertImage', url);
+    }
+  }
+};
 
-  var exec = function exec(command)
-  {
-    var value = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null;
-    return document.execCommand(command, false, value);
-  };
+var defaultClasses = {
+  actionbar: 'pell-actionbar',
+  button: 'pell-button',
+  content: 'pell-content',
+  selected: 'pell-button-selected'
+};
 
-  var defaultActions = {
-    bold: {
-      icon: 'B',
-      title: 'Bold',
-      state: function state()
+var init = function init(settings)
+{
+  var actions = settings.actions ? settings.actions.map(function (action)
+  {
+    if (typeof action === 'string') return defaultActions[action]; else if (defaultActions[action.name]) return _extends({}, defaultActions[action.name], action);
+    return action;
+  }) : Object.keys(defaultActions).map(function (action)
+  {
+    return defaultActions[action];
+  });
+
+  var classes = _extends({}, defaultClasses, settings.classes);
+
+  var defaultParagraphSeparator = settings[defaultParagraphSeparatorString] || 'div';
+
+  var actionbar = createElement('div');
+  actionbar.className = classes.actionbar;
+  appendChild(settings.element, actionbar);
+
+  var content = settings.element.content = createElement('div');
+  content.contentEditable = true;
+  content.className = classes.content;
+  content.oninput = function (_ref)
+  {
+    var firstChild = _ref.target.firstChild;
+
+    if (firstChild && firstChild.nodeType === 3) exec(formatBlock, '<' + defaultParagraphSeparator + '>'); else if (content.innerHTML === '
') content.innerHTML = ''; + settings.onChange(content.innerHTML); + }; + content.onkeydown = function (event) + { + if (event.key === 'Enter' && queryCommandValue(formatBlock) === 'blockquote') + { + setTimeout(function () { - return queryCommandState('bold'); - }, - result: function result() - { - return exec('bold'); - } - }, - italic: { - icon: 'I', - title: 'Italic', - state: function state() - { - return queryCommandState('italic'); - }, - result: function result() - { - return exec('italic'); - } - }, - underline: { - icon: 'U', - title: 'Underline', - state: function state() - { - return queryCommandState('underline'); - }, - result: function result() - { - return exec('underline'); - } - }, - strikethrough: { - icon: 'S', - title: 'Strike-through', - state: function state() - { - return queryCommandState('strikeThrough'); - }, - result: function result() - { - return exec('strikeThrough'); - } - }, - heading1: { - icon: 'H1', - title: 'Heading 1', - result: function result() - { - return exec(formatBlock, '

'); - } - }, - heading2: { - icon: 'H2', - title: 'Heading 2', - result: function result() - { - return exec(formatBlock, '

'); - } - }, - paragraph: { - icon: '¶', - title: 'Paragraph', - result: function result() - { - return exec(formatBlock, '

'); - } - }, - quote: { - icon: '“ ”', - title: 'Quote', - result: function result() - { - return exec(formatBlock, '

'); - } - }, - olist: { - icon: '', - title: 'Ordered List', - result: function result() - { - return exec('insertOrderedList'); - } - }, - ulist: { - icon: '', - title: 'Unordered List', - result: function result() - { - return exec('insertUnorderedList'); - } - }, - code: { - icon: '</>', - title: 'Code', - result: function result() - { - return exec(formatBlock, '
');
-      }
-    },
-    line: {
-      icon: '',
-      title: 'Horizontal Line',
-      result: function result()
-      {
-        return exec('insertHorizontalRule');
-      }
-    },
-    link: {
-      icon: '',
-      title: 'Link',
-      result: function result()
-      {
-        var url = window.prompt('Enter the link URL');
-        if (url) exec('createLink', url);
-      }
-    },
-    image: {
-      icon: '📷',
-      title: 'Image',
-      result: function result()
-      {
-        var url = window.prompt('Enter the image URL');
-        if (url) exec('insertImage', url);
-      }
+        return exec(formatBlock, '<' + defaultParagraphSeparator + '>');
+      }, 0);
     }
   };
+  appendChild(settings.element, content);
 
-  var defaultClasses = {
-    actionbar: 'pell-actionbar',
-    button: 'pell-button',
-    content: 'pell-content',
-    selected: 'pell-button-selected'
-  };
-
-  var init = function init(settings)
+  actions.forEach(function (action)
   {
-    var actions = settings.actions ? settings.actions.map(function (action)
+    var button = createElement('button');
+    button.className = classes.button;
+    button.innerHTML = action.icon;
+    button.title = action.title;
+    button.setAttribute('type', 'button');
+    button.onclick = function ()
     {
-      if (typeof action === 'string') return defaultActions[action]; else if (defaultActions[action.name]) return _extends({}, defaultActions[action.name], action);
-      return action;
-    }) : Object.keys(defaultActions).map(function (action)
-    {
-      return defaultActions[action];
-    });
-
-    var classes = _extends({}, defaultClasses, settings.classes);
-
-    var defaultParagraphSeparator = settings[defaultParagraphSeparatorString] || 'div';
-
-    var actionbar = createElement('div');
-    actionbar.className = classes.actionbar;
-    appendChild(settings.element, actionbar);
-
-    var content = settings.element.content = createElement('div');
-    content.contentEditable = true;
-    content.className = classes.content;
-    content.oninput = function (_ref)
-    {
-      var firstChild = _ref.target.firstChild;
-
-      if (firstChild && firstChild.nodeType === 3) exec(formatBlock, '<' + defaultParagraphSeparator + '>'); else if (content.innerHTML === '
') content.innerHTML = ''; - settings.onChange(content.innerHTML); + return action.result() && content.focus(); }; - content.onkeydown = function (event) - { - if (event.key === 'Enter' && queryCommandValue(formatBlock) === 'blockquote') - { - setTimeout(function () - { - return exec(formatBlock, '<' + defaultParagraphSeparator + '>'); - }, 0); - } - }; - appendChild(settings.element, content); - actions.forEach(function (action) + if (action.state) { - var button = createElement('button'); - button.className = classes.button; - button.innerHTML = action.icon; - button.title = action.title; - button.setAttribute('type', 'button'); - button.onclick = function () + var handler = function handler() { - return action.result() && content.focus(); + return button.classList[action.state() ? 'add' : 'remove'](classes.selected); }; + addEventListener(content, 'keyup', handler); + addEventListener(content, 'mouseup', handler); + addEventListener(button, 'click', handler); + } - if (action.state) - { - var handler = function handler() - { - return button.classList[action.state() ? 'add' : 'remove'](classes.selected); - }; - addEventListener(content, 'keyup', handler); - addEventListener(content, 'mouseup', handler); - addEventListener(button, 'click', handler); - } + appendChild(actionbar, button); + }); - appendChild(actionbar, button); - }); + if (settings.styleWithCSS) exec('styleWithCSS'); + exec(defaultParagraphSeparatorString, defaultParagraphSeparator); - if (settings.styleWithCSS) exec('styleWithCSS'); - exec(defaultParagraphSeparatorString, defaultParagraphSeparator); + return settings.element; +}; - return settings.element; - }; - var pell = { exec: exec, init: init }; - - exports.exec = exec; - exports.init = init; - exports['default'] = pell; - - Object.defineProperty(exports, '__esModule', { value: true }); - -}))); +export default { exec, init }; \ No newline at end of file diff --git a/zero.Web.UI/App/components/globals.js b/zero.Web.UI/App/components/globals.js deleted file mode 100644 index cbb13e49..00000000 --- a/zero.Web.UI/App/components/globals.js +++ /dev/null @@ -1,47 +0,0 @@ -// Globally register all base components for convenience, because they -// will be used very frequently. Components are registered using the -// PascalCased version of their file name. - -import Vue from 'vue'; - -//const directories = ['buttons', 'forms', 'messages', 'tables', 'tabs']; - -// TODO this file will import all vue component files with subdirectories -// if it contains a file "overlay.vue" it will create an "ui-overlay" component -// if another folder contains a file "overlay.vue" it is not used anymore or overridden. -// we don't want to add all files but only files where we define global component access in its settings - -// https://webpack.js.org/guides/dependency-management/#require-context -const requireComponent = require.context( - // Look for files in the current directory - '.', - // Do look in subdirectories - true, - // .vue files - /[\w-]+\.vue$/ -); - -// For each matching file name... -requireComponent.keys().forEach((path) => -{ - let pathParts = path.split('/'); - let fileName = pathParts[pathParts.length - 1]; - - // Get the component config - const componentConfig = requireComponent(path); - // Get the PascalCase version of the component name - const componentName = 'ui' + fileName - // Remove the "./_" from the beginning - .replace(/^\.\/_/, '') - // Remove the file extension from the end - .replace(/\.\w+$/, '') - // Split up kebabs - .split('-') - // Upper case - .map((kebab) => kebab.charAt(0).toUpperCase() + kebab.slice(1)) - // Concatenated - .join(''); - - // Globally register the component - Vue.component(componentName, componentConfig.default || componentConfig); -}); \ No newline at end of file diff --git a/zero.Web.UI/App/components/modules/module-preview.vue b/zero.Web.UI/App/components/modules/module-preview.vue index afb5364a..81b3780d 100644 --- a/zero.Web.UI/App/components/modules/module-preview.vue +++ b/zero.Web.UI/App/components/modules/module-preview.vue @@ -2,7 +2,7 @@
- +
{{alias}} @@ -29,10 +29,13 @@ \ No newline at end of file diff --git a/zero.Web.UI/App/core/plugin.zero.js b/zero.Web.UI/App/core/plugin.zero.js index e997ab58..c3fdc027 100644 --- a/zero.Web.UI/App/core/plugin.zero.js +++ b/zero.Web.UI/App/core/plugin.zero.js @@ -1,5 +1,5 @@  -import Plugin from './plugin.js'; +import Plugin from './plugin.ts'; import editors from '../renderers/editors/all.js'; import lists from '../renderers/lists/all.js'; import routes from './routes.js'; diff --git a/zero.Web.UI/App/editor/editor-component.vue b/zero.Web.UI/App/editor/editor-component.vue index c04c5d37..3ee1d3c4 100644 --- a/zero.Web.UI/App/editor/editor-component.vue +++ b/zero.Web.UI/App/editor/editor-component.vue @@ -9,8 +9,8 @@ \ No newline at end of file diff --git a/zero.Web/Controllers/ZeroBackofficeController.cs b/zero.Web/Controllers/ZeroBackofficeController.cs index 2b0357cd..36bec8f4 100644 --- a/zero.Web/Controllers/ZeroBackofficeController.cs +++ b/zero.Web/Controllers/ZeroBackofficeController.cs @@ -1,8 +1,10 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using zero.Core.Extensions; using zero.Core.Identity; using zero.Core.Options; using zero.Web.Models; +using Zero.Web.DevServer; namespace zero.Web.Controllers { @@ -11,11 +13,13 @@ namespace zero.Web.Controllers { IZeroVue ZeroVue { get; set; } IZeroOptions Options { get; set; } + IOptions DevServerOptions { get; set; } - public ZeroBackofficeController(IZeroVue zeroVue, IZeroOptions options) + public ZeroBackofficeController(IZeroVue zeroVue, IZeroOptions options, IOptions devServerOptions) { ZeroVue = zeroVue; Options = options; + DevServerOptions = devServerOptions; } @@ -28,6 +32,7 @@ namespace zero.Web.Controllers return View("Views/Zero/Index.cshtml", new ZeroBackofficeModel() { + Port = DevServerOptions.Value.Port, Vue = ZeroVue }); } diff --git a/zero.Web/DevServer/EventedStreamReader.cs b/zero.Web/DevServer/EventedStreamReader.cs new file mode 100644 index 00000000..ddbf6656 --- /dev/null +++ b/zero.Web/DevServer/EventedStreamReader.cs @@ -0,0 +1,194 @@ +// 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; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + + +namespace Zero.Web.DevServer +{ + /// + /// 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/zero.Web/DevServer/PidUtils.cs b/zero.Web/DevServer/PidUtils.cs new file mode 100644 index 00000000..1f2604b7 --- /dev/null +++ b/zero.Web/DevServer/PidUtils.cs @@ -0,0 +1,205 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Zero.Web.DevServer +{ + 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/zero.Web/DevServer/ZeroDevOptions.cs b/zero.Web/DevServer/ZeroDevOptions.cs new file mode 100644 index 00000000..21dfb678 --- /dev/null +++ b/zero.Web/DevServer/ZeroDevOptions.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Zero.Web.DevServer +{ + public class ZeroDevOptions + { + public int Port { get; set; } = 3399; + + public bool ForwardLog { get; set; } = true; + + public string WorkingDirectory { get; set; } + } +} diff --git a/zero.Web/DevServer/ZeroDevProcess.cs b/zero.Web/DevServer/ZeroDevProcess.cs new file mode 100644 index 00000000..2fc79e7f --- /dev/null +++ b/zero.Web/DevServer/ZeroDevProcess.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Zero.Web.DevServer +{ + public class ZeroDevProcess + { + string workingDirectory = null; + string script = null; + 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 = null; + EventedStreamReader stdErr = null; + + + + + public ZeroDevProcess(string workingDirectory, string script, bool forwardLog = true) + { + this.workingDirectory = workingDirectory; + this.script = script; + this.forwardLog = forwardLog; + } + + + /// + /// Adds an environment variable + /// + public ZeroDevProcess EnvVar(string key, string value) + { + envVars.Add(key, value); + return this; + } + + + /// + /// Adds an argument + /// + public ZeroDevProcess Argument(string argument) + { + arguments.Add(argument); + return this; + } + + + /// + /// Configures the process start info + /// + public ZeroDevProcess Configure(Action onProcessConfigure) + { + this.onProcessConfigure = onProcessConfigure; + return this; + } + + + /// + /// Capture the log instead of outputting it to the console + /// + public ZeroDevProcess Capture(Action action) + { + if (!forwardLog) + { + throw new InvalidOperationException("Set forwardLog=true (ctor) to enable log capturing"); + } + + 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); + } + + if (forwardLog) + { + 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); + } + else + { + (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); + } + else + { + (isError ? Console.Error : Console.Out).Write(chunk.Array, chunk.Offset, chunk.Count); + } + } + } +} diff --git a/zero.Web/DevServer/ZeroDevService.cs b/zero.Web/DevServer/ZeroDevService.cs new file mode 100644 index 00000000..5b72bcee --- /dev/null +++ b/zero.Web/DevServer/ZeroDevService.cs @@ -0,0 +1,109 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Zero.Web.DevServer +{ + public class ZeroDevService : IHostedService + { + IWebHostEnvironment env; + IOptions options; + ZeroDevProcess viteProcess; + ILogger logger; + string workingDirectory; + + + public ZeroDevService(IWebHostEnvironment env, IOptions options, ILogger logger) + { + this.env = env; + this.options = options; + this.workingDirectory = options.Value.WorkingDirectory; + this.logger = logger; + } + + + public async Task StartAsync(CancellationToken cancellationToken) + { + // this is a development-time service, + // therefore no way to enable it in production + if (!env.IsDevelopment()) + { + return; + } + + Version npmVersion = await FindNpmVersion(); + + if (npmVersion == null) + { + // TODO report and return + } + + logger.LogDebug("Found npm version {version}", npmVersion); + + viteProcess = await StartDevServer(options.Value.Port); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + + /// + /// Get the node version from the system + /// + async Task FindNpmVersion() + { + Version version = null; + + ZeroDevProcess process = new ZeroDevProcess(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); + + // get all plugins which need to be passed to vite + var plugins = new List() + { + new + { + path = Path.Combine(env.ContentRootPath, "../", "ViteZero.Plugin", "vite.plugin.js") + } + }; + + // create and run the vite script + ZeroDevProcess process = new ZeroDevProcess(workingDirectory, "npm", options.Value.ForwardLog) + .Argument("run dev") + .EnvVar("PORT", options.Value.Port.ToString()) + .EnvVar("ZERO_PLUGINS", JsonConvert.SerializeObject(plugins)); + + await process.RunAsync("running at", TimeSpan.FromMinutes(5)); + + return process; + } + } +} diff --git a/zero.Web/DevServer/ZeroPluginManager.cs b/zero.Web/DevServer/ZeroPluginManager.cs new file mode 100644 index 00000000..ce2df298 --- /dev/null +++ b/zero.Web/DevServer/ZeroPluginManager.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using zero.Core.Plugins; + +namespace Zero.Web.DevServer +{ + public class ZeroPluginManager + { + protected IEnumerable Plugins { get; set; } + + + public ZeroPluginManager(IEnumerable plugins) + { + Plugins = plugins; + } + } +} diff --git a/zero.Web/Models/ZeroBackofficeModel.cs b/zero.Web/Models/ZeroBackofficeModel.cs index 171997fb..b430efd2 100644 --- a/zero.Web/Models/ZeroBackofficeModel.cs +++ b/zero.Web/Models/ZeroBackofficeModel.cs @@ -2,6 +2,8 @@ { public class ZeroBackofficeModel { + public int Port { get; set; } + public IZeroVue Vue { get; set; } } } diff --git a/zero.Web/Views/Zero/Index.cshtml b/zero.Web/Views/Zero/Index.cshtml index addcf09b..135aba7d 100644 --- a/zero.Web/Views/Zero/Index.cshtml +++ b/zero.Web/Views/Zero/Index.cshtml @@ -25,7 +25,7 @@ window.__zero = @Html.Raw(await Model.Vue.ConfigAsJson()); window.zero = window.__zero; - - + + \ No newline at end of file diff --git a/zero.Web/ZeroApplicationBuilderExtensions.cs b/zero.Web/ZeroApplicationBuilderExtensions.cs index 77518173..d8b8a2e1 100644 --- a/zero.Web/ZeroApplicationBuilderExtensions.cs +++ b/zero.Web/ZeroApplicationBuilderExtensions.cs @@ -17,7 +17,7 @@ namespace zero.Web { public static class ZeroApplicationBuilderExtensions { - public static IApplicationBuilder UseZero(this IApplicationBuilder app, string devPath = null) + public static IApplicationBuilder UseZero(this IApplicationBuilder app) { IZeroOptions options = app.ApplicationServices.GetService(); @@ -56,11 +56,6 @@ namespace zero.Web // pattern: path + "/api/{controller}/{action}/{id?}" //); - if (devPath != null) - { - endpoints.MapFallback(path + "/vue-cli/{**path}", CreateVueProxyDelegate(endpoints, new SpaOptions { SourcePath = devPath })); - } - // fallbacks for SPA endpoints.MapFallbackToController(path + "/{**path}", "Index", "ZeroBackoffice"); }); @@ -74,64 +69,5 @@ namespace zero.Web return app; } - - - static RequestDelegate CreateVueProxyDelegate(IEndpointRouteBuilder endpoints, SpaOptions options = null) - { - var app = endpoints.CreateApplicationBuilder(); - app.Use(next => context => - { - context.SetEndpoint(null); - return next(context); - }); - - app.UseSpa(opt => - { - if (options != null) - { - opt.Options.DefaultPage = options.DefaultPage; - opt.Options.DefaultPageStaticFileOptions = options.DefaultPageStaticFileOptions; - opt.Options.SourcePath = options.SourcePath; - opt.Options.StartupTimeout = options.StartupTimeout; - } - - opt.UseVueCli(); - }); - - return app.Build(); - } - - - public static IApplicationBuilder UseZeroDevEnvironment(this IApplicationBuilder app) - { - //IZeroOptions options = app.ApplicationServices.GetService(); - //string zeroPath = options.BackofficePath.EnsureStartsWith('/').TrimEnd('/'); - - - //string webUiPath = path ?? Path.Combine(Environment.CurrentDirectory, "..", "zero.Web.UI"); // TODO dynPATH - - // map backoffice - //app.UseWhen(ctx => ctx.Request.Path.ToString().StartsWith(zeroPath), builder => - //{ - // builder.UseRouting(); - - - //}); - - //#pragma warning disable CS0618 - //app.UseWebpackDevMiddleware(new WebpackDevMiddlewareOptions() - //{ - // HotModuleReplacement = true, - // ProjectPath = webUiPath, - // HotModuleReplacementServerPort = 8999, - // EnvironmentVariables = new Dictionary() - // { - // { "ZERO", "hi from zero 0.1" } - // } - //}); - //#pragma warning restore CS0618 - - return app; - } } } diff --git a/zero.Web/ZeroBuilder.cs b/zero.Web/ZeroBuilder.cs index d4c14129..fdf1165e 100644 --- a/zero.Web/ZeroBuilder.cs +++ b/zero.Web/ZeroBuilder.cs @@ -12,6 +12,7 @@ using Newtonsoft.Json.Serialization; using Raven.Client.Documents; using Raven.Client.Documents.Indexes; using System; +using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; @@ -29,6 +30,7 @@ using zero.Core.Validation; using zero.Web.Controllers; using zero.Web.Defaults; using zero.Web.Filters; +using Zero.Web.DevServer; namespace zero.Web { @@ -74,7 +76,6 @@ namespace zero.Web opts.ZeroVersion = "0.0.1.0"; // TODO }); - // add transient options to DI Services.AddTransient(factory => factory.GetService>().CurrentValue); @@ -118,6 +119,17 @@ namespace zero.Web IWebHostEnvironment env = factory.GetService(); return new Paths(env.WebRootPath, true); }); + + + // add dev server + Services.AddOptions() + .Bind(Configuration.GetSection("Zero:DevServer")) + .Configure((opts, env) => + { + opts.WorkingDirectory = Path.Combine(env.ContentRootPath, "..", "Zero.Web.UI", "App"); + }); + + Services.AddHostedService(); } diff --git a/zero.Web/zero.Web.csproj b/zero.Web/zero.Web.csproj index 1bc358d3..062aec64 100644 --- a/zero.Web/zero.Web.csproj +++ b/zero.Web/zero.Web.csproj @@ -1,7 +1,7 @@  - 8.0 + preview net5.0 true