diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..163eb75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.cr] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10bd96f --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +/build/cvue.main.js +/build/vue-ssr-client-manifest.json +/build/vue-ssr-server-bundle.json +/docs/ +/lib/ +/bin/ +/.shards/ +*.dwarf +*.sublime-project +*.sublime-workspace +crystal-vue.sublime-project +crystal-vue.sublime-workspace + +# Libraries don't need dependency lock +/shard.lock +yarn.lock + +# node / yarn-related files +/node_modules/ + + diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..e3cbcda --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +10.9.0 \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ffc7b6a --- /dev/null +++ b/.travis.yml @@ -0,0 +1 @@ +language: crystal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ac5285d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019 Noah Lehmann-Haupt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b69ab5e --- /dev/null +++ b/README.md @@ -0,0 +1,514 @@ +# crystal-vue + + Crystal + Vue.js = :zap: + +## Introduction + +Crystal-vue allows you to use the full power of [Vue.js](https://vuejs.org/) components in your [Crystal](https://crystal-lang.org) web apps. It's a drop-in replacement for your view layer -- no more need for intermediate `.ecr` templates. With crystal-vue, you write your backend server code in Crystal, your frontend client code in JavaScript & HTML, and everything works together seamlessly...and fast. + +Pages are delivered to the client fully-rendered and SEO-friendly (like a traditional web app), but with the power and reactivity of Vue taking over once the page has loaded. + +This means you get the best of all worlds -- clients see lightning fast page loads (since the initial payload is pure HTML/CSS) yet still get the benefit of rich dynamic JavaScript-enabled components the load without stutters or flickers. + +And your job as a developer is easier: Instead of writing an intermediate view layer (typically `.ecr` or similar templates) which glues your server code & client code together, you write pure Vue components **once** with all your server-side data seamlessly blended in. Awesome, right? + +## Features + + * (`.vue`) SFC (single-file component) support + * Server-side rendering w/ variables interpolated in Crystal and/or JavaScript + * Client-side hydration for full-featured reactive pages + * Configurable layouts/templates + * Built-in support for [Amber](https://amberframework.org) (other frameworks coming soon) + +## Background + +Traditional web apps were mostly server-powered -- a client (browser) would make a request, the server would do some processing, render the page, and send down HTML, CSS, and maybe a little JavaScript to spice things up. Pages loaded lightning fast and SEO was simple (crawlers saw basically the same thing as users). + +With the advent of powerful JavaScript frameworks like Vue, much of the work shifted to the client. The server became a vessel for a client-loaded JavaScript bundle (and an API endpoint to communicate with that client) and your browser rendered most of what you'd see on the page via JavaScript. Web apps certainly became more powerful and dynamic (the notion of a Single Page App was born), but at the cost of higher initial load time, worse SEO (crawlers saw basically nothing since all the logic lived in JavaScript), and increased complexity (you now had to manage two view layers). + +Both React and Vue added Server Side Rendering support to mitigate this, but both have that support baked solely into Node (since it's JavaScript all the way down.) + +Crystal is awesome and a joy to use for server-side web processing. Vue is awesome and a joy to use for client-side reactivity and UI composability. Crystal-vue now lets you bring those joys together. This is ideal for folks who believe the heavy lifting of page rendering should (mostly) be done by the server and yet still want the reactivity and dynamism of a powerful client-side framework like Vue. + +#### THIS IS PREVIEW / EARLY ALPHA SOFTWARE + +**This is not much more than a proof-of-concept at the moment, but it does work! Standard warnings apply - it will likely break/crash in spectacular and ill-timed glory, so don't poke it, feed it past midnight, or use it for anything mission-critical (yet).** + +## Show Me The Money + +Start with a regular 'ol Amber controller & `.ecr` template + +````crystal +# home_controller.cr + +class HomeController < ApplicationController + def index + example_crystal_data = 1 + 1 + render("index.ecr") + end +end +```` + +````crystal +# index.ecr + +
+

Hello World!

+

This was rendered server-side (by crystal): <%= example_crystal_data %>

+
+```` + +`example_crystal_data` is simple arithmetic here, but imagine it's a database query in a more complex application). In this simple example, the basic `.ecr` template works perfectly. + +If you want to add some client-side reactivity and, say, a Vue component, things get more complex. You'll have to split up your view logic between this `.ecr` file (which becomes rendered HTML) and the Vue component (which doesn't get rendered until the client finishes loading), pass in `example_crystal_data` via props, load it via a backchannel API call, or use some other series of intermediate glue steps. + +Why not skip all of that and make the data available directly in the Vue component? Replace the `render` call with our new `vue_render` macro and move our server-side calculation into a special `@vue_context` object: + +````crystal +# home_controller.cr - crystal-vue powered + +class HomeController < ApplicationController + def index + @vue_context[:example_crystal_data] = 1 + 1 + vue_render(@vue_context) + end +end +```` + +Now instead of a server-rendered `.ecr` template + client-rendered Vue component, you skip the `.ecr` step entirely and put all of your view code (including CSS) into a single Vue component that's both server- and client-rendered: + +````vue +// Index.vue + + + + + + +```` + +Et voila: + + + +Ok that's not so exciting. And it's a bit more verbose. But there's more to this, I promise! The key is that number 2 is not just a static ASCII character loaded via HTML -- it's actually a fully reactive element of a live Vue component: + + + +A quick peek at the raw HTML shows our server's doing all the hard work (which is what servers were designed to do) and sending us a fully rendered page that, even if it were more complex, would load quickly and be SEO-friendly: + +````html +❯ curl localhost:3000 + + + + + + + +
+
+

Hello World!

+

This was rendered server-side (by crystal): 2

+
+
+ + + +```` + +There's a lot going on in there and we'll get into more detail later, but in short: Crystal-vue is taking the result of the server-side calculation (1 + 1), rendering the HTML, inserting a default layout, injecting the relevant CSS (scoped to this component), and compiling & linking to a client-side JS bundle. + +### Data Computation Options + +Crystal-vue gives you flexibility in where your data computation is done - on the server (in Crystal), on the server (in Node/JavaScript), or on the client (in Vue/JavaScript). It totally depends on the needs of your application, and you can mix-and-match. + +#### 1) Server-side: Crystal + +Our first example did the "work" (calculating 1 + 1) server-side, in crystal. Let's look at the other options... + +#### 2) Server-side: JavaScript + +Because Crystal-vue uses a live [NodeJS](https://nodejs.org/en/) process behind the scenes, you can choose to do computations in that context. This gives you access to the whole ecosystem of Node modules to use in your application as-needed. + +Changing our Vue component slightly: + +````vue + // Index.vue + + + + + +... + +```` + +Gives us, as expected: + + + +And again, this is all being done server-side, the raw HTML tells the same story: + +````html +❯ curl localhost:3000 + +... + +
+

Hello World!

+

This was rendered server-side (by crystal): 2

+

This was rendered server-side (by node): 4

+
+ +... + +```` + +#### 3) Client-side: JavaScript + +Thus far we haven't actually done any client-side DOM manipulation even though we're using a client-side reactive framework. Ironic, right? + +Of course you can have crystal-vue do pure client rendering if you want. We do this by taking advantage of how Vue component lifecycles work (the `mounted()` hook is not called when doing SSR, only on the client). + +Let's modify our Vue template once more: + +````vue + + + + +... +```` + +And we see: + + + +All looking good. Same with the raw HTML: + +````html +❯ curl localhost:3000 + +... + +
+

Hello World!

+

This was rendered server-side (by crystal): 2

+

This was rendered server-side (by node): 4

+

+
+ +... +```` + +Note our most recent addition is not being rendered on the server, as it should(n't). + +#### Server rendering + Client-side hydration + +Let's make this more interesting: We want to actually have some fun, live, dynamic stuff happen on the client (which is why we're using a framework like Vue in the first place, right?) + +Crystal-vue's hybrid rendering model that we saw in the first example (which exposes the power of both server- and client-side rendering) is called client-side hydration, and it's awesome. + +Our fun, live, dynamic stuff is going to be a....clock. Ok fine I'll come up with a better example later. But for now it's simple and shows off the concept. + +Our new Vue component: + +````vue + + + +```` + +Translating this to English, we are: + +* defining a reactive data element in `data()` called `current_time` that gets an initial value of `Date.now()` +* defining a method called `setCurrentTime()` that sets `current_time` to be `Date.now()` when called, and +* using the `mounted()` lifecycle hook to call `setCurrentTime()` every 100 ms when the component is mounted (client-only) + +When a request comes in, the server will render the component and send down the following (static) HTML, which represents a snapshot of the current time as of when the request came in: + +````html +❯ curl localhost:3000 + +... + +
+

The current time is: 1547520952139

+
+ +... +```` + +In your browser, the javascript bundle (along with Vue) will load and automatically understand that this is server-rendered Vue code. It then "hydrates" the static element and makes it dynamic, as such: + + + +Huzzah! Server-rendered initial view, reactive (hydrated) elements after load, and a rich dynamic (sort of) UI for the user, and all in a single piece of view code. Heaven. + +### Requirements + +* Crystal 0.27 +* Yarn 1.12+ +* Node 10.9+ + +The render server was built using node 10.9.X (in particular it uses the WHATWG URL Standard, which was added in Node 7+.) It doesn't need to do this, strictly-speaking, and if there's a compelling reason to support earlier versions of node I'm happy to make this change.) + +## Installation + +(Note: This is a bit more manual than I'd like. Automation to come.) + +Crystal-vue has been developed / tested with the [Amber](https://amberframework.org) web framework, but designed to work standalone as well. There's also no reason it won't work with [Lucky](https://luckyframework.org/), [Kemal](http://kemalcr.com/), etc. (but no work integrating with those has been done yet.) + +**1) Add crystal-vue to your application's `shard.yml` and run `shards install`:** + +```yaml +dependencies: + crystal-vue: + github: noahlh/crystal-vue + version: ~> 0.1.0 +``` + +**2) Add `crystal_vue_amber_init.cr` to `/config/initializers`.** [An example is provided.](/config/crystal_vue_amber_init.example.cr) You can name this file whatever you want, just so long as it gets called upon initialization. + +**3) Add a `routes.js` file to `/config`.** This should export an array of path/component mappings, for example: + +````javascript +// routes.js + +export default [ + { path: '/', component: 'home/Index' }, + { path: '/about', component: 'static/About' }, + { path: '/user/:userid', component: 'users/User' }, +] +```` + +This is almost exactly like the routes object you'd use with [vue-router](https://router.vuejs.org/guide/#html) and in fact uses that underneath. + +There is one small difference here, in that with vue-router you're specifying the actual component (that you've `import`'ed). Here, instead, you'll specify a string with the component name (and subdirectory off the main components folder). + +This is annoyingly redundant (since you've already specified your routes w/ controllers & actions in Amber), and also somewhat hard to reason about, so it's going on the chopping block soon and I'll figure out a more elegant solution. + +**4) Add a default layout.** This is the shell HTML into which your Vue components will be injected. You must include the magic comment `` where you'd like your components injected. + +````html + + + + + ... head ... + + + + + +```` + +TODO, high on the list: Variable interpolation in this layout so you can do dynamic titles, etc. + +**5) Include the helper `CrystalVue::Adapter::Amber` in your `application_controller.cr`.** This adds the `vue_render` macro and sets up the `@vue_context` object. + +````crystal + # application_controller.cr + + require "jasper_helpers" + + class ApplicationController < Amber::Controller::Base + include JasperHelpers ++ include CrystalVue::Adapter::Amber + end +```` + +**6) Add your `.vue` files to your views folder and get building!** + +## Usage Details + +### `vue_render` macro + +`vue_render(vue_context : CrystalVue::Context = nil, path : String? = nil, template : String? = nil)` + +Performs the render. This is to be called where you'd normally call `render` in your controllers. It doesn't need any parameters by default (it automatically extracts the path of the method calling it based on your Amber routes), but accepts the following optional parameters: + +**`vue_context : CrystalVue::Context`** - The Crystal-vue Amber helper creates a special Hash-like object called `@vue_context`. Any variables you'd like available in your Vue components go in here, using a Symbol key of the desired name. + +So if you want to access `example_crystal_data` in your vue component, assign the relevant value to `@vue_context[:example_crystal_data]`. + +Behind the scenes, crystal-vue sets up a [Vuex](https://vuex.vuejs.org/) store to be accessed in the Vue component. The crystal-rendered data (`example_crystal_data`) is automatically injected into that store (under the `crystal` namespace) and is available under `this.$store.state.crystal` in all of your Vue components. + +**`path : String?`** - If you need to manually specify which path you're rending (i.e. you're not in Amber), you can pass in a string parameter. In Amber this will be assigned a default value equal to the current Amber route the controller method is handling. + +**`template : String?`** - Which layout/template you'd like to render the component in. Uses the default template specified in the init file if none specified on render. + +### Why isn't the syntax `vue_render(Index.vue)`? + +With `.ecr` templates, you're explicitly rendering a specific template. Vue SSR doesn't quite work that way -- instead, you render a route, which is composed of a path and a component (this is defined in routes.js). Since the mapping between a controller's routes & actions is already established, specifically naming the Vue file would be redundant. + +This can be slightly confusing, I might consider making it a bit more explicit as this project develops (eliminate the need for a routes.js and build that mapping implicitly by looking at the rendered components in each controller action.) + +## Server Client Rendering (Node/JavaScript) + +[(From Vue's docs on SSR)](https://ssr.vuejs.org/guide/universal.html) + +> Since there are no dynamic updates, of all the lifecycle hooks, only `beforeCreate` and `created` will be called during SSR. This means any code inside other lifecycle hooks such as `beforeMount` or `mounted` will only be executed on the client. + +Outside the lifecycle hooks, anything in your `data` object will be rendered both server- and client-side (on initial load). + +There are some other considerations to keep in mind when writing components that will be used both on the server and client. [You can read more about those here.](https://ssr.vuejs.org/guide/universal.html) + +### Client-only rendering (Vue/JavaScript) + +If you want something rendered _only_ on the client and not in any server context, you can use any lifecycle method other than `beforeCreate` and `created`. [See the Vue docs for complete details on all lifecycle hooks.](https://vuejs.org/v2/api/#Options-Lifecycle-Hooks) + +## How Crystal-vue Works + +Crystal-vue launches and manages a local node process ([render-server.js](/src/server/render-server.js)) that runs a very lightweight HTTP server to perform Vue server-side rendering. The `vue_render` macro simply makes an HTTP call to that node server with any Crystal-computed variables as parameters. + +This server closely follows the model outlined in the [Vue SSR Rendering Guide](https://ssr.vuejs.org/) and adds in dynamic component loading and a few bits to handle the interaction with the crystal client. + +This is roughly the same architecture that AirBNB's [Hypernova](https://github.com/airbnb/hypernova) uses, and has some of the same tradeoffs. While there's definitely some overhead in making HTTP requests, it's still extremely fast: A simple page render from Amber takes about 3ms on a 2015 MBP, including the back-and-forth over HTTP. This is an order of magnitude slower than a base case Amber render (approx. 300 microseconds). But it's fast enough for now, though most certainly an area to explore for future optimization. + +The node process integrates Webpack (using the node API) to compile the Vue templates and setup the client & server bundles. In development, it runs `webpack.watch()` and will keep an eye out for changes to your `.vue` components. In a production, it runs `webpack.run()` and does the compilation once. + +I've tried to comment the code extensively, but I'm also writing this up in more detail mostly as a high-level reference *ahem* and reminder to myself *cough* about exactly how the various internals work. + +### Environment Variables + +A few environment variables are set prior to loading the render server. You shouldn't need to touch any of this as Crystal-vue handles this automatically. + +`VUE_COMPONENT_DIR` - **Required** - The directory where the vue components for the project can be found. + +`VUE_ROUTES_FILE` - **Required** - The file (in .json format) that contains the mapping between routes and Vue components. + +`VUE_TEMPLATE_DIR` - this is used to specify the directory from which to load layouts/templates. The terminology is a bit confusing - Vue refers to templates (the standalone files) the way that Amber/rails/etc. refer to layouts - a structured HTML document in which to inject the rendered page content. Layout is probably a better term to use since templates also refer to the HTML portion of an actual Vue component. + +`NODE_PORT` - The port on which the render server runs and communicates with the Crystal-vue render client. This is for internal communications only and defaults to port 4000. + +TODO: Auto-detect if the port is in use and find an open one. Right now things crash and burn if something is already on port 4000, including another instance of `render-server.js`. + +`NODE_ENV` - The environment in which to run the node server (development, test, prod, etc.). Defaults to 'development' + +## Project Status + +My goal/philosophy is to release early, release often, and get as much user feedback as early in the process as possible, so even though the perfectionist in me would like to spend another 6 years improving this, by then it'll be 2024 and who knows we might all be living underwater. No time like the present. + +## Roadmap + +Short-term goals: + +- [x] Release this embarrassing 0.1.0 version +- [ ] Get usage --> expose bugs +- [ ] Fix reloading issues (not everything restarts properly) +- [ ] Figure out Hot Module Reloading (HMR) +- [ ] Automate installation +- [ ] Get example / demo project live +- [ ] Improve error handling +- [ ] Write more comprehensive tests +- [ ] Figure out CSS splitting +- [ ] Add support for preprocessors in .vue files (pug, SCSS, stylus, etc.) + +Longer-term goals: + +- Performance improvements +- Port to Ruby/Rails +- Remove need for a separate node process / http (evaluate JS in crystal, compile templates without Webpack?) + +## Contributions / Critique Wanted! + +This has been a solo project of mine and I would love nothing more than to get feedback on the code / improvements / contributions. I've found by far the best way to learn and level-up development skills is to have others review code that you've wrestled with. + +That is to say, don't hold back. Report things that are broken, help improve some of the code, or even just fix some typos. Everyone (at all skill levels) is welcome. + +1. Fork it () +2. Create your feature/bugfix branch (`git checkout -b omg-this-fixed-so-many-bugs`) +3. Make magic (and don't forget to write tests!) +4. Commit your changes (`git commit -am 'Made '`) +5. Push to the branch (`git push origin omg-this-fixed-so-many-bugs`) +6. Create a new Pull Request +7. ::party:: + +## Contributors + +- Noah Lehmann-Haupt (nlh@nlh.me / [noahlh](https://github.com/noahlh)) - creator, maintainer \ No newline at end of file diff --git a/config/crystal_vue_amber_init.example.cr b/config/crystal_vue_amber_init.example.cr new file mode 100644 index 0000000..e801eaf --- /dev/null +++ b/config/crystal_vue_amber_init.example.cr @@ -0,0 +1,46 @@ +require "crystal-vue" + +# Setup the environment. In development, Crystal-vue uses webpack.watch() to auto-rebuild the +# SSR bundle & client manifest when any of the components change. In production, it calls +# webpack.run() once to build the files. + +ENV["CRYSTAL_VUE"] = Amber.env.to_s + +# Copy Amber logger settings, then change the color of our logger so it stands out + +crystal_vue_logger = Amber.settings.logger.dup +crystal_vue_logger.progname = "Vue SSR" +crystal_vue_logger.color = :green + +# The main init... + +CrystalVue.init( + # logger - optional; A Logger for the node processes to pipe stdout/stderr. We set this up above. + logger: crystal_vue_logger, + + # component_dir - required; Where our .vue components live + component_dir: "#{Dir.current}/src/views/", + + # routes_file - required; A small JSON object outlining our front-end routes. Should match our server-side + # routes. This will eventually be auto-generated (coming soon!) since it's annoyingly redundant. + routes_file: "#{Dir.current}/config/routes.js", + + # port - optional; Choose the port that we'll use to communicate with the node process. Defaults to 4000. + port: 4000, + + # template_dir - optional; If you're going to use custom templates, you'll need to specify where they live. + # otherwise crystal-vue will use a pretty straightforward default template. + template_dir: "#{Dir.current}/src/views/layouts/", + + # default_template - optional; A default template to use if one is not specified in render calls + default_template: "main.html", + + # build_dir - optional (but basically required if you want client-side JavaScript to work); Where the crystal-vue + # webpack process should output the client-side javascript bundle build. This file will be called by browsers + # directly, so it should live where your other static files live. For now this will be a symbolic link. + build_dir: "#{Dir.current}/public/dist", + + # build_dir_public_path - related to above, this is the relative directory the browser should reference for the + # client js build file. + build_dir_public_path: "/dist" +) diff --git a/config/webpack/webpack-base-cfg.js b/config/webpack/webpack-base-cfg.js new file mode 100644 index 0000000..bfc6f86 --- /dev/null +++ b/config/webpack/webpack-base-cfg.js @@ -0,0 +1,53 @@ +const path = require('path') +const VueLoaderPlugin = require('vue-loader/lib/plugin') + +module.exports = { + resolve: { + alias: { + scripts: path.resolve(__dirname, '../../src/scripts/'), + } + }, + resolveLoader: { + modules: [path.resolve(__dirname, '../../node_modules')] + }, + module: { + rules: [{ + test: /\.vue$/, + loader: 'vue-loader', + }, + { + test: /\.js$/, + exclude: /node_modules/, + loader: 'babel-loader', + query: { + presets: ['env'] + } + }, + { + test: /\.css$/, + exclude: /node_modules/, + use: [ + 'vue-style-loader', + 'css-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]' + ] + }, + ] + }, + plugins: [ + new VueLoaderPlugin(), + ] +} \ No newline at end of file diff --git a/config/webpack/webpack-vue-client-cfg.js b/config/webpack/webpack-vue-client-cfg.js new file mode 100644 index 0000000..f8b7f2a --- /dev/null +++ b/config/webpack/webpack-vue-client-cfg.js @@ -0,0 +1,15 @@ +const webpack = require('webpack') +const merge = require('webpack-merge') +const path = require('path') +const baseConfig = require('./webpack-base-cfg.js') +const VuewSSRClientPlugin = require('vue-server-renderer/client-plugin') + +module.exports = merge(baseConfig, { + entry: 'scripts/entry-client.js', + output: { + filename: 'cvue.[name].js' + }, + plugins: [ + new VuewSSRClientPlugin() + ] +}) \ No newline at end of file diff --git a/config/webpack/webpack-vue-server-cfg.js b/config/webpack/webpack-vue-server-cfg.js new file mode 100644 index 0000000..2136917 --- /dev/null +++ b/config/webpack/webpack-vue-server-cfg.js @@ -0,0 +1,20 @@ +const merge = require('webpack-merge') +const path = require('path') +const nodeExternals = require('webpack-node-externals') +const baseConfig = require('./webpack-base-cfg.js') +const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') + +module.exports = merge(baseConfig, { + entry: 'scripts/entry-server.js', + target: 'node', + devtool: 'source-map', + output: { + libraryTarget: 'commonjs2', + }, + externals: nodeExternals({ + whitelist: [/\.css$/, /\.vue$/] + }), + plugins: [ + new VueSSRServerPlugin() + ] +}) \ No newline at end of file diff --git a/doc/crystal-vue-example-1.png b/doc/crystal-vue-example-1.png new file mode 100644 index 0000000..b4a3936 Binary files /dev/null and b/doc/crystal-vue-example-1.png differ diff --git a/doc/crystal-vue-example-2.png b/doc/crystal-vue-example-2.png new file mode 100644 index 0000000..24a51ba Binary files /dev/null and b/doc/crystal-vue-example-2.png differ diff --git a/doc/crystal-vue-example-2a.png b/doc/crystal-vue-example-2a.png new file mode 100644 index 0000000..c25f21c Binary files /dev/null and b/doc/crystal-vue-example-2a.png differ diff --git a/doc/crystal-vue-example-3.png b/doc/crystal-vue-example-3.png new file mode 100644 index 0000000..95f7582 Binary files /dev/null and b/doc/crystal-vue-example-3.png differ diff --git a/doc/crystal-vue-example-4.gif b/doc/crystal-vue-example-4.gif new file mode 100644 index 0000000..ba1405c Binary files /dev/null and b/doc/crystal-vue-example-4.gif differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..c205645 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "crystal-vue", + "version": "0.1.0", + "repository": "https://github.com/noahlh/crystal-vue", + "author": "Noah Lehmann-Haupt ", + "license": "MIT", + "dependencies": { + "nodemon": "^1.18.7", + "vue": "^2.5.21", + "vue-router": "^3.0.2", + "vue-server-renderer": "^2.5.21", + "vue-template-compiler": "^2.5.21", + "vuex": "^3.0.1", + "vuex-router-sync": "^5.0.0", + "babel-core": "^6.26.3", + "babel-loader": "7", + "babel-preset-env": "^1.7.0", + "css-loader": "^2.0.1", + "vue-loader": "^15.4.2", + "webpack": "^4.28.2", + "webpack-cli": "^3.1.2", + "webpack-merge": "^4.1.5", + "webpack-node-externals": "^1.7.2" + } +} \ No newline at end of file diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..2e844de --- /dev/null +++ b/shard.yml @@ -0,0 +1,12 @@ +name: crystal-vue +version: 0.1.0 + +scripts: + postinstall: yarn install + +authors: + - Noah Lehmann-Haupt + +crystal: 0.27 + +license: MIT diff --git a/spec/crystal-vue_spec.cr b/spec/crystal-vue_spec.cr new file mode 100644 index 0000000..ff22d1d --- /dev/null +++ b/spec/crystal-vue_spec.cr @@ -0,0 +1,61 @@ +require "./spec_helper" +require "./renderer/**" +require "./support/**" + +describe CrystalVue do + describe "self.init" do + it "returns a Renderer when called with no arguments" do + r = CrystalVue.init( + routes_file: "#{__DIR__}/scripts/routes.js", + component_dir: "#{__DIR__}/scripts" + ) + r.should be_a(CrystalVue::Renderer) + ensure + CrystalVue.kill_processes + end + end + describe "@@renderer" do + it "should store a renderer in the module/class var" do + CrystalVue.init( + routes_file: "#{__DIR__}/scripts/routes.js", + component_dir: "#{__DIR__}/scripts" + ) + CrystalVue.renderer.should be_a(CrystalVue::Renderer) + ensure + CrystalVue.kill_processes + end + it "both returns & stores an init'd renderer in a class/module variable" do + r = CrystalVue.init( + routes_file: "#{__DIR__}/scripts/routes.js", + component_dir: "#{__DIR__}/scripts" + ) + r.should eq(CrystalVue.renderer) + ensure + CrystalVue.kill_processes + end + end + describe "self.render" do + it "renders a template (called from the module-level)" do + CrystalVue.init( + routes_file: "#{__DIR__}/scripts/routes.js", + component_dir: "#{__DIR__}/scripts" + ) + sleep 3 + r = CrystalVue.render("/") + r.should eq("
Test!
") + ensure + CrystalVue.kill_processes + end + it "renders a template (called from the module level) with a default template" do + CrystalVue.init( + routes_file: "#{__DIR__}/scripts/routes.js", + component_dir: "#{__DIR__}/scripts", + template_dir: "#{__DIR__}/scripts", + default_template: "test.html" + ) + sleep 3 + r = CrystalVue.render("/") + r.should contain("\n\n") if r + end + end +end diff --git a/spec/renderer/renderer_spec.cr b/spec/renderer/renderer_spec.cr new file mode 100644 index 0000000..a8e7795 --- /dev/null +++ b/spec/renderer/renderer_spec.cr @@ -0,0 +1,107 @@ +require "../spec_helper" + +describe CrystalVue do + describe CrystalVue::Renderer do + describe "#initialize" do + it "logs its initialization" do + IO.pipe do |r, w| + logger = Logger.new(w) + logger.progname = "VueSSR" + CrystalVue::Renderer.new(logger: logger) + r.gets.should match(/Renderer Initialized/) + end + end + it "can be initialized with no params" do + r = CrystalVue::Renderer.new + r.should be_a(CrystalVue::Renderer) + end + end + describe "#start_server" do + it "raises an exception if no component_dir set" do + expect_raises(Exception, "Error - you must define component_dir to launch the node process") do + proc = CrystalVue::Renderer.new.start_server + end + end + it "raises an exception if no routes_file set" do + expect_raises(Exception, "Error - you must define routes_file to launch the node process") do + r = CrystalVue::Renderer.new(component_dir: "/") + proc = r.start_server + end + end + it "spawns a node process (whether it's valid or not)" do + r = CrystalVue::Renderer.new(component_dir: "/", routes_file: "test.js") + proc = r.start_server + proc.should be_a(Process) + ensure + proc.kill if proc + end + it "raises an exception if the server doesn't start within a specified timeout" do + r = CrystalVue::Renderer.new( + routes_file: "#{__DIR__}/../scripts/routes.js", + component_dir: "#{__DIR__}/../scripts" + ) + expect_raises(Exception, "Node server failed to start within timeout") do + run_spec_server(r, timeout: 0.001.seconds) do + res = r.render("/") + end + end + end + it "raises an exception if the server is started with an invalid routes file" do + r = CrystalVue::Renderer.new( + routes_file: "foo.js", + component_dir: "#{__DIR__}/../scripts" + ) + expect_raises(Exception, "Node server terminated due to fault") do + run_spec_server(r) do + res = r.render("/") + end + end + end + end + describe "#render" do + it "Renders a vue component (no context) and returns the raw HTML" do + r = CrystalVue::Renderer.new( + routes_file: "#{__DIR__}/../scripts/routes.js", + component_dir: "#{__DIR__}/../scripts" + ) + run_spec_server(r) do + res = r.render("/") + res.should eq("
Test!
") + end + end + it "Renders a vue component (w/ context) and returns the raw HTML" do + r = CrystalVue::Renderer.new( + routes_file: "#{__DIR__}/../scripts/routes.js", + component_dir: "#{__DIR__}/../scripts" + ) + run_spec_server(r) do + res = r.render("/context", CrystalVue::Context{:foo => "bar"}) + res.should eq("
Test context: bar
") + end + end + it "Renders a vue component w/ a template specified" do + r = CrystalVue::Renderer.new( + routes_file: "#{__DIR__}/../scripts/routes.js", + component_dir: "#{__DIR__}/../scripts", + template_dir: "#{__DIR__}/../scripts", + ) + run_spec_server(r) do + res = r.render("/", template: "test.html") + res.should contain("\n\n") + end + end + it "Renders a vue component w/ a default template specified" do + r = CrystalVue::Renderer.new( + routes_file: "#{__DIR__}/../scripts/routes.js", + component_dir: "#{__DIR__}/../scripts", + template_dir: "#{__DIR__}/../scripts", + default_template: "test.html" + ) + run_spec_server(r) do + res = r.render("/") + res.should contain("\n\n") + end + end + end + end +end diff --git a/spec/scripts/Context.vue b/spec/scripts/Context.vue new file mode 100644 index 0000000..2fa4c02 --- /dev/null +++ b/spec/scripts/Context.vue @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/spec/scripts/Test.vue b/spec/scripts/Test.vue new file mode 100644 index 0000000..9319273 --- /dev/null +++ b/spec/scripts/Test.vue @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/spec/scripts/routes.js b/spec/scripts/routes.js new file mode 100644 index 0000000..05258d0 --- /dev/null +++ b/spec/scripts/routes.js @@ -0,0 +1,4 @@ +export default [ + { path: '/', component: 'Test' }, + { path: '/context', component: 'Context' } +] \ No newline at end of file diff --git a/spec/scripts/test.html b/spec/scripts/test.html new file mode 100644 index 0000000..20cfe68 --- /dev/null +++ b/spec/scripts/test.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..8e6344d --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,31 @@ +require "spec" +require "../src/crystal-vue" + +ENV["CRYSTAL_VUE"] = "test" + +def run_spec_server(renderer, timeout = 5.seconds) + channel = Channel(Process).new + + IO.pipe do |reader, writer| + # multi = IO::MultiWriter.new(STDOUT, writer) ## uncomment for debugging + now = Time.monotonic + renderer.logger = Logger.new(writer) + spawn do + proc = renderer.start_server + channel.send(proc) + end + + begin + proc = channel.receive + loop do + break if reader.gets =~ /SSR renderer listening/ + raise "Node server failed to start within timeout" if ((Time.monotonic - now) > timeout) + raise "Node server terminated due to fault" if proc.terminated? + sleep 0.1 + end + yield + ensure + renderer.kill_server + end + end +end diff --git a/spec/support/amber_spec.cr b/spec/support/amber_spec.cr new file mode 100644 index 0000000..f461e62 --- /dev/null +++ b/spec/support/amber_spec.cr @@ -0,0 +1,20 @@ +require "../spec_helper" + +class GenericController + include CrystalVue::Adapter::Amber + + def vue_context_test_getter + @vue_context + end +end + +describe CrystalVue do + describe CrystalVue::Adapter::Amber do + describe "macro included" do + it "inits a @vue_context object when included" do + c = GenericController.new + c.vue_context_test_getter.should be_a(CrystalVue::Context) + end + end + end +end diff --git a/spec/support/vue_context_spec.cr b/spec/support/vue_context_spec.cr new file mode 100644 index 0000000..02b3b7f --- /dev/null +++ b/spec/support/vue_context_spec.cr @@ -0,0 +1,12 @@ +require "../spec_helper" + +describe CrystalVue do + describe CrystalVue::Context do + describe "#to_json" do + it "Converts a CrystalVue::Conext Hashlike to a JSON object" do + v = CrystalVue::Context{:foo => "bar"} + v.to_json.should eq("{\"foo\":\"bar\"}") + end + end + end +end diff --git a/src/crystal-vue.cr b/src/crystal-vue.cr new file mode 100644 index 0000000..2c90b79 --- /dev/null +++ b/src/crystal-vue.cr @@ -0,0 +1,63 @@ +require "./crystal-vue/*" +require "logger" + +module CrystalVue + # Keep track of any processes spawned + @@node_processes : Array(Process) = [] of Process + + # Made available as class var so at_exit can log + @@logger : Logger = Logger.new(nil) + + # Make an init'd renderer available on the module in cases where it's tough to pass the object (i.e. Amber) + @@renderer : Renderer? + + # Store the default template in case #render is called without a template + @@default_template : String? + + # CrystalVue.init does the full range of initialization (launches all processes) to get the renderer going + def self.init(component_dir, routes_file, logger = Logger.new(nil), port = 4000, template_dir = nil, @@default_template = nil, build_dir = nil, build_dir_public_path = nil) + @@logger = logger + renderer = Renderer.new(component_dir: component_dir, routes_file: routes_file, logger: logger, port: port, template_dir: template_dir, default_template: @@default_template, build_dir: build_dir, build_dir_public_path: build_dir_public_path) + @@node_processes << renderer.start_server + @@renderer = renderer if renderer + end + + # This exposes the render class on the module, in cases (i.e. Amber) where you can't easily pass around the render instance. + def self.render(path : String?, context : CrystalVue::Context? = nil, template : String? = @@default_template) + if renderer = @@renderer + if proc = renderer.node_process + raise ProcessException.new("Error rendering - node process is dead", renderer.errors) if proc.terminated? + renderer.render(path, context, template) + end + end + end + + # This might be replaceable with a 'getter' - can you do that for class vars? + def self.renderer + @@renderer + end + + # Clean up after ourselves and nuke any spun up node processes + def self.kill_processes + if @@node_processes.size > 0 + @@node_processes.each do |process| + @@logger.info("Nuking node process #{process.pid}") + begin + process.kill unless process.terminated? + rescue ex + end + end + @@node_processes.clear + end + end + + # Kill off the node processes when the program exits or receives a SIGTERM (i.e. Amber watch restart) + at_exit do + self.kill_processes + end + + Signal::TERM.trap do + self.kill_processes + exit + end +end diff --git a/src/crystal-vue/amber.cr b/src/crystal-vue/amber.cr new file mode 100644 index 0000000..c2b8e63 --- /dev/null +++ b/src/crystal-vue/amber.cr @@ -0,0 +1,17 @@ +module CrystalVue + module Adapter + module Amber + macro included + @vue_context = CrystalVue::Context.new + end + + macro vue_render(vue_context = nil, path = nil, template = nil) + {% if template %} + CrystalVue.render(path: ({{ path }} || request.resource), context: {{ vue_context }}, template: {{ template }}) + {% else %} + CrystalVue.render(path: ({{ path }} || request.resource), context: {{ vue_context }}) + {% end %} + end + end + end +end diff --git a/src/crystal-vue/process_exception.cr b/src/crystal-vue/process_exception.cr new file mode 100644 index 0000000..823cbc6 --- /dev/null +++ b/src/crystal-vue/process_exception.cr @@ -0,0 +1,14 @@ +class ProcessException < Exception + def initialize(message, @errors : IO::Memory) + super(message) + end + + def inspect_with_backtrace + String.build do |io| + io.puts(self.message) + @errors.rewind + io.puts("-------------------------") + io.puts(@errors.to_s) + end + end +end diff --git a/src/crystal-vue/renderer.cr b/src/crystal-vue/renderer.cr new file mode 100644 index 0000000..83a8f9a --- /dev/null +++ b/src/crystal-vue/renderer.cr @@ -0,0 +1,119 @@ +## +# The Renderer class does the heavy lifting of actually calling the node process to render +## + +require "logger" +require "http/client" + +module CrystalVue + class Renderer + @component_dir : String? + @routes_file : String? + @port : Int32 + @template_dir : String? + @default_template : String? + @build_dir : String? + @build_dir_public_path : String? + @client : HTTP::Client + + getter node_process : Process? + getter errors : IO::Memory + + ## + # You can pass in a logger to keep things neat & tidy (like with Amber). + ## + + property logger : Logger + + def initialize(@component_dir = nil, @routes_file = nil, @logger = Logger.new(nil), @port = 4000, @template_dir = nil, @default_template = nil, @build_dir = nil, @build_dir_public_path = nil) + ENV["CRYSTAL_VUE"] ||= "development" + @logger.info "Renderer Initialized" + + @client = HTTP::Client.new("localhost", @port) + + @errors = IO::Memory.new + end + + ## + # Starts up a node server and spawns two fibers to pipe both output & errors. + # + # Fibers take a bit of getting used to, but for more details, see: + # https://crystal-lang.org/docs/guides/concurrency.html + # + # The key here is that the while loop + gets, as per the docs: + # "...talk directly with the Runtime Scheduler and the Event Loop..." + # "...the standard library already takes care of doing all of this so you don't have to." + ## + + def start_server + port = @port + template_dir = @template_dir + component_dir = @component_dir + routes_file = @routes_file + build_dir = @build_dir + build_dir_public_path = @build_dir_public_path + + raise "Error - you must define component_dir to launch the node process" unless component_dir + raise "Error - you must define routes_file to launch the node process" unless routes_file + + process_command = String.build do |str| + str << "NODE_PATH=. " + str << "NODE_PORT=#{port} " if port + str << "VUE_TEMPLATE_DIR=#{template_dir} " if template_dir + str << "VUE_COMPONENT_DIR=#{component_dir} " if component_dir + str << "VUE_ROUTES_FILE=#{routes_file} " if routes_file + str << "VUE_CLIENT_BUILD_DIR=#{build_dir} " if build_dir + str << "VUE_CLIENT_BUILD_DIR_PUBLIC_PATH=#{build_dir_public_path} " if build_dir_public_path + + if ENV["CRYSTAL_VUE"] == "development" + str << "npx nodemon -e \"json js\" " + str << "--watch #{template_dir} " if template_dir + else + str << "node " + end + str << "#{__DIR__}/../server/render-server.js" + end + + @logger.info("Starting node with command: #{process_command}") + + node_process = Process.new(process_command, shell: true, output: Process::Redirect::Pipe, error: Process::Redirect::Pipe) + + spawn do + while line = node_process.output.gets + @logger.info(line) + end + end + + spawn do + while line = node_process.error.gets + @logger.error(line) + @errors.puts(line) + end + end + + @node_process = node_process + @logger.info "Renderer started as process #{node_process.pid}" + return node_process + end + + def kill_server + proc = @node_process + proc.kill if (proc && !proc.terminated?) + end + + def render(path : String?, context : CrystalVue::Context? = CrystalVue::Context.new, template : String? = @default_template) + @logger.info "Rendering #{path}" + @logger.info "Context: #{context}" + @logger.info "Template: #{template}" + + method = (context || template) ? "POST" : "GET" + + path_with_query = String.build do |q| + q << path + q << "?template=#{template}" if template + end + + response = @client.exec(method: method, path: path_with_query, body: context.to_json).body + end + end +end diff --git a/src/crystal-vue/version.cr b/src/crystal-vue/version.cr new file mode 100644 index 0000000..24da700 --- /dev/null +++ b/src/crystal-vue/version.cr @@ -0,0 +1,3 @@ +module CrystalVue + VERSION = "0.1.0" +end diff --git a/src/crystal-vue/vue_context.cr b/src/crystal-vue/vue_context.cr new file mode 100644 index 0000000..3de7248 --- /dev/null +++ b/src/crystal-vue/vue_context.cr @@ -0,0 +1,15 @@ +require "json" + +module CrystalVue + class Context < Hash(Symbol, (String | Int32 | Float64)) + def to_json + string = JSON.build do |json| + json.object do + self.each do |k, v| + json.field k, v + end + end + end + end + end +end diff --git a/src/scripts/Base.vue b/src/scripts/Base.vue new file mode 100644 index 0000000..007efb0 --- /dev/null +++ b/src/scripts/Base.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/scripts/app.js b/src/scripts/app.js new file mode 100644 index 0000000..756f7be --- /dev/null +++ b/src/scripts/app.js @@ -0,0 +1,23 @@ +import Vue from 'vue' +import { mapState } from 'vuex' + +import { createRouter } from "scripts/router" +import { createStore } from "scripts/store" +import { sync } from "vuex-router-sync" + +import Base from "scripts/Base.vue" + +export function createApp() { + const router = createRouter() + const store = createStore() + + sync(store, router) + + const app = new Vue({ + router, + store, + render: h => h(Base) + }) + + return { app, router, store } +} \ No newline at end of file diff --git a/src/scripts/entry-client.js b/src/scripts/entry-client.js new file mode 100644 index 0000000..56332ed --- /dev/null +++ b/src/scripts/entry-client.js @@ -0,0 +1,11 @@ +import { createApp } from "scripts/app" + +const { app, router, store } = createApp() + +if (window.__INITIAL_STATE__) { + store.replaceState(window.__INITIAL_STATE__) +} + +router.onReady(() => { + app.$mount('#crystal-vue-app') +}) \ No newline at end of file diff --git a/src/scripts/entry-server.js b/src/scripts/entry-server.js new file mode 100644 index 0000000..34e08b4 --- /dev/null +++ b/src/scripts/entry-server.js @@ -0,0 +1,15 @@ +import { createApp } from "scripts/app" + +export default context => { + const { app, router, store } = createApp() + + router.push(context.pathname) + + if (context.vueContext) { + store.commit('SSR_INIT', context.vueContext) + } + + context.state = store.state + + return app +} \ No newline at end of file diff --git a/src/scripts/router.js b/src/scripts/router.js new file mode 100644 index 0000000..a4cfac1 --- /dev/null +++ b/src/scripts/router.js @@ -0,0 +1,41 @@ +import Vue from 'vue' +import Router from 'vue-router' +import routes from 'vueRoutes' + +Vue.use(Router) + +// Load up every .vue component in the directory and expose them +// by their file names, to be used in the routes. +const vueComponents = {} +const requireComponent = require.context( + 'components', // resolved by Webpack + true, // look in subfolders + /[A-Z]\w+\.(vue|js)$/ +) + +requireComponent.keys().forEach(fileName => { + // load the actual component + const componentConfig = requireComponent(fileName) + + // Strip the leading `./` from the filename + const componentName = fileName.replace(/^\.\/(.*)\.\w+$/, '$1') + + // put all components into a helper object + vueComponents[componentName] = componentConfig.default +}) + +// Replace the text strings in the routes file with the actual component references +const replacedRoutes = routes.map(route => { + return { + path: route.path, + component: vueComponents[route.component] + } +}) + +// Create the router! +export function createRouter() { + return new Router({ + mode: 'history', + routes: replacedRoutes + }) +} \ No newline at end of file diff --git a/src/scripts/store.js b/src/scripts/store.js new file mode 100644 index 0000000..2e28ee9 --- /dev/null +++ b/src/scripts/store.js @@ -0,0 +1,19 @@ +import Vue from 'vue' +import Vuex from 'vuex' + +Vue.use(Vuex) + +export function createStore() { + return new Vuex.Store({ + state: { + crystal: {} + }, + mutations: { + SSR_INIT(state, vueContext) { + for (const [key, value] of Object.entries(vueContext)) { + Vue.set(state.crystal, key, value) + } + } + } + }) +} \ No newline at end of file diff --git a/src/server/render-server.js b/src/server/render-server.js new file mode 100644 index 0000000..784c76c --- /dev/null +++ b/src/server/render-server.js @@ -0,0 +1,204 @@ +// Utility function to exit on error +const _exit = (m) => { + console.error(m) + process.exit(1) +} + +const path = require('path') +const fs = require('fs') +const { createBundleRenderer } = require('vue-server-renderer') + +const VUE_SSR_BUILD_DIR = path.resolve(__dirname, '../../build/') +const VUE_TEMPLATE_DIR = process.env.VUE_TEMPLATE_DIR ? path.resolve(process.env.VUE_TEMPLATE_DIR) : null +const VUE_COMPONENT_DIR = process.env.VUE_COMPONENT_DIR ? path.resolve(process.env.VUE_COMPONENT_DIR) : _exit("Component directory not defined - please set VUE_COMPONENT_DIR environment variable") +const VUE_ROUTES_FILE = process.env.VUE_ROUTES_FILE ? path.resolve(process.env.VUE_ROUTES_FILE) : _exit("Routes file must be specified - please set VUE_ROUTES_FILE environment variable") +const VUE_CLIENT_BUILD_DIR = process.env.VUE_CLIENT_BUILD_DIR ? path.resolve(process.env.VUE_CLIENT_BUILD_DIR) : VUE_SSR_BUILD_DIR +const VUE_CLIENT_BUILD_DIR_PUBLIC_PATH = process.env.VUE_CLIENT_BUILD_DIR_PUBLIC_PATH ? path.resolve(process.env.VUE_CLIENT_BUILD_DIR_PUBLIC_PATH) : "/" +const NODE_ENV = process.env.NODE_ENV || "development" + +// If we're doing templates (aka layouts), synchronously load them into an in-memory array. +// Since templates are almost always going to be super lightweight, for now synch + in-memory is fine. +const templateFiles = [] + +if (VUE_TEMPLATE_DIR) { + fs.readdirSync(VUE_TEMPLATE_DIR).forEach(file => { + let name = path.parse(file).name + path.parse(file).ext + let filepath = path.resolve(VUE_TEMPLATE_DIR, file) + let stat = fs.statSync(filepath) + let isFile = stat.isFile() + if (isFile) { + let body = fs.readFileSync(filepath, 'utf-8') + templateFiles.push({ name, body }) + } + }) +} + +// HTTP & webpack-related imports. +const http = require('http') +const port = process.env.NODE_PORT || 4000 +const webpack = require('webpack') +const merge = require('webpack-merge') +const webpackServerConfig = require('../../config/webpack/webpack-vue-server-cfg.js') +const webpackClientConfig = require('../../config/webpack/webpack-vue-client-cfg.js') + +const webpackCommonVariableConfig = { + mode: NODE_ENV, + output: { + path: VUE_SSR_BUILD_DIR, + publicPath: VUE_CLIENT_BUILD_DIR_PUBLIC_PATH + }, + resolve: { + alias: { + components: VUE_COMPONENT_DIR, + vueRoutes$: VUE_ROUTES_FILE + } + } +} + +const webpackCompiler = webpack([ + merge(webpackCommonVariableConfig, webpackClientConfig), + merge(webpackCommonVariableConfig, webpackServerConfig) +]) + +// Our actual render workhorse - this is called on each request to do the actual SSR. +// Takes a serverBundle & clientManifest, which come from webpack's memory FS in development +// and will live on disk (pre-built) in production +const doRender = (serverBundle, clientManifest) => { + return (req, res) => { + + // We're using the WHATWG URL standard since it's a mostly-standard, but that bind us to Node 8+ + let url = new URL(`http://localhost:${port}${req.url}`) + let pathname = url.pathname + let templateRequested = url.searchParams.get('template') + let templateResult = templateFiles.find(file => file.name == templateRequested) + let template = templateResult ? templateResult.body : null + + console.log(`[node] SSR request received - ${req.url}`) + console.log(`[node] type: ${req.method}, path: ${pathname}, template: ${templateRequested}`) + + // This is the core vue-ssr method that builds the SSR. + // See https://ssr.vuejs.org/guide/bundle-renderer.html + let renderer = createBundleRenderer(serverBundle, { + template, + clientManifest, + runInNewContext: false + }) + + // We use the HTTP body to pass a JSON object containing the crystal-rendered parameters. + // Probably a better/more robust way to do this, but it's simple and works for now. + let body = []; + let vueContext = {}; + + req.on('error', (err) => { + console.log(err) + }).on('data', (chunk) => { + body.push(chunk) + }).on('end', () => { + + let rawBody = Buffer.concat(body).toString() + + if (rawBody) { + Object.assign(vueContext, JSON.parse(rawBody)) + } + + let context = { pathname, vueContext } + + console.log(`[node] Context going into render:`) + console.log(context) + + renderer.renderToString(context, (err, html) => { + if (err) { + res.write(`error: ${err}`) + return + } + res.end(html) + }) + }) + } +} + +// Define our node http server and configure for long-running connections (keepAlive) +const server = new http.Server({ + timeout: 0, + keepAliveTimeout: 0 +}) + +// Define the callback for webpack after compilation. We can use the same one for dev & prod. +// TODO: (Bug) - this gets called on each compile, and because there's a client & server build, +// the http server listener is added, removed, then added again on each compile. +const webpackCompileCallback = (err, stats) => { + + // Webpack has 3 types of error - webpack errors, compile errors, and compile warnings + // The first two mean we've got a real problem, so abort the node process. + // The third doesn't necessarily mean baddness ensures, so keep going. + // This section inspirted by https://webpack.js.org/api/node/#error-handling + + // err == an actual webpack compile error + if (err) { + console.error(err.stack || err); + if (err.details) { + console.err(err.details) + } + process.exit(1) + } + + // stats.hasErrors() == a compilation error, so log output to console.error & exit + if (stats.hasErrors()) { + console.error(stats.toString({ + chunks: false, + colors: true, + modules: false + })) + process.exit(1) + } + + // Otherwise logs the stats to console.info... + console.log(stats.toString({ + chunks: false, + colors: true, + modules: false + })) + + + // Otherwise, the compile was good, so proceed... + // If there's an existing server, clear listeners and close. + if (server.listening) { + server.removeAllListeners('request') + server.close() + } + + const serverBundle = JSON.parse(fs.readFileSync(path.resolve(VUE_SSR_BUILD_DIR, 'vue-ssr-server-bundle.json'))) + const clientManifest = JSON.parse(fs.readFileSync(path.resolve(VUE_SSR_BUILD_DIR, 'vue-ssr-client-manifest.json'))) + + // Webpack doesn't support multiple output within a single config file, and we need to put the client JSON + // bundle in the server dist directory, but the client JS file in the calling webserver's output directory + // so we do this with a symlink. This feels hack-y, but for now it'll have to do. + + try { + fs.symlinkSync(path.resolve(VUE_SSR_BUILD_DIR, 'cvue.main.js'), path.resolve(VUE_CLIENT_BUILD_DIR, 'cvue.main.js')) + } catch (e) { + if (e.code == 'EEXIST') { + console.log('[node] Symlink already exists - moving on...') + } else { + throw e + } + } + + server.on('request', doRender(serverBundle, clientManifest)) + server.listen((port), () => { + console.log(`[node] SSR renderer listening in ${NODE_ENV} mode on port ${port}`); + }) + + // handle exceptions gracefully so our client doesn't die of loneliness + process.on('uncaughtException', err => { + console.error(`[node] Error w/ node process: ${err}`) + server.close(() => process.exit(1)) + }) +} + +// webpack watch if development, otherwise asume we're in prod or test and compile once. +if (NODE_ENV == "development") { + webpackCompiler.watch({}, webpackCompileCallback) +} else { + webpackCompiler.run(webpackCompileCallback) +} \ No newline at end of file