diff --git a/Dockerfile b/Dockerfile index db669898b..4686b3ebf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,10 +4,6 @@ FROM crystallang/crystal:0.25.0 ARG DEBIAN_FRONTEND=noninteractive RUN apt-get update -qq && apt-get install -y --no-install-recommends libpq-dev libsqlite3-dev libmysqlclient-dev libreadline-dev git curl vim netcat -# Install Node -RUN curl -sL https://deb.nodesource.com/setup_6.x | bash - -RUN apt-get install -y nodejs - WORKDIR /opt/amber # Build Amber diff --git a/assets/js/amber.js b/assets/js/amber.js index 25fc29ee3..ffae30551 100644 --- a/assets/js/amber.js +++ b/assets/js/amber.js @@ -1,254 +1,347 @@ -const EVENTS = { - join: 'join', - leave: 'leave', - message: 'message' +"use strict"; + +function _classCallCheck(instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } } -const STALE_CONNECTION_THRESHOLD_SECONDS = 100 -const SOCKET_POLLING_RATE = 10000 + +var EVENTS = { + join: "join", + leave: "leave", + message: "message" +}; +var STALE_CONNECTION_THRESHOLD_SECONDS = 100; +var SOCKET_POLLING_RATE = 10000; /** * Returns a numeric value for the current time */ -let now = () => { - return new Date().getTime() -} +var now = function now() { + return new Date().getTime(); +}; /** * Returns the difference between the current time and passed `time` in seconds * @param {Number|Date} time - A numeric time or date object */ -let secondsSince = (time) => { - return (now() - time) / 1000 -} +var secondsSince = function secondsSince(time) { + return (now() - time) / 1000; +}; -/** - * Class for channel related functions (joining, leaving, subscribing and sending messages) - */ -export class Channel { +var Amber = { /** - * @param {String} topic - topic to subscribe to - * @param {Socket} socket - A Socket instance + * Class for channel related functions (joining, leaving, subscribing and sending messages) */ - constructor(topic, socket) { - this.topic = topic - this.socket = socket - this.onMessageHandlers = [] - } - - /** - * Join a channel, subscribe to all channels messages - */ - join() { - this.socket.ws.send(JSON.stringify({ event: EVENTS.join, topic: this.topic })) - } + Channel: (function() { + /** + * @param {String} topic - topic to subscribe to + * @param {Socket} socket - A Socket instance + */ + function Channel(topic, socket) { + _classCallCheck(this, Channel); - /** - * Leave a channel, stop subscribing to channel messages - */ - leave() { - this.socket.ws.send(JSON.stringify({ event: EVENTS.leave, topic: this.topic })) - } + this.topic = topic; + this.socket = socket; + this.onMessageHandlers = []; + } - /** - * Calls all message handlers with a matching subject - */ - handleMessage(msg) { - this.onMessageHandlers.forEach((handler) => { - if (handler.subject === msg.subject) handler.callback(msg.payload) - }) - } + /** + * Join a channel, subscribe to all channels messages + */ - /** - * Subscribe to a channel subject - * @param {String} subject - subject to listen for: `msg:new` - * @param {function} callback - callback function when a new message arrives - */ - on(subject, callback) { - this.onMessageHandlers.push({ subject: subject, callback: callback }) - } + Channel.prototype.join = function join() { + this.socket.ws.send( + JSON.stringify({ event: EVENTS.join, topic: this.topic }) + ); + }; - /** - * Send a new message to the channel - * @param {String} subject - subject to send message to: `msg:new` - * @param {Object} payload - payload object: `{message: 'hello'}` - */ - push(subject, payload) { - this.socket.ws.send(JSON.stringify({ event: EVENTS.message, topic: this.topic, subject: subject, payload: payload })) - } -} + /** + * Leave a channel, stop subscribing to channel messages + */ -/** - * Class for maintaining connection with server and maintaining channels list - */ -export class Socket { - /** - * @param {String} endpoint - Websocket endpont used in routes.cr file - */ - constructor(endpoint) { - this.endpoint = endpoint - this.ws = null - this.channels = [] - this.lastPing = now() - this.reconnectTries = 0 - this.attemptReconnect = true - } + Channel.prototype.leave = function leave() { + this.socket.ws.send( + JSON.stringify({ event: EVENTS.leave, topic: this.topic }) + ); + }; - /** - * Returns whether or not the last received ping has been past the threshold - */ - _connectionIsStale() { - return secondsSince(this.lastPing) > STALE_CONNECTION_THRESHOLD_SECONDS - } + /** + * Calls all message handlers with a matching subject + */ - /** - * Tries to reconnect to the websocket server using a recursive timeout - */ - _reconnect() { - clearTimeout(this.reconnectTimeout) - this.reconnectTimeout = setTimeout(() => { - this.reconnectTries++ - this.connect(this.params) - this._reconnect() - }, this._reconnectInterval()) - } + Channel.prototype.handleMessage = function handleMessage(msg) { + this.onMessageHandlers.forEach(function(handler) { + if (handler.subject === msg.subject) handler.callback(msg.payload); + }); + }; - /** - * Returns an incrementing timeout interval based around the number of reconnection retries - */ - _reconnectInterval() { - return [1000, 2000, 5000, 10000][this.reconnectTries] || 10000 - } + /** + * Subscribe to a channel subject + * @param {String} subject - subject to listen for: `msg:new` + * @param {function} callback - callback function when a new message arrives + */ - /** - * Sets a recursive timeout to check if the connection is stale - */ - _poll() { - this.pollingTimeout = setTimeout(() => { - if (this._connectionIsStale()) { - this._reconnect() - } else { - this._poll() - } - }, SOCKET_POLLING_RATE) - } + Channel.prototype.on = function on(subject, callback) { + this.onMessageHandlers.push({ subject: subject, callback: callback }); + }; - /** - * Clear polling timeout and start polling - */ - _startPolling() { - clearTimeout(this.pollingTimeout) - this._poll() - } + /** + * Send a new message to the channel + * @param {String} subject - subject to send message to: `msg:new` + * @param {Object} payload - payload object: `{message: 'hello'}` + */ - /** - * Sets `lastPing` to the curent time - */ - _handlePing() { - this.lastPing = now() - } + Channel.prototype.push = function push(subject, payload) { + this.socket.ws.send( + JSON.stringify({ + event: EVENTS.message, + topic: this.topic, + subject: subject, + payload: payload + }) + ); + }; - /** - * Clears reconnect timeout, resets variables an starts polling - */ - _reset() { - clearTimeout(this.reconnectTimeout) - this.reconnectTries = 0 - this.attemptReconnect = true - this._startPolling() - } + return Channel; + })(), /** - * Connect the socket to the server, and binds to native ws functions - * @param {Object} params - Optional parameters - * @param {String} params.location - Hostname to connect to, defaults to `window.location.hostname` - * @param {String} parmas.port - Port to connect to, defaults to `window.location.port` - * @param {String} params.protocol - Protocol to use, either 'wss' or 'ws' + * Class for maintaining connection with server and maintaining channels list */ - connect(params) { - this.params = params + Socket: (function() { + /** + * @param {String} endpoint - Websocket endpont used in routes.cr file + */ + function Socket(endpoint) { + _classCallCheck(this, Socket); - let opts = { - location: window.location.hostname, - port: window.location.port, - protocol: window.location.protocol === 'https:' ? 'wss:' : 'ws:', + this.endpoint = endpoint; + this.ws = null; + this.channels = []; + this.lastPing = now(); + this.reconnectTries = 0; + this.attemptReconnect = true; } - if (params) Object.assign(opts, params) - if (opts.port) opts.location += `:${opts.port}` + /** + * Returns whether or not the last received ping has been past the threshold + */ + + Socket.prototype._connectionIsStale = function _connectionIsStale() { + return secondsSince(this.lastPing) > STALE_CONNECTION_THRESHOLD_SECONDS; + }; + + /** + * Tries to reconnect to the websocket server using a recursive timeout + */ + + Socket.prototype._reconnect = function _reconnect() { + var _this = this; + + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = setTimeout(function() { + _this.reconnectTries++; + _this.connect(_this.params); + _this._reconnect(); + }, this._reconnectInterval()); + }; + + /** + * Returns an incrementing timeout interval based around the number of reconnection retries + */ + + Socket.prototype._reconnectInterval = function _reconnectInterval() { + return [1000, 2000, 5000, 10000][this.reconnectTries] || 10000; + }; + + /** + * Sets a recursive timeout to check if the connection is stale + */ + + Socket.prototype._poll = function _poll() { + var _this2 = this; + + this.pollingTimeout = setTimeout(function() { + if (_this2._connectionIsStale()) { + _this2._reconnect(); + } else { + _this2._poll(); + } + }, SOCKET_POLLING_RATE); + }; - return new Promise((resolve, reject) => { - this.ws = new WebSocket(`${opts.protocol}//${opts.location}${this.endpoint}`) - this.ws.onmessage = (msg) => { this.handleMessage(msg) } - this.ws.onclose = () => { - if (this.attemptReconnect) this._reconnect() + /** + * Clear polling timeout and start polling + */ + + Socket.prototype._startPolling = function _startPolling() { + clearTimeout(this.pollingTimeout); + this._poll(); + }; + + /** + * Sets `lastPing` to the curent time + */ + + Socket.prototype._handlePing = function _handlePing() { + this.lastPing = now(); + }; + + /** + * Clears reconnect timeout, resets variables an starts polling + */ + + Socket.prototype._reset = function _reset() { + clearTimeout(this.reconnectTimeout); + this.reconnectTries = 0; + this.attemptReconnect = true; + this._startPolling(); + }; + + /** + * Connect the socket to the server, and binds to native ws functions + * @param {Object} params - Optional parameters + * @param {String} params.location - Hostname to connect to, defaults to `window.location.hostname` + * @param {String} parmas.port - Port to connect to, defaults to `window.location.port` + * @param {String} params.protocol - Protocol to use, either 'wss' or 'ws' + */ + + Socket.prototype.connect = function connect(params) { + var _this3 = this; + + this.params = params; + + var opts = { + location: window.location.hostname, + port: window.location.port, + protocol: window.location.protocol === "https:" ? "wss:" : "ws:" + }; + + if (params) { + Object.assign(opts, params); } - this.ws.onopen = () => { - this._reset() - resolve() + + if (opts.port) { + opts.location += ":" + opts.port; } - }) - } - /** - * Closes the socket connection permanently - */ - disconnect() { - this.attemptReconnect = false - clearTimeout(this.pollingTimeout) - clearTimeout(this.reconnectTimeout) - this.ws.close() - } + return new Promise(function(resolve, reject) { + _this3.ws = new WebSocket( + opts.protocol + "//" + opts.location + _this3.endpoint + ); + _this3.ws.onmessage = function(msg) { + _this3.handleMessage(msg); + }; + _this3.ws.onclose = function() { + if (_this3.attemptReconnect) _this3._reconnect(); + }; + _this3.ws.onopen = function() { + _this3._reset(); + resolve(); + }; + }); + }; - /** - * Adds a new channel to the socket channels list - * @param {String} topic - Topic for the channel: `chat_room:123` - */ - channel(topic) { - let channel = new Channel(topic, this) - this.channels.push(channel) - return channel - } + /** + * Closes the socket connection permanently + */ + + Socket.prototype.disconnect = function disconnect() { + this.attemptReconnect = false; + clearTimeout(this.pollingTimeout); + clearTimeout(this.reconnectTimeout); + this.ws.close(); + }; + + /** + * Adds a new channel to the socket channels list + * @param {String} topic - Topic for the channel: `chat_room:123` + */ + + Socket.prototype.channel = function channel(topic) { + var channel = new Channel(topic, this); + this.channels.push(channel); + return channel; + }; + + /** + * Message handler for messages received + * @param {MessageEvent} msg - Message received from ws + */ + + Socket.prototype.handleMessage = function handleMessage(msg) { + if (msg.data === "ping") { + return this._handlePing(); + } + + var parsed_msg = JSON.parse(msg.data); + this.channels.forEach(function(channel) { + if (channel.topic === parsed_msg.topic) { + channel.handleMessage(parsed_msg); + } + }); + }; + + return Socket; + })() /** - * Message handler for messages received - * @param {MessageEvent} msg - Message received from ws + * Load functions when DOM is ready */ - handleMessage(msg) { - if (msg.data === "ping") return this._handlePing() - - let parsed_msg = JSON.parse(msg.data) - this.channels.forEach((channel) => { - if (channel.topic === parsed_msg.topic) channel.handleMessage(parsed_msg) - }) - } -} +}; +document.addEventListener("DOMContentLoaded", function(event) { + watchAnchorButtons(); +}); -module.exports = { - Socket: Socket +/** + * Allows links to post for security and ease of use similar to Rails jquery_ujs + */ +function watchAnchorButtons() { + document.querySelectorAll("a[data-method]").forEach(function(element) { + var method = element.getAttribute("data-method"); + element.addEventListener("click", function(event) { + event.preventDefault(); + var message = element.getAttribute("data-confirm") || "Are you sure?"; + if (confirm(message)) { + var form = document.createElement("form"); + var input = document.createElement("input"); + form.setAttribute("action", element.getAttribute("href")); + form.setAttribute("method", "POST"); + input.setAttribute("type", "hidden"); + input.setAttribute("name", "_method"); + input.setAttribute("value", method); + form.appendChild(input); + document.body.appendChild(form); + form.submit(); + } + }); + }); } - /** - * Allows delete links to post for security and ease of use similar to Rails jquery_ujs + * Allows to convert Date to Granite Model format */ -document.addEventListener("DOMContentLoaded", function () { - document.querySelectorAll("a[data-method='delete']").forEach(function (element) { - element.addEventListener("click", function (e) { - e.preventDefault(); - var message = element.getAttribute("data-confirm") || "Are you sure?"; - if (confirm(message)) { - var form = document.createElement("form"); - var input = document.createElement("input"); - form.setAttribute("action", element.getAttribute("href")); - form.setAttribute("method", "POST"); - input.setAttribute("type", "hidden"); - input.setAttribute("name", "_method"); - input.setAttribute("value", "DELETE"); - form.appendChild(input); - document.body.appendChild(form); - form.submit(); - } - return false; - }) - }) +Object.assign(Date.prototype, { + toGranite: function toGranite() { + var pad = function pad(number) { + if (number < 10) { + return "0" + number; + } + return number; + }; + return ( + this.getUTCFullYear() + + "-" + + pad(this.getUTCMonth() + 1) + + "-" + + pad(this.getUTCDate()) + + " " + + pad(this.getUTCHours()) + + ":" + + pad(this.getUTCMinutes()) + + ":" + + pad(this.getUTCSeconds()) + ); + } }); diff --git a/assets/js/amber.min.js b/assets/js/amber.min.js deleted file mode 100644 index 9ef3b2809..000000000 --- a/assets/js/amber.min.js +++ /dev/null @@ -1 +0,0 @@ -var Amber=function(e){function t(o){if(n[o])return n[o].exports;var i=n[o]={i:o,l:!1,exports:{}};return e[o].call(i.exports,i,i.exports,t),i.l=!0,i.exports}var n={};return t.m=e,t.c=n,t.d=function(e,n,o){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:o})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=0)}([function(e,t,n){"use strict";function o(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(t,"__esModule",{value:!0});var i=function(){function e(e,t){for(var n=0;n100}},{key:"_reconnect",value:function(){var e=this;clearTimeout(this.reconnectTimeout),this.reconnectTimeout=setTimeout(function(){e.reconnectTries++,e.connect(e.params),e._reconnect()},this._reconnectInterval())}},{key:"_reconnectInterval",value:function(){return[1e3,2e3,5e3,1e4][this.reconnectTries]||1e4}},{key:"_poll",value:function(){var e=this;this.pollingTimeout=setTimeout(function(){e._connectionIsStale()?e._reconnect():e._poll()},1e4)}},{key:"_startPolling",value:function(){clearTimeout(this.pollingTimeout),this._poll()}},{key:"_handlePing",value:function(){this.lastPing=s()}},{key:"_reset",value:function(){clearTimeout(this.reconnectTimeout),this.reconnectTries=0,this.attemptReconnect=!0,this._startPolling()}},{key:"connect",value:function(e){var t=this;this.params=e;var n={location:window.location.hostname,port:window.location.port,protocol:"https:"===window.location.protocol?"wss:":"ws:"};return e&&Object.assign(n,e),n.port&&(n.location+=":"+n.port),new Promise(function(e,o){t.ws=new WebSocket(n.protocol+"//"+n.location+t.endpoint),t.ws.onmessage=function(e){t.handleMessage(e)},t.ws.onclose=function(){t.attemptReconnect&&t._reconnect()},t.ws.onopen=function(){t._reset(),e()}})}},{key:"disconnect",value:function(){this.attemptReconnect=!1,clearTimeout(this.pollingTimeout),clearTimeout(this.reconnectTimeout),this.ws.close()}},{key:"channel",value:function(e){var t=new a(e,this);return this.channels.push(t),t}},{key:"handleMessage",value:function(e){if("ping"===e.data)return this._handlePing();var t=JSON.parse(e.data);this.channels.forEach(function(e){e.topic===t.topic&&e.handleMessage(t)})}}]),e}();e.exports={Socket:u},document.addEventListener("DOMContentLoaded",function(){document.querySelectorAll("a[data-method='delete']").forEach(function(e){e.addEventListener("click",function(t){t.preventDefault();var n=e.getAttribute("data-confirm")||"Are you sure?";if(confirm(n)){var o=document.createElement("form"),i=document.createElement("input");o.setAttribute("action",e.getAttribute("href")),o.setAttribute("method","POST"),i.setAttribute("type","hidden"),i.setAttribute("name","_method"),i.setAttribute("value","DELETE"),o.appendChild(i),document.body.appendChild(o),o.submit()}return!1})})})}]); \ No newline at end of file diff --git a/assets/js/webpack.config.js b/assets/js/webpack.config.js deleted file mode 100644 index fa8713944..000000000 --- a/assets/js/webpack.config.js +++ /dev/null @@ -1,26 +0,0 @@ -var webpack = require('webpack') - -module.exports = { - entry: './amber.js', - output: { - filename: 'amber.min.js', - library: 'Amber' - }, - module: { - loaders: [ - { - test: /\.js?$/, - exclude: /node_modules/, - loader: 'babel-loader', - query: { - presets: ['env'] - } - } - ] - }, - plugins: [ - new webpack.optimize.UglifyJsPlugin({ - compress: { warnings: false } - }) - ] -}; diff --git a/package.json b/package.json deleted file mode 100644 index 94feeafa5..000000000 --- a/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "amber", - "version": "0.8.0", - "description": "Front-end configuration for Amber Framework", - "private": true, - "author": "Amber", - "license": "MIT", - "scripts": { - "build": "cd assets/js && webpack --config webpack.config.js" - }, - "devDependencies": { - "babel-core": "^6.26.3", - "babel-loader": "^7.1.4", - "babel-preset-env": "^1.7.0", - "webpack": "^3.12.0" - } -} diff --git a/src/amber/cli/commands/database.cr b/src/amber/cli/commands/database.cr index dec41cf93..9314cbbcd 100644 --- a/src/amber/cli/commands/database.cr +++ b/src/amber/cli/commands/database.cr @@ -55,7 +55,7 @@ module Amber::CLI when "create" Micrate.logger.info create_database when "seed" - Helpers.run("crystal db/seeds.cr", wait: true, shell: true) + Helpers.run("crystal db/seeds.cr", shell: true).wait Micrate.logger.info "Seeded database" when "migrate" begin diff --git a/src/amber/cli/commands/pipelines.cr b/src/amber/cli/commands/pipelines.cr index 7805cf884..7f73c8157 100644 --- a/src/amber/cli/commands/pipelines.cr +++ b/src/amber/cli/commands/pipelines.cr @@ -1,6 +1,5 @@ require "cli" require "shell-table" -require "../helpers/sentry" module Amber::CLI class MainCommand < ::Cli::Supercommand diff --git a/src/amber/cli/commands/routes.cr b/src/amber/cli/commands/routes.cr index 4ede6475f..4a98ec11b 100644 --- a/src/amber/cli/commands/routes.cr +++ b/src/amber/cli/commands/routes.cr @@ -1,6 +1,5 @@ require "cli" require "shell-table" -require "../helpers/sentry" module Amber::CLI class MainCommand < ::Cli::Supercommand diff --git a/src/amber/cli/commands/watch.cr b/src/amber/cli/commands/watch.cr index d1eb0df8d..ba33b7085 100644 --- a/src/amber/cli/commands/watch.cr +++ b/src/amber/cli/commands/watch.cr @@ -1,28 +1,27 @@ require "cli" -require "../helpers/sentry" +require "../helpers/process_runner" module Amber::CLI class MainCommand < ::Cli::Supercommand command "w", aliased: "watch" - class Watch < Sentry::SentryCommand - command_name "watch" - + class Watch < ::Cli::Command class Options bool "--no-color", desc: "# Disable colored output", default: false help end class Help - header "Starts amber development server and rebuilds on file changes" - caption "# Starts amber development server and rebuilds on file changes" + header <<-HEADER + Starts amber development server and rebuilds on file changes. + See `.amber.yml` for more settings. + HEADER end def run CLI.toggle_colors(options.no_color?) - options.watch << "./config/**/*.cr" - options.watch << "./src/views/**/*.slang" - super + process_runner = Helpers::ProcessRunner.new + process_runner.run end end end diff --git a/src/amber/cli/config.cr b/src/amber/cli/config.cr index 217d2bb65..3ca31585f 100644 --- a/src/amber/cli/config.cr +++ b/src/amber/cli/config.cr @@ -1,3 +1,5 @@ +require "yaml" + module Amber::CLI def self.config if File.exists? AMBER_YML @@ -5,14 +7,20 @@ module Amber::CLI else Config.new end + rescue ex : YAML::ParseException + logger.error "Couldn't parse #{AMBER_YML} file", "Watcher", :red + exit 1 end class Config - property database : String = "pg" - property language : String = "slang" - property model : String = "granite" - property recipe : (String | Nil) = nil - property recipe_source : (String | Nil) = nil + alias Watch = Hash(String, Hash(String, Array(String))) + + getter database : String = "pg" + getter language : String = "slang" + getter model : String = "granite" + getter recipe : String? + getter recipe_source : String? + getter watch : Watch? def initialize end @@ -21,8 +29,9 @@ module Amber::CLI database: {type: String, default: "pg"}, language: {type: String, default: "slang"}, model: {type: String, default: "granite"}, - recipe: String | Nil, - recipe_source: String | Nil + recipe: String?, + recipe_source: String?, + watch: Watch? ) end end diff --git a/src/amber/cli/helpers/file_watcher.cr b/src/amber/cli/helpers/file_watcher.cr new file mode 100644 index 000000000..28dbcdce7 --- /dev/null +++ b/src/amber/cli/helpers/file_watcher.cr @@ -0,0 +1,19 @@ +module Amber + class FileWatcher + @file_timestamps = {} of String => String + + private def get_timestamp(file : String) + File.info(file).modification_time.to_s("%Y%m%d%H%M%S") + end + + def scan_files(files) + Dir.glob(files) do |file| + timestamp = get_timestamp(file) + if @file_timestamps[file]? != timestamp + @file_timestamps[file] = timestamp + yield file + end + end + end + end + end diff --git a/src/amber/cli/helpers/helpers.cr b/src/amber/cli/helpers/helpers.cr index f7e9641be..3016254af 100644 --- a/src/amber/cli/helpers/helpers.cr +++ b/src/amber/cli/helpers/helpers.cr @@ -33,11 +33,7 @@ module Amber::CLI::Helpers File.write("./config/application.cr", application.gsub("require \"amber\"", replacement)) end - def self.run(command, wait = true, shell = true) - if wait - Process.run(command, shell: shell, output: Process::Redirect::Inherit, error: Process::Redirect::Inherit) - else - Process.new(command, shell: shell, output: Process::Redirect::Inherit, error: Process::Redirect::Inherit) - end + def self.run(command, shell = true, input = Process::Redirect::Inherit, output = Process::Redirect::Inherit, error = Process::Redirect::Inherit) + Process.new(command, shell: shell, input: input, output: output, error: error) end end diff --git a/src/amber/cli/helpers/process_runner.cr b/src/amber/cli/helpers/process_runner.cr index b90b95b1e..28af26056 100644 --- a/src/amber/cli/helpers/process_runner.cr +++ b/src/amber/cli/helpers/process_runner.cr @@ -1,114 +1,217 @@ +require "http" require "./helpers" +require "../config" +require "./file_watcher" +require "../../exceptions/exception_page" -module Sentry - class ProcessRunner - property processes = [] of Process - property process_name : String - property files = [] of String - @logger : Amber::Environment::Logger - FILE_TIMESTAMPS = {} of String => String - - def initialize( - @process_name : String, - @build_command : String, - @run_command : String, - @build_args : Array(String) = [] of String, - @run_args : Array(String) = [] of String, - files = [] of String, - @logger = Amber::CLI.logger - ) - @files = files - @npm_process = false - @app_running = false +module Amber::CLI::Helpers + include Environment + + struct ProcessRunner + PROCESSES = [] of {Process, String} + + @host : String + @port : Int32 + @file_watcher : FileWatcher + + def initialize + @watch_running = false + @wait_build = Channel(Bool).new + @server_files_changed = false + @notify_counter = 0 + @notify_counter_channel = Channel(Int32).new + @notify_channel = Channel(Nil).new + @host = Helpers.settings.host + @port = Helpers.settings.port + @file_watcher = FileWatcher.new + + at_exit do + kill_processes + end + + Signal::INT.trap do + Signal::INT.reset + exit + end end def run - loop do - scan_files - sleep 1 + if watch_object = CLI.config.watch + run_watcher(watch_object) + else + error "Can't find watch settings, please check your .amber.yml file" + exit 1 end + rescue ex : KeyError + error "Error in watch configuration. #{ex.message}" + exit 1 end - # Compiles and starts the application - def start_app - build_result = build_app_process - if build_result.is_a? Process::Status - if build_result.success? - stop_all_processes - create_all_processes - @app_running = true - elsif !@app_running - log "Compile time errors detected. Shutting down..." - exit 1 + private def run_watcher(watch_object) + watch_object.each do |key, value| + next if key == "client" + files = value["files"] + commands = value["commands"] + if key != "server" + @notify_counter += 1 end + spawn watcher(key, files, commands) end + @notify_counter_channel.send @notify_counter + @notify_counter = @notify_counter_channel.receive + sleep end - private def scan_files + private def watcher(key, files, commands) + if key != "server" + @notify_channel.receive + end + if files.empty? + commands.each do |command| + run_command(command, key) + end + else + loop do + scan_files(key, files, commands) + @watch_running = true if key == "server" + sleep 1 + end + end + end + + private def scan_files(key, files, commands) file_counter = 0 - Dir.glob(files) do |file| - timestamp = get_timestamp(file) - if FILE_TIMESTAMPS[file]? != timestamp - if @app_running - log "File changed: #{file.colorize(:light_gray)}" - end - FILE_TIMESTAMPS[file] = timestamp - file_counter += 1 + @file_watcher.scan_files(files) do |file| + if @watch_running + debug "File changed: #{file}" end + file_counter += 1 end if file_counter > 0 - log "Watching #{file_counter} files (server reload)..." - start_app + debug "Watching #{file_counter} #{key} files" + kill_processes(key) + commands.each do |command| + if key == "server" && command == commands.first? + run_build_command(command, commands) + else + run_command(command, key) + end + end end end - private def stop_all_processes - log "Terminating app #{project_name}..." - @processes.each do |process| - process.kill unless process.terminated? + private def check_directories + Dir.mkdir_p("bin") + if !Dir.exists?("lib") + error "You need to install dependencies first, execute `shards install`" + exit 1 end - processes.clear end - private def create_all_processes - process = create_watch_process - @processes << process if process.is_a? Process - unless @npm_process - create_npm_process - @npm_process = true + private def run_build_command(command, commands) + check_directories + next_server_commands_range = (1...commands.size) + info "Building project #{Helpers.settings.name.colorize(:light_cyan)}" + spawn do + error_io = IO::Memory.new + process = Helpers.run(command, error: error_io) + PROCESSES << {process, "server"} + loop do + if process.terminated? + exit_status = process.wait.exit_status + if error_io.empty? + if exit_status.zero? + if @watch_running + kill_processes("server") + else + notify_next_processes + end + next_server_commands_range.each { @wait_build.send true } + else + next_server_commands_range.each { @wait_build.send false } + end + else + handle_error(error_io.to_s) + next_server_commands_range.each { @wait_build.send false } + end + break + end + sleep 1 + end end end - private def build_app_process - log "Building project #{project_name}..." - Amber::CLI::Helpers.run(@build_command) + private def notify_next_processes + notify_counter = @notify_counter_channel.receive + notify_counter.times { @notify_channel.send nil } + @notify_counter_channel.send 0 end - private def create_watch_process - log "Starting #{project_name}..." - Amber::CLI::Helpers.run(@run_command, wait: false, shell: false) + private def run_command(command, key) + if key == "server" + spawn do + build_sucess? = @wait_build.receive + if build_sucess? + error_io = IO::Memory.new + process = Helpers.run(command, error: error_io) + PROCESSES << {process, "server"} + loop do + if process.terminated? + unless error_io.empty? + handle_error(error_io.to_s) + end + break + end + sleep 1 + end + end + end + else + process = Helpers.run(command) + PROCESSES << {process, key} + end end - private def create_npm_process - node_log "Installing dependencies..." - Amber::CLI::Helpers.run("npm install --loglevel=error && npm run watch", wait: false) - node_log "Watching public directory" + private def kill_processes(key = nil) + PROCESSES.each do |process, owner| + if process.terminated? + PROCESSES.delete(process) + elsif owner == key || key.nil? + process.kill + end + end + end + + private def error_server(error_output) + HTTP::Server.new do |context| + error_id = Digest::MD5.hexdigest(error_output) + context.response.content_type = "text/html" + context.response.status_code = 500 + context.response.headers["Client-Reload"] = [error_id] + context.response.print Amber::Exceptions::ExceptionPageServer.new(context, error_output, error_id).to_s + end end - private def get_timestamp(file : String) - File.info(file).modification_time.to_s("%Y%m%d%H%M%S") + private def handle_error(error_output) + kill_processes("server") + puts error_output + new_error_server = Process.fork do + error_server(error_output).listen(@host, @port, reuse_port: true) + end + PROCESSES << {new_error_server, "server"} + error "A server error has been detected see the output above, use CTRL+C to exit" end - private def project_name - process_name.capitalize.colorize(:white) + private def debug(msg) + CLI.logger.debug msg, "Watcher" end - private def log(msg) - @logger.info msg, "Watcher", :light_gray + private def info(msg) + CLI.logger.info msg, "Watcher", :light_cyan end - private def node_log(msg) - @logger.info msg, "NodeJS", :dark_gray + private def error(msg) + CLI.logger.error msg, "Watcher", :light_red end end end diff --git a/src/amber/cli/helpers/sentry.cr b/src/amber/cli/helpers/sentry.cr deleted file mode 100644 index 8c37deff4..000000000 --- a/src/amber/cli/helpers/sentry.cr +++ /dev/null @@ -1,93 +0,0 @@ -require "cli" -require "yaml" -require "./process_runner" - -module Sentry - class SentryCommand < Cli::Command - command_name "sentry" - SHARD_YML = "shard.yml" - DEFAULT_NAME = "[process_name]" - - class Options - def self.defaults - name = Options.get_name - { - name: name, - process_name: "./bin/#{name}", - build: "mkdir -p bin && crystal build ./src/#{name}.cr -o bin/#{name}", - watch: ["./src/**/*.cr", "./src/**/*.ecr"], - } - end - - def self.get_name - if File.exists?(SHARD_YML) && - (yaml = YAML.parse(File.read SHARD_YML)) && - (name = yaml["name"]?) - name.as_s - else - DEFAULT_NAME - end - end - - string %w(-n --name), desc: "Sets the name of the app process", - default: Options.defaults[:name] - - string %w(-b --build), desc: "Overrides the default build command", - default: Options.defaults[:build] - - string "--build-args", desc: "Specifies arguments for the build command" - - string %w(-r --run), desc: "Overrides the default run command", - default: Options.defaults[:process_name] - - string "--run-args", desc: "Specifies arguments for the run command" - - array %w(-w --watch), - desc: "Overrides default files and appends to list of watched files", - default: Options.defaults[:watch] - - bool %w(-i --info), - desc: "Shows the values for build/run commands, build/run args, and watched files", - default: false - - help - end - - def run - if options.info? - puts <<-INFO - name: #{options.name?} - build: #{options.build?} - build args: #{options.build_args?} - run: #{options.run?} - run args: #{options.run_args?} - files: #{options.watch} - INFO - exit! code: 0 - end - - build_args = if ba = options.build_args? - ba.split " " - else - [] of String - end - run_args = if ra = options.run_args? - ra.split " " - else - [] of String - end - - process_runner = Sentry::ProcessRunner.new( - process_name: options.name, - build_command: options.build, - run_command: options.run, - build_args: build_args, - run_args: run_args, - files: options.watch, - logger: Amber::CLI.logger - ) - - process_runner.run - end - end -end diff --git a/src/amber/cli/recipes/recipe.cr b/src/amber/cli/recipes/recipe.cr index cb9afb45d..54db651d4 100644 --- a/src/amber/cli/recipes/recipe.cr +++ b/src/amber/cli/recipes/recipe.cr @@ -58,7 +58,7 @@ module Amber::Recipes App.new(name, options.d, options.t, options.m, options.r).render(directory, list: true, color: true) if options.deps? info "Installing Dependencies" - Amber::CLI::Helpers.run("cd #{name} && shards update") + Amber::CLI::Helpers.run("cd #{name} && shards update").wait end end when "controller" diff --git a/src/amber/cli/templates/app/.amber.yml.ecr b/src/amber/cli/templates/app/.amber.yml.ecr index 0214f6af2..f45f190fe 100644 --- a/src/amber/cli/templates/app/.amber.yml.ecr +++ b/src/amber/cli/templates/app/.amber.yml.ecr @@ -2,3 +2,44 @@ type: app database: <%= @database %> language: <%= @language %> model: <%= @model %> +watch: + server: # required: the first command for this task is blocking + files: + - "src/**/*.cr" + - "src/**/*.<%= @language %>" + - "config/**/*.cr" + commands: + - "crystal build -o bin/<%= @name %> src/<%= @name %>.cr -p --no-color" + - "bin/<%= @name %>" + + client: # optional: these files changes trigger browser reloading + files: + - "public/**/*" + commands: [] + + amber: # optional: Amber.Socket client, a[data-method] handler, and Date prototype.toGranite + files: [] + commands: + - "cp lib/amber/assets/js/amber.js public/dist/amber.js" + + js: # optional: concat and copy js files to public dir + files: + - "src/assets/javascripts/*.js" + commands: + - "cat src/assets/javascripts/*.js > public/dist/main.bundle.js" + + css: # optional: concat and copy css files to public dir + files: + - "src/assets/stylesheets/*.css" + commands: + - "cat src/assets/stylesheets/*.css > public/dist/main.bundle.css" + + images: # optional: concat and copy images to public dir + files: + - "src/assets/images/*" + commands: + - "cp -r src/assets/images public/" + + mytask: # Minimal valid task example + files: [] # Tasks with empty "files" execute "commands" just once. + commands: [] # Tasks with empty "commands" are ignored, except "client" task. diff --git a/src/amber/cli/templates/app/config/webpack/common.js b/src/amber/cli/templates/app/config/webpack/common.js deleted file mode 100644 index 336418eb8..000000000 --- a/src/amber/cli/templates/app/config/webpack/common.js +++ /dev/null @@ -1,69 +0,0 @@ -const webpack = require('webpack'); -const path = require('path'); -const ExtractTextPlugin = require('extract-text-webpack-plugin'); - -let config = { - entry: { - 'main.bundle.js': './src/assets/javascripts/main.js', - 'main.bundle.css': './src/assets/stylesheets/main.scss' - }, - output: { - filename: '[name]', - path: path.resolve(__dirname, '../../public/dist'), - publicPath: '/dist' - }, - resolve: { - alias: { - amber: path.resolve(__dirname, '../../lib/amber/assets/js/amber.js') - } - }, - module: { - rules: [ - { - test: /\.css$/, - exclude: /node_modules/, - use: ExtractTextPlugin.extract({ - fallback: 'style-loader', - use: 'css-loader' - }) - }, - { - test: /\.scss$/, - exclude: /node_modules/, - use: ExtractTextPlugin.extract({ - fallback: 'style-loader', - use: ['css-loader', 'sass-loader'] - }) - }, - { - test: /\.(png|svg|jpg|gif)$/, - exclude: /node_modules/, - use: [ - 'file-loader?name=/images/[name].[ext]' - ] - }, - { - test: /\.(woff|woff2|eot|ttf|otf)$/, - exclude: /node_modules/, - use: [ - 'file-loader?name=/[name].[ext]' - ] - }, - { - test: /\.js?$/, - exclude: /node_modules/, - loader: 'babel-loader', - query: { - presets: ['env'] - } - } - ] - }, - plugins: [ - new ExtractTextPlugin('main.bundle.css'), - ], - // For more info about webpack logs see: https://webpack.js.org/configuration/stats/ - stats: 'errors-only' -}; - -module.exports = config; diff --git a/src/amber/cli/templates/app/config/webpack/development.js b/src/amber/cli/templates/app/config/webpack/development.js deleted file mode 100644 index 3ec0d95ef..000000000 --- a/src/amber/cli/templates/app/config/webpack/development.js +++ /dev/null @@ -1,7 +0,0 @@ -const webpack = require('webpack'); -const merge = require('webpack-merge'); -const common = require('./common.js'); - -module.exports = merge(common, { - devtool: 'inline-source-map' -}); diff --git a/src/amber/cli/templates/app/config/webpack/production.js b/src/amber/cli/templates/app/config/webpack/production.js deleted file mode 100644 index 4a44b4715..000000000 --- a/src/amber/cli/templates/app/config/webpack/production.js +++ /dev/null @@ -1,11 +0,0 @@ -const webpack = require('webpack'); -const merge = require('webpack-merge'); -const common = require('./common.js'); - -module.exports = merge(common, { - plugins: [ - new webpack.optimize.UglifyJsPlugin({ - compress: { warnings: false } - }) - ] -}); diff --git a/src/amber/cli/templates/app/package.json.ecr b/src/amber/cli/templates/app/package.json.ecr deleted file mode 100644 index ab85cde51..000000000 --- a/src/amber/cli/templates/app/package.json.ecr +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "<%= @name %>", - "version": "0.1.0", - "description": "<%= display_name %> with Amber", - "private": true, - "author": "<%= @author %>", - "license": "UNLICENSED", - "scripts": { - "build": "webpack --config config/webpack/development.js", - "watch": "webpack -w --config config/webpack/development.js", - "release": "webpack -p --config config/webpack/production.js", - "test": "echo \"No test specified\" && exit 1" - }, - "devDependencies": { - "babel-core": "^6.26.3", - "babel-loader": "^7.1.4", - "babel-preset-env": "^1.6.1", - "css-loader": "^0.28.11", - "file-loader": "^1.1.11", - "node-sass": "^4.9.0", - "sass-loader": "^7.0.1", - "style-loader": "^0.21.0", - "webpack": "^3.12.0", - "webpack-merge": "^4.1.2", - "extract-text-webpack-plugin": "^3.0.2" - } -} diff --git a/src/amber/cli/templates/app/public/js/amber_reload.js b/src/amber/cli/templates/app/public/js/amber_reload.js new file mode 100644 index 000000000..b5b49b40b --- /dev/null +++ b/src/amber/cli/templates/app/public/js/amber_reload.js @@ -0,0 +1,53 @@ +if ('WebSocket' in window) { + (function () { + /** + * Allows to reload the browser when the server connection is lost + */ + function tryReload() { + var request = new XMLHttpRequest(); + request.open('GET', window.location.href, true); + request.onreadystatechange = function () { + if (request.readyState == 4) { + if (request.status == 0) { + setTimeout(function () { + tryReload(); + }, 1000) + } else { + window.location.reload(); + } + } + }; + request.send(); + } + /** + * Listen server file reload + */ + function refreshCSS() { + var sheets = [].slice.call(document.getElementsByTagName('link')); + var head = document.getElementsByTagName('head')[0]; + for (var i = 0; i < sheets.length; ++i) { + var elem = sheets[i]; + var rel = elem.rel; + if (elem.href && typeof rel != 'string' || rel.length == 0 || rel.toLowerCase() == 'stylesheet') { + head.removeChild(elem); + var url = elem.href.replace(/(&|\\?)_cacheOverride=\\d+/, ''); + elem.href = url + (url.indexOf('?') >= 0 ? '&' : '?') + '_cacheOverride=' + (new Date().valueOf()); + head.appendChild(elem); + } + } + } + var protocol = window.location.protocol === 'http:' ? 'ws://' : 'wss://'; + var address = protocol + window.location.host + '/client-reload'; + var socket = new WebSocket(address); + socket.onmessage = function (msg) { + if (msg.data == 'reload') { + window.location.reload(); + } else if (msg.data == 'refreshcss') { + refreshCSS(); + } + }; + socket.onclose = function () { + tryReload(); + } + })(); +} diff --git a/src/amber/cli/templates/app/src/views/layouts/application.ecr.ecr b/src/amber/cli/templates/app/src/views/layouts/application.ecr.ecr index 17c9b245b..fd7cb6301 100644 --- a/src/amber/cli/templates/app/src/views/layouts/application.ecr.ecr +++ b/src/amber/cli/templates/app/src/views/layouts/application.ecr.ecr @@ -37,6 +37,7 @@ + <% if Amber.env.development? %><% end %> diff --git a/src/amber/cli/templates/app/src/views/layouts/application.slang.ecr b/src/amber/cli/templates/app/src/views/layouts/application.slang.ecr index f9342623a..34c919d85 100644 --- a/src/amber/cli/templates/app/src/views/layouts/application.slang.ecr +++ b/src/amber/cli/templates/app/src/views/layouts/application.slang.ecr @@ -26,4 +26,6 @@ html script src="https://code.jquery.com/jquery-3.3.1.min.js" script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.0/umd/popper.min.js" script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/js/bootstrap.min.js" + - if Amber.env.development? + script src="/js/amber_reload.js" script src="/dist/main.bundle.js" diff --git a/src/amber/cli/templates/error.cr b/src/amber/cli/templates/error.cr index cf60c685b..a997136f2 100644 --- a/src/amber/cli/templates/error.cr +++ b/src/amber/cli/templates/error.cr @@ -14,7 +14,7 @@ module Amber::CLI end private def add_plugs - add_plugs :web, "plug Amber::Pipe::Error.new" + add_plugs :web, "plug #{class_name}.new" end private def add_dependencies diff --git a/src/amber/cli/templates/error/spec/controllers/{{name}}_controller_spec.cr.ecr b/src/amber/cli/templates/error/spec/controllers/{{name}}_controller_spec.cr.ecr index d0d386c69..d33ab4914 100644 --- a/src/amber/cli/templates/error/spec/controllers/{{name}}_controller_spec.cr.ecr +++ b/src/amber/cli/templates/error/spec/controllers/{{name}}_controller_spec.cr.ecr @@ -35,10 +35,10 @@ class <%= class_name %>ControllerTest < GarnetSpec::Controller::Test def initialize @handler = Amber::Pipe::Pipeline.new @handler.build :web do - plug Amber::Pipe::Error.new + plug <%= class_name %>.new end @handler.build :static do - plug Amber::Pipe::Error.new + plug <%= class_name %>.new end @handler.prepare_pipelines end diff --git a/src/amber/cli/templates/error/src/pipes/error.cr.ecr b/src/amber/cli/templates/error/src/pipes/error.cr.ecr index 2f730fdcd..a1769195d 100644 --- a/src/amber/cli/templates/error/src/pipes/error.cr.ecr +++ b/src/amber/cli/templates/error/src/pipes/error.cr.ecr @@ -1,25 +1,25 @@ -module Amber - module Pipe - # The Error pipe catches RouteNotFound and returns a 404. It responds based - # on the `Accepts` header as JSON or HTML. It also catches any runtime - # Exceptions and returns a backtrace in text/html format. - class Error < Base - def call(context : HTTP::Server::Context) - raise Amber::Exceptions::RouteNotFound.new(context.request) unless context.valid_route? - call_next(context) - rescue ex : Amber::Exceptions::Forbidden - context.response.status_code = 403 - error = <%= class_name %>Controller.new(context, ex) - context.response.print(error.forbidden) - rescue ex : Amber::Exceptions::RouteNotFound - context.response.status_code = 404 - error = <%= class_name %>Controller.new(context, ex) - context.response.print(error.not_found) - rescue ex : Exception - context.response.status_code = 500 - error = <%= class_name %>Controller.new(context, ex) - context.response.print(error.internal_server_error) - end - end +class <%= class_name %> < Amber::Pipe::Error + def error(context, ex : ValidationFailed | InvalidParam) + context.response.status_code = 400 + action = <%= class_name %>Controller.new(context, ex) + context.response.print(action.bad_request) + end + + def error(context, ex : Forbidden) + context.response.status_code = 403 + action = <%= class_name %>Controller.new(context, ex) + context.response.print(action.forbidden) + end + + def error(context, ex : RouteNotFound) + context.response.status_code = 404 + action = <%= class_name %>Controller.new(context, ex) + context.response.print(action.not_found) + end + + def error(context, ex) + context.response.status_code = 500 + action = <%= class_name %>Controller.new(context, ex) + context.response.print(action.internal_server_error) end end diff --git a/src/amber/cli/templates/template.cr b/src/amber/cli/templates/template.cr index 416b14de1..84e0f284d 100644 --- a/src/amber/cli/templates/template.cr +++ b/src/amber/cli/templates/template.cr @@ -51,7 +51,7 @@ module Amber::CLI App.new(name, options.d, options.t, options.m).render(directory, list: true, color: true) if options.deps? info "Installing Dependencies" - Helpers.run("cd #{name} && shards update") + Helpers.run("cd #{name} && shards update").wait end end when "migration" diff --git a/src/amber/controller/error.cr b/src/amber/controller/error.cr index d75889afe..bce90fa7a 100644 --- a/src/amber/controller/error.cr +++ b/src/amber/controller/error.cr @@ -1,4 +1,5 @@ require "./base" +require "../exceptions/exception_page" module Amber::Controller class Error < Base @@ -7,49 +8,38 @@ module Amber::Controller @context.response.content_type = content_type end - def not_found - response_format(@ex.message) + def bad_request + response_format end - def internal_server_error - response_format("ERROR: #{internal_server_error_message}") + def forbidden + response_format end - def forbidden - response_format(@ex.message) + def not_found + response_format + end + + def internal_server_error + response_format end private def content_type if context.request.headers["Accept"]? request.headers["Accept"].split(",").first else - "text/html" + "text/plain" end end - private def internal_server_error_message - # IMPORTANT: #inspect_with_backtrace will fail in some situations which breaks the tests. - # Even if you call @ex.callstack you'll notice that backtrace is nil. - # #backtrace? is supposed to be safe but it exceptions anyway. - # Please don't remove this without verifying that crystal core has been fixed first. - @ex.inspect_with_backtrace - rescue ex : IndexError - @ex.message - rescue ex - <<-ERROR - Original Error: #{@ex.message} - Error during 'inspect_with_backtrace': #{ex.message} - ERROR - end - - private def response_format(message) + private def response_format case content_type when "application/json" - {"error": message}.to_json + {"error": @ex.message}.to_json when "text/html" - "
#{message}
" + Amber::Exceptions::ExceptionPageClient.new(@context, @ex).to_s else - message + @ex.message end end end diff --git a/src/amber/exceptions/exception_page.cr b/src/amber/exceptions/exception_page.cr new file mode 100644 index 000000000..39e1c342f --- /dev/null +++ b/src/amber/exceptions/exception_page.cr @@ -0,0 +1,113 @@ +require "ecr" +require "../../environment/settings" + +module Amber::Exceptions + include Environment + + class ExceptionPage + struct Frame + property app : String, + args : String, + context : String, + index : Int32, + file : String, + line : Int32, + info : String, + snippet = [] of Tuple(Int32, String, Bool) + + def initialize(@app, @context, @index, @file, @args, @line, @info, @snippet) + end + end + + @params : Hash(String, String) + @headers : Hash(String, Array(String)) + @session : Hash(String, HTTP::Cookie) + @method : String + @path : String + @message : String + @query : String + @reload_code = "" + @frames = [] of Frame + + def initialize(context : HTTP::Server::Context, @message : String) + @params = context.request.query_params.to_h + @headers = context.response.headers.to_h + @method = context.request.method + @path = context.request.path + @url = "#{context.request.host_with_port}#{context.request.path}" + @query = context.request.query_params.to_s + @session = context.response.cookies.to_h + end + + def generate_frames_from(message : String) + generated_frames = [] of Frame + if frames = message.scan(/\s([^\s\:]+):(\d+)([^\n]+)/) + frames.each_with_index do |frame, index| + snippets = [] of Tuple(Int32, String, Bool) + file = frame[1] + filename = file.split('/').last + linenumber = frame[2].to_i + linemsg = "#{file}:#{linenumber}#{frame[3]}" + if File.exists?(file) + lines = File.read_lines(file) + lines.each_with_index do |code, codeindex| + if (codeindex + 1) <= (linenumber + 5) && (codeindex + 1) >= (linenumber - 5) + highlight = (codeindex + 1 == linenumber) ? true : false + snippets << {codeindex + 1, code, highlight} + end + end + end + context = "all" + app = case file + when .includes?("/crystal/") + "crystal" + when .includes?("/amber/") + "amber" + when .includes?("lib/") + "shards" + else + context = "app" + Amber.settings.name.as(String) + end + generated_frames << Frame.new(app, context, index, file, linemsg, linenumber, filename, snippets) + end + end + if self.class.name == "ExceptionPageServer" + generated_frames.reverse + else + generated_frames + end + end + + ECR.def_to_s "#{__DIR__}/exception_page.ecr" + end + + class ExceptionPageClient < ExceptionPage + EX_ECR_SCRIPT = "lib/amber/src/amber/exceptions/exception_page_client_script.js" + + def initialize(context : HTTP::Server::Context, @ex : Exception) + super(context, @ex.message) + @title = "Error #{context.response.status_code}" + @frames = generate_frames_from(@ex.inspect_with_backtrace) + @reload_code = File.read(File.join(Dir.current, EX_ECR_SCRIPT)) + end + end + + class ExceptionPageServer < ExceptionPage + def initialize(context : HTTP::Server::Context, message : String, @error_id : String) + super(context, message) + @title = "Build Error" + @method = "Server" + @path = Dir.current + @frames = generate_frames_from(message) + @reload_code = ExceptionPageServerScript.new(@error_id).to_s + end + end + + class ExceptionPageServerScript + def initialize(@error_id : String) + end + + ECR.def_to_s "#{__DIR__}/exception_page_server_script.js" + end +end diff --git a/src/amber/exceptions/exception_page.ecr b/src/amber/exceptions/exception_page.ecr new file mode 100644 index 000000000..869c14514 --- /dev/null +++ b/src/amber/exceptions/exception_page.ecr @@ -0,0 +1,860 @@ + +<%- +accent = "#e57310" +highlight = "#ebe1d7" +red_highlight = "#ffe5e5" +light_accent = "#c0bda0" +gray = "#807e60" +line_color = "#eee" +text_color = "#271708" +logo_uri = "data:image/png;base64,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" +monospace_font = "menlo, consolas, monospace" +-%> + + + + <% + details = @message.split('\n') + headline = details.first + %> + + <%= @title %> at <%= @method %> <%= @path %> - <%= headline %> + + + + + +
+ +
+
+ <%= @title %> + at <%= @method %> <%= @path %> +
+

<%= HTML.escape(headline).gsub("'", '\'').gsub(""", '"') %>

+
+ + See raw message + +
<%- details.each do |detail| -%><%= HTML.escape(detail).gsub("'", '\'').gsub(""", '"') %>
+<%- end -%>
+
+
+
+ <% if !@frames.empty? %> +
+
+ <% @frames.each do |frame| %> +
+ + + <%- if (snippet = frame.snippet) && !snippet.empty? -%> +
<%- snippet.each do |index, line, highlight| -%>
+<%= index %><%= HTML.escape(line.rstrip).gsub("'", '\'').gsub(""", '"') %>
+<%- end -%>
+
+ <%- else -%> +
No code available.
+ <%- end -%> + + <% if !frame.args.blank? %> +
+ + <% if app = frame.app %> + <%= app %> + <% end %> + <%= frame.info %> + +
<%= HTML.escape(frame.args).gsub("'", '\'').gsub(""", '"') %>
+
+ <% else %> +
+
+ <% if app = frame.app %> + <%= app %> + <% end %> + <%= frame.info %> +
+
+ <% end %> +
+ <% end %> +
+ +
+
+ + +
+ +
    + <% @frames.each do |frame| %> +
  • + +
  • + <% end %> +
+
+
+ <% end %> +
+ +
+ <% if @params && !@params.empty? %> +
+ Params + <% @params.each do |key, value| %> +
+
<%= key %>
+
<%= value.inspect %>
+
+ <% end %> +
+ <% end %> + +
+ Request info + +
+
URI:
+
<%= @url %>
+
+ +
+
Query string:
+
<%= @query %>
+
+
+ +
+ Headers + <% @headers.each do |key, value| %> +
+
<%= key %>
+
<%= value %>
+
+ <% end %> +
+ + <% if (session = @session) && !session.empty? %> +
+ Session + <% session.each do |key, value| %> +
+
<%= key %>
+
<%= value.inspect %>
+
+ <% end %> +
+ <% end %> +
+ + + + diff --git a/src/amber/exceptions/exception_page_client_script.js b/src/amber/exceptions/exception_page_client_script.js new file mode 100644 index 000000000..b5b49b40b --- /dev/null +++ b/src/amber/exceptions/exception_page_client_script.js @@ -0,0 +1,53 @@ +if ('WebSocket' in window) { + (function () { + /** + * Allows to reload the browser when the server connection is lost + */ + function tryReload() { + var request = new XMLHttpRequest(); + request.open('GET', window.location.href, true); + request.onreadystatechange = function () { + if (request.readyState == 4) { + if (request.status == 0) { + setTimeout(function () { + tryReload(); + }, 1000) + } else { + window.location.reload(); + } + } + }; + request.send(); + } + /** + * Listen server file reload + */ + function refreshCSS() { + var sheets = [].slice.call(document.getElementsByTagName('link')); + var head = document.getElementsByTagName('head')[0]; + for (var i = 0; i < sheets.length; ++i) { + var elem = sheets[i]; + var rel = elem.rel; + if (elem.href && typeof rel != 'string' || rel.length == 0 || rel.toLowerCase() == 'stylesheet') { + head.removeChild(elem); + var url = elem.href.replace(/(&|\\?)_cacheOverride=\\d+/, ''); + elem.href = url + (url.indexOf('?') >= 0 ? '&' : '?') + '_cacheOverride=' + (new Date().valueOf()); + head.appendChild(elem); + } + } + } + var protocol = window.location.protocol === 'http:' ? 'ws://' : 'wss://'; + var address = protocol + window.location.host + '/client-reload'; + var socket = new WebSocket(address); + socket.onmessage = function (msg) { + if (msg.data == 'reload') { + window.location.reload(); + } else if (msg.data == 'refreshcss') { + refreshCSS(); + } + }; + socket.onclose = function () { + tryReload(); + } + })(); +} diff --git a/src/amber/exceptions/exception_page_server_script.js b/src/amber/exceptions/exception_page_server_script.js new file mode 100644 index 000000000..b6059f6ac --- /dev/null +++ b/src/amber/exceptions/exception_page_server_script.js @@ -0,0 +1,21 @@ +var reload = document.querySelector('[role~="reload-toggle"]'); +var reloadTimeout; +recursiveReload(); +on(reload, 'click', reloadOnclick); +function recursiveReload() { + var request = new XMLHttpRequest(); + request.open('GET', window.location.href, true); + request.onreadystatechange = function () { + if (request.readyState === 4) { + var errorId = request.getResponseHeader('Client-Reload'); + if (errorId === '<%= @error_id %>') { + reloadTimeout = setTimeout(function () { + recursiveReload(); + }, 1000) + } else if (errorId === 'true') { + window.location.reload(); + } + } + }; + request.send(); +} diff --git a/src/amber/exceptions/exceptions.cr b/src/amber/exceptions/exceptions.cr index e0d246cef..80d18900a 100644 --- a/src/amber/exceptions/exceptions.cr +++ b/src/amber/exceptions/exceptions.cr @@ -3,13 +3,6 @@ require "colorize" module Amber module Exceptions class Base < Exception - getter status_code : Int32 = 500 - - def set_response(response) - response.headers["Content-Type"] = "text/plain" - response.print message - response.status_code = status_code - end end class Environment < Exception @@ -43,14 +36,12 @@ module Amber class RouteNotFound < Base def initialize(request) - @status_code = 404 super("The request was not found. #{request.method} - #{request.path}") end end class Forbidden < Base def initialize(message : String?) - @status_code = 403 super(message || "Action is Forbidden.") end end diff --git a/src/amber/pipes/error.cr b/src/amber/pipes/error.cr index aff74879d..b0086b0e7 100644 --- a/src/amber/pipes/error.cr +++ b/src/amber/pipes/error.cr @@ -4,21 +4,38 @@ module Amber # on the `Accepts` header as JSON or HTML. It also catches any runtime # Exceptions and returns a backtrace in text/html format. class Error < Base + include Amber::Exceptions + include Amber::Exceptions::Validator + def call(context : HTTP::Server::Context) - raise Amber::Exceptions::RouteNotFound.new(context.request) unless context.valid_route? + raise Amber::Exceptions::RouteNotFound.new(context.request) unless context.request.valid_route? call_next(context) - rescue ex : Amber::Exceptions::Forbidden + rescue ex + error(context, ex) + end + + def error(context, ex : ValidationFailed | InvalidParam) + context.response.status_code = 400 + action = Amber::Controller::Error.new(context, ex) + context.response.print(action.bad_request) + end + + def error(context, ex : Forbidden) context.response.status_code = 403 - error = Amber::Controller::Error.new(context, ex) - context.response.print(error.forbidden) - rescue ex : Amber::Exceptions::RouteNotFound + action = Amber::Controller::Error.new(context, ex) + context.response.print(action.forbidden) + end + + def error(context, ex : RouteNotFound) context.response.status_code = 404 - error = Amber::Controller::Error.new(context, ex) - context.response.print(error.not_found) - rescue ex : Exception + action = Amber::Controller::Error.new(context, ex) + context.response.print(action.not_found) + end + + def error(context, ex) context.response.status_code = 500 - error = Amber::Controller::Error.new(context, ex) - context.response.print(error.internal_server_error) + action = Amber::Controller::Error.new(context, ex) + context.response.print(action.internal_server_error) end end end