Chapter 12
Modules and builds
fs = require 'fs'
12.1 Server-side modules (on Node.js)
# -- my-trip-to-the-zoo.txt ?-
My trip to the zoo
I went to the Zoo. There were animals.
Listing 12.1 File and directory structure for your blog application
|
|—— app
| |—— controllers
| | |—— blog.coffee
| | |—— controller.coffee
| | |—— static.coffee
| |—— load.coffee
| |—— models
| | |—— model.coffee
| | |—— post.coffee
| |—— server.coffee
| ⌊—— views
| |—— list.coffee
| |—— post.coffee
| |—— view.coffee
|—— content
| |—— my-trip-to-the-circus.txt
| ⌊—— my-trip-to-the-zoo.txt
class Controller
class Blog extends Controller
coffee ?c application.coffee
# -- blog.coffee --
coffee ?c blog.coffee
# ReferenceError: Controller is not defined
require './controller'
# -- blog.coffee ?
require './controller'
class Blog extends Controller
coffee ?c blog.coffee
# ReferenceError: Controller is not defined
# -- controller.coffee ?
class Controller
exports.Controller = Controller
# -- blog.coffee ?-
controller = require './controller'
class Blog extends controller.Controller
# -- blog.coffee -?
{Controller} = require './controller'
class Blog extends Controller
module_a.coffee
w = 3
x = {a: 1}
exports.y = x
|
module_b.coffee
z = require('./module_a).y
# z == {a: 1}
|
{Controller} = require './controller.coffee'
> coffee blog.coffee
# application is running successfully at http://localhost:8080
> coffee ?c blog.coffee
> node blog.js
controller.coffee:2
class Controller
^^^^^
...
SyntaxError: Unexpected reserved word
class Post
posts = []
constructor: (@title, @body) ->
posts.push @
@all: -> posts
exports.Post = Post
{Post} = require './post'
aPost = new Post 'A Post', 'Some content'
anotherPost = new Post 'Another Post', 'Some more content'
Post.all().length
# 2
TheSamePost = require('./post').Post
aThirdPost = new Post 'Three', 'Content three'
TheSamePost.all().length
# 3
Post.all().length
# 3
makePost = ->
class Post
posts = []
constructor: (@title, @body) ->
posts.push @
@all: -> posts
{Post}
exports.makePost = makePost
{Post} = require('./post').makePost()
new Post 'A post', 'Some content'
Other = require ('./post').makePost().Post
Post.all().length
# 1
Other.all().length
# 0
> coffee server.coffee
Listing 12.2 server.coffee
http = require 'http'
{load} = require './load'
{Blog} = require './controllers'
load './content'
server = new http.Server()
server.listen '8080', 'localhost'
blog = new Blog server
Listing 12.3 load.coffee
fs = require 'fs'
{Post} = require './models/post'
load = (dir) ->
fs.readdir dir, (err, files) ->
for file in files when /.*[.]md$/.test file
fs.readFile "#{dir}/#{file}", 'utf-8', (err, data) ->
[title, content...] = data.split '\n'
new Post title, content.join '\n'
exports.load = load
Listing 12.4 controllers/controller.coffee
class Controller
routes = {}
@route = (path, method) ->
routes[path] = method
constructor: (server) ->
server.on 'request', (req, res) =>
path = require('url').parse(request.url).pathname
handlers = []
for route, handler of routes
if new RegExp("^#{route}$").test(path)
handlers.push
handler: handler
matches: path.match(new RegExp("^#{route}$"))
method = handlers[0]?.handler || 'default'
res.end @[method](req,res,handlers[0]?.matches.slice(1)...)
render: (view) ->
@response.writeHead 200, 'Content-Type': 'text/html'
@response.end view.render()
default: (@request, @response) ->
@render render: -> 'unknown'
exports.Controller = Controller
Listing 12.5 controllers/blog.coffee
fs = require 'fs'
{Controller} = require './controller'
{Post} = require '../models'
{views} = require '../views'
class Blog extends Controller
@route '/', 'index'
index: (@request, @response) =>
@posts = Post.all()
@render views 'list', @posts
@route '/([a-zA-Z0-9-]+)', 'show'
show: (@request, @response, id) =>
@post = Post.get id
if @post
@render views 'post', @post
else ''
exports.Blog = Blog
Listing 12.6 models/model.coffee
class Model
dirify: (s) -> s.toLowerCase().replace /[^a-zA-Z0-9-]/gi, '-'
exports.Model = Model
Listing 12.7 models/post.coffee
{Model} = require './model'
class Post extends Model
posts = []
constructor: (@title, @body) ->
throw 'requires title' unless @title
super
@slug = @dirify @title
posts.push @
@all: -> posts
@get: (slug) -> (post for post in posts when post.slug is slug)[0]
@purge = ->
posts = []
exports.Post = Post
Listing 12.8 views/view.coffee
class View
render: ->
'Lost?'
wrap: (content) ->
"""
<!DOCTYPE html>
<html dir='ltr' lang='en-US'>
<head>
<meta http-equiv='Content-Type' content='text/html; charset=utf-8'>
<title>Agtron's blog</title>
#{content}
"""
exports.View = View
Listing 12.9 views/list.coffee
{View} = require './view'
class List extends View
constructor: (@posts) ->
render: ->
all = (for post in @posts
"<li><a href='#{post.slug}'>#{post.title}</a></li>"
).join ''
@wrap """
<ul>#{all}</ul>
"""
exports.List = List
Listing 12.10 views/post.coffee
{View} = require './view'
class Post extends View
constructor: (@post) ->
render: ->
@wrap """
<h1>#{@post.title}</h1>
<div class='content'>
#{@post.body}
</div>
"""
exports.Post = Post
|—— utils/
| |—— string.coffee
| |—— array.coffee
| |—— statistics.coffee
{trim, pad} = require 'utils/string'
{remove} = require 'utils/array'
{chebyshev} = require 'utils/stats'
exports.string = require '.utils/string'
exports.array = require '.utils/array'
exports.stats = require '.utils/stats'
{string:{trim,pad},array:{remove},stats:{chebyshev}} = require './utils/index'
{string:{trim,pad},array:{remove},stats:{chebyshev}} = require './utils'
12.2 Build automation with Cake
> coffee ?c ?o compiled app
> node compiled/server.js
> node compiled/tests.js
|—— lib
| |—— highball.coffee
| |—— cocktail.coffee
| |—— julep.coffee
|—— app
| |—— punch.coffee
| |—— fizz.coffee
| |—— flip.coffee
|—— vendor
| |—— mug.coffee
| |—— beaker.coffee
| |—— teacup.coffee
|—— resources
| |—— reference.csv
> coffee ?c single_file_application.coffee
> cake
task 'build', 'Compile all the CoffeeScript', ->
console.log 'Not implemented yet'
> cake
cake build # Compile all the CoffeeScript
> cake build
Not implemented yet
{spawn} = require 'child_process'
task 'build', 'Compile all the CoffeeScript', ->
coffee = spawn 'coffee', ['-c', '-o', "compiled/app", "app"]
coffee.on 'exit', (code) ->
console.log 'Build complete'
> cake build
# Build complete
|—— app
| |—— controllers
| | |—— blog.coffee
| | |—— controller.coffee
| | |—— static.coffee
| |—— load.coffee
| |—— models
| | |—— model.coffee
| | |—— post.coffee
| |—— server.coffee
| |—— views
| | |—— list.coffee
| | |—— post.coffee
| | |—— view.coffee
|—— compiled
| |—— controllers
| | |—— blog.js
| | |—— controller.js
| | |—— static.js
| |—— load.js
| |—— models
| | |—— model.js
| | |—— post.js
| |—— server.js
| |—— views
| | |—— list.js
| | |—— post.js
| | |—— view.js
task 'test', 'Run all the tests', ->
console.log 'No tests'
|—— Cakefile
|—— app
| |—— # application files are here
|—— spec
| |—— # test files are here
Listing 12.11 A specification for the Post class
{describe, it} = require 'chromic'
{Post} = require '../../app/models/post'
describe 'Post', ->
post = new Post 'A post', 'with contents'
another = new Post 'Another post', 'with contents'
it 'should return all posts', ->
Post.all().length.shouldBe 2
it 'should return a specific post', ->
Post.get(post.slug).shouldBe 'a-post'
Listing 12.12 Part of a Cakefile with build
and test
tasks
# See listing 12.19 for the complete Cakefile this is part of
compile (directory) = ->
coffee = spawn 'coffee', ['-c', '-o', "compiled/#{directory}", directory]
coffee.on 'exit', (code) ->
console.log 'Build complete'
clean = (path, callback) ->
exec "rm -rf #{path}", -> callback?()
forAllSpecsIn = (dir, fn) ->
execFile 'find', [ dir ], (err, stdout, stderr) ->
fileList = stdout.split '\n'
for file in fileList
fn file if /_spec.js$/.test file
runSpecs = (folder) ->
forAllSpecsIn folder, (file) ->
require "./#{file}"
task 'build', 'Compile the application', ->
clean 'compiled', ->
compile 'app', ->
'Build complete'
task 'test' , 'Run the tests', ->
clean 'compiled', ->
compile 'app', ->
compile 'spec', ->
runSpecs 'compiled', ->
console.log 'Tests complete'
task 'build', ->
task 'test', -> invoke 'build'
task 'build', ->
console.log 'built'
task 'test', ->
invoke 'build'
task 'deploy', ->
invoke 'build'
invoke 'test'
> cake deploy
# built
# built
built = false
task 'build', ->
return if built
built = true
12.3 Client-side modules (in a web browser)
|—— Cakefile
| |—— app
| | |—— # application files are here
| |—— client
| | |—— # Put your files here (Scruffy)
| |—— spec
| | |—— # test files are here
@app.controllers = do ->
controller = do ->
blog = do ->
@app = loadSomeNefariousProgram()
# -- main.coffee --
{Comments} = require 'comments'
# <rest of module omitted>
# -- comments.coffee --
class Comments
# <rest of module omitted>
exports.Comments = Comments
task 'concatenate', 'Compile multiple CoffeeScript files', ->
(function() {
var Comments;
Comments = (function() {
function Comments() {}
return Comments;
})();
exports.Comments = Comments;
}).call(this);
(function() {
var Comments;
Comments = require('comments').Comments;
}).call(this);
defmodule({'main': function (require, exports) {
var Comments = require('./comments').Comments;
}});
defmodule({'comments': function (require, exports) {
var Comments;
Comments = (function() {
function Comments() {}
return Comments;
})();
exports.Comments = Comments;
}});
Listing 12/13 require
and defmodule
for the browser (lib/modules.coffee)
do ->
modules = {}
cache = {}
@require = (raw_name) ->
name = raw_name.replace /[^a-z]/gi, ''
return cache[name].exports if cache[name]
if modules[name]
module = exports: {}
cache[name] = module
modules[name]((name) ->
require name
, module.exports)
module.exports
else throw "No such module #{name}"
@defmodule = (bundle) ->
for own key of bundle
modules[key] = bundle[key]
defmodule('comments': function(require, exports) {
class Comments
exports.Comments = Comments;
});
defmodule('main': function(require, exports) {
var Comments = require('./comments').Comments;
});
require './main'
Listing 12.14 Cake task for client-side modules
task 'build:client', 'build client side stuff with modules', ->
compiler = require 'coffee-script'
modules = fs.readFileSync "lib/modules.coffee", "utf-8"
modules = compiler.compile modules, bare: true
files = fs.readdirSync 'client'
source = (for file in files when /\.coffee$/.test file
module = file.replace /\.coffee/, ''
fileSource = fs.readFileSync "client/#{file}", "utf-8"
"""
defmodule({#{module}: function (require, exports) {
#{compiler.compile(fileSource, bare: true)}
}});
"""
).join '\n\n'
out = modules + '\n\n' + source
fs.writeFileSync 'compiled/app/client/application.js'
Listing 12.15 Comments specification (spec/comments_spec.coffee)
{describe, it} = require 'chromic'
{Comments} = require '../../app/client/comments'
describe 'Comments', ->
it 'should post a comment to the server', ->
requested = false
httpRequest = (url) -> requested = url
comments = new Comments 'http://the-url', {}, httpRequest
comment = 'Hey Agtron. Nice site.'
comments.post comment
requested.shouldBe "http://the-url/comments?insert=#{comment}"
it 'should fetch the comments when constructed', ->
requested = false
httpRequest = (url) -> requested = url
comments = new Comments 'http://the-url', {}, httpRequest
requested.shouldBe "http://the-url/comments"
it 'should bind to event on the element', ->
comments = new Comments 'http://the-url', {}, ->
element =
querySelector: -> element
value: 'A comment from Scruffy'
comments.bind element, 'post'
postReceived = false
comments.post = (comment) -> postReceived = comment
element.onpost()
postReceived.shouldBe element.value
it 'should render comments to the page as a list', ->
out = innerHTML: (content) -> renderedContent = content
comments = new Comments 'http://the-url', out, ->
comments.render '["One", "Two", "Three"]'
out.innerHTML.shouldBe "<ul><li>One</li><li>Two</li><li>Three"
Listing 12.16 The Comments
module (client/comments.coffee)
class Comments
constructor: (@url, @out, @httpRequest) ->
@httpRequest "#{@url}/comments", @render
post: (comment) ->
@httpRequest "#{@url}/comments?insert=#{comment}", @render
bind: (element, event) ->
comment = element.querySelector 'textarea'
element["on#{event}"] = =>
@post comment.value
false
render: (data) =>
inLi = (text) -> "<li>#{text}</li>"
if data isnt ''
comments = JSON.parse data
if comments.map?
formatted = comments.map(inLi).join ''
@out.innerHTML = "<ul>#{formatted}</ul>"
exports.Comments = Comments
12.4 Application deployment
> node compiled/app/server.js
createArtifact = (path, version, callback) ->
execFile "tar", ["-cvf", "artifact.#{version}.tar", path], (e, d) ->
callback()
task 'artifact', 'build the artifact', ->
version = fs.readFileSync './VERSION', 'utf-8'
createArtifact 'compiled', version, ->
console.log "done. artifact.#{version}.tar generated"
> cake artifact
# done. artifact.1.tar generated
server.listen '8080', 'localhost'
server.listen '80, 'agtronsblog.com'
if process.env.NODE_ENV is 'production'
server.listen '80', 'agtronsblog.com'
else if process.env.NODE_ENV is 'development'
server.listen '8080', 'localhost'
Listing 12.17 The blog application (server.coffee)
http = require 'http'
{load} = require './load'
{Blog} = require './controllers'
load './content'
config = require('./config')[process.env.NODE_ENV]
server = new http.Server()
server.listen config.port, config.host
blog = new Blog server
Listing 12.18 The blog application configuration file (config.coffee)
config =
development:
host: 'localhost'
port: '8080'
production:
host: 'agtronsblog.com'
port: '80'
for key, value of config
exports[key] = value
> NODE_ENV=development node compiled/server.js
> NODE_ENV=production node compiled/server.js
task 'production:deploy', 'deploy the application to production' ->
VERSION = fs.readFileSync('./VERSION', 'utf-8')
SERVER = require('./app/config').production.host
clean 'compiled', ->
compile 'app', ->
copy 'content', 'compiled', ->
createArtifact 'compiled', VERSION, ->
execFile 'scp', [
"artifact.#{VERSION}.tar",
"#{SERVER}:~/."
], (err, data) ->
console.log "Uploaded artifact #{VERSION} to #{SERVER}"
12.5 The final Cakefile
> cake
cake clean # delete existing build
cake build # run the build
cake test # run the tests
cake development:start # start the application locally
Listing 12.19 The Cakefile for the blog application (Cakefile)
s = require 'fs'
{exec, execFile} = require 'child_process'
buildUtilities = require './build_utilities'
{
clean,
compile,
copy,
createArtifact,
runSpecs,
runApp
} = buildUtilities.fromDir './'
VERSION = fs.readFileSync('./VERSION', 'utf-8')
task 'clean', 'delete existing build', ->
execFile "npm", ["install"], ->
clean "compiled"
task 'build', 'run the build', ->
clean 'compiled', ->
compile 'app', ->
copy 'content', 'compiled', ->
createArtifact 'compiled', VERSION, ->
console.log 'Build complete'
task 'test' , 'run the tests', ->
clean 'compiled', ->
compile 'app', ->
compile 'spec', ->
runSpecs 'compiled', ->
console.log 'Tests complete'
task "development:start", "start on development", ->
runApp 'development'
SERVER = require('./app/config').production.host
deploy = ->
console.log "Deploy..."
tarOptions = ["-cvf","artifact.#{VERSION}.tar","compiled"]
execFile "tar", tarOptions,(err, data) ->
console.log '1. Created artifact'
execFile 'scp', [
"artifact.#{VERSION}.tar",
"#{SERVER}:~/."
], (err, data) ->
console.log '2. Uploaded artifact'
exec """
ssh #{SERVER} 'cd ~/;
rm -rf compiled;
tar -xvf artifact.#{VERSION}.tar;
cd ~/compiled;
NODE_ENV=production nohup node app/server.js &' &
""", (err, data) ->
console.log '3. Started server'
console.log 'Done'
task "production:deploy", "deploys the app to production", ->
clean 'compiled', ->
compile 'app', ->
copy 'content', 'compiled', ->
createArtifact 'compiled', VERSION, ->
deploy()
Listing 12.20 Build utilities (build_utilities.coffee)
fs = require 'fs'
{spawn, exec, execFile, fork} = require 'child_process'
clientCompiled = false
forAllSpecsIn = (dir, fn) ->
execFile 'find', [ dir ], (err, stdout, stderr) ->
fileList = stdout.split '\n'
for file in fileList
fn file if /_spec.js$/.test file
compileClient = (callback) ->
return callback() if clientCompiled
clientCompiled = true
compiler = require 'coffee-script'
modules = fs.readFileSync "lib/modules.coffee", "utf-8"
modules = compiler.compile modules, bare: true
files = fs.readdirSync 'client'
fs.mkdirSync "compiled/app/client"
source = (for file in files when /\.coffee$/.test file
module = file.replace /\.coffee/, ''
fileSource = fs.readFileSync "client/#{file}", "utf-8"
fs.writeFileSync "compiled/app/client/#{module}.js",
compiler.compile fileSource
"""
defmodule({#{module}: function (require, exports) {
#{compiler.compile(fileSource, bare: true)}
}});
"""
).join '\n\n'
out = modules + '\n\n' + source
fs.writeFileSync 'compiled/app/client/application.js', out
callback?()
exports.fromDir = (root) ->
return unless root
compile = (path, callback) ->
coffee = spawn 'coffee', ['-c', '-o', "#{root}compiled/#{path}", path]
coffee.on 'exit', (code, s) ->
if code is 0 then compileClient callback
else console.log 'error compiling'
coffee.on 'message', (data) ->
console.log data
createArtifact = (path, version, callback) ->
execFile "tar", ["-cvf", "artifact.#{version}.tar", path], (e, d) ->
callback?()
runSpecs = (folder) ->
forAllSpecsIn "#{root}#{folder}", (file) ->
require "./#{file}"
clean = (path, callback) ->
exec "rm -r #{root}#{path}", (err) -> callback?()
copy = (src, dst, callback) ->
exec "cp -R #{root}#{src} #{root}#{dst}/.", ->
callback?()
runApp = (env) ->
exec 'NODE_ENV=#{env} node compiled/app/server.js &', ->
console.log "Running..."
{clean, compile, copy, createArtifact, runSpecs, runApp}
task 'build', 'run the build', ->
clean 'compiled', ->
compile 'app', ->
copy 'content', 'compiled', ->
createArtifact 'compiled', VERSION, ->
console.log 'Build complete'
build: artifact
artifact: clean compile copy
tar ?cvf artifact.tar compiled
task 'build', 'run the build', ->
clean('compiled')
.then(compile 'app')
.then(copy 'content', 'compiled')
.then(createArtifact 'compiled', VERSION)
.then(-> console.log 'done')
.run()
task 'build', 'run the build', depends ['clean', 'compile', 'copy'], ->
12.6 Summary