Skip to content

Commit

Permalink
Start building something
Browse files Browse the repository at this point in the history
There's a bug where polling is dropped and never resumed
  • Loading branch information
marijnh committed Jun 1, 2015
0 parents commit 65bae40
Show file tree
Hide file tree
Showing 9 changed files with 376 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/node_modules
/public/demo.js
.tern-port
26 changes: 26 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "prosemirror-demo",
"version": "0.0.1",
"description": "Demo of ProseMirror's collaborative editing",
"maintainers": [
{
"name": "Marijn Haverbeke",
"email": "[email protected]",
"web": "http://marijnhaverbeke.nl"
}
],
"dependencies": {
"ecstatic": "^0.8.0",
"prosemirror": "^0.0.1"
},
"devDependencies": {
"babel": "^5.4.7",
"babelify": "^6.0.2",
"cssify": "^0.7.0",
"watchify": "^3.2.0"
},
"scripts": {
"start": "node src/server/start.js",
"demo": "watchify -d --outfile public/demo.js -t babelify src/client/client.js"
}
}
10 changes: 10 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>

<meta charset="utf-8">
<title>ProseMirror demo</title>

<body>

<h1>ProseMirror collaborative editing demo</h1>

<script src="demo.js"></script>
88 changes: 88 additions & 0 deletions src/client/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {Node} from "prosemirror/dist/model"
import {ProseMirror} from "prosemirror/dist/edit"
import "prosemirror/dist/collab"

import {GET, POST} from "./http"

function report(err) {
console.log("ERROR:", err.toString()) // FIXME
}

class Channel {
constructor(url, version) {
this.url = url
this.version = version
this.listener = null
this.sending = this.polling = false
}

listen(f) {
this.listener = f
this.poll()
}

send(version, steps, callback) {
this.sending = true
if (this.polling) this.polling.abort()
console.log("sending", steps.length, this.version)

POST(this.url + "/steps",
JSON.stringify({version: version, steps: steps}),
"application/json",
err => {
if (err && err.status != 406) {
report(err)
// FIXME retry a few times then callback(false)? this leaves
// client never sending anything again
// Move scheduling responsibility out of the collab module entirely?
} else {
let ok = !err
console.log("sent", steps.length, ok)
callback(ok)
if (ok) this.version += steps.length
this.sending = false
this.poll()
}
})
}

poll() {
if (this.polling) return
console.log("start polling @", this.version)
this.polling = GET(this.url + "/steps/" + this.version, (err, steps) => {
this.polling = false
console.log("received response", steps.length, this.version, this.sending)
if (this.sending) return

if (err) {
report(err) // FIXME swallow a few errors before giving up
setTimeout(() => this.poll(), 500)
} else {
steps = JSON.parse(steps)
if (steps.length) {
this.version += steps.length
this.listener(steps)
}
this.poll()
}
})
}

static start(url, callback) {
GET(url, (err, data) => {
data = JSON.parse(data)
callback(err, !err && {channel: new Channel(url, data.version),
doc: Node.fromJSON(data.doc)})
})
}
}

Channel.start("/doc/test", (err, data) => {
if (err) return report(err)
window.pm = new ProseMirror({
place: document.body,
doc: data.doc,
collab: {channel: data.channel,
version: data.channel.version}
})
})
27 changes: 27 additions & 0 deletions src/client/http.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export function req(conf, callback) {
let req = new XMLHttpRequest()
req.open(conf.method, conf.url, true)
req.addEventListener("load", () => {
if (req.status < 400) {
callback(null, req.responseText)
} else {
let err = new Error("Request failed: " + req.statusText + (req.responseText ? "\n\n" + req.responseText : ""))
err.status = req.status
callback(err)
}
})
req.addEventListener("error", function() {
callback(null, new Error("Network error"))
})
if (conf.headers) for (let header in conf.headers) req.setRequestHeader(header, conf.headers[header])
req.send(conf.body || null)
return req
}

export function GET(url, callback) {
return req({url: url, method: "GET"}, callback)
}

export function POST(url, body, type, callback) {
return req({url: url, method: "POST", body: body, headers: {"Content-Type": type}}, callback)
}
57 changes: 57 additions & 0 deletions src/server/instance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {Node} from "prosemirror/dist/model"
import {applyStep} from "prosemirror/dist/transform"

class Instance {
constructor(id) {
this.id = id
this.doc = new Node("doc", null, [new Node("paragraph")])
this.version = 0
this.steps = []
this.lastActive = Date.now()
this.waiting = []
}
}

const instances = Object.create(null)
let instanceCount = 0
let maxCount = 500

export function getInstance(id) {
return instances[id] || newInstance(id)
}

function newInstance(id) {
if (++instanceCount > maxCount) {
let oldest = null
for (let id in instances) {
let inst = instances[id]
if (!oldest || inst.lastActive < oldest.lastActive) oldest = inst
}
delete instances[oldest.id]
}
return instances[id] = new Instance(id)
}

export function addSteps(id, version, steps) {
let inst = getInstance(id)
if (version < 0 || version > inst.version) throw new Error("Bogus version " + version)
if (inst.version != version) return false
let doc = inst.doc
for (let i = 0; i < steps.length; i++)
doc = applyStep(doc, steps[i]).doc
inst.doc = doc
inst.version += steps.length
inst.steps = inst.steps.concat(steps)
inst.lastActive = Date.now()
while (inst.waiting.length) inst.waiting.pop()()
return true
}

export function getSteps(id, version) {
let inst = getInstance(id)
if (version < 0 || version > inst.version) throw new Error("Bogus version " + version)
let startIndex = inst.steps.length - (inst.version - version)
if (startIndex < 0) return false
inst.lastActive = Date.now()
return inst.steps.slice(startIndex)
}
44 changes: 44 additions & 0 deletions src/server/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {parse} from "url"

export class Router {
constructor() { this.routes = [] }

add(method, url, handler) {
this.routes.push({method, url, handler})
}

match(pattern, path) {
if (typeof pattern == "string") {
if (pattern == path) return []
} else if (pattern instanceof RegExp) {
let match = pattern.exec(path)
return match && match.slice(1)
} else {
let parts = path.slice(1).split("/")
if (parts.length != pattern.length) return null
let result = []
for (let i = 0; i < parts.length; i++) {
let pat = pattern[i]
if (pat) {
if (pat != parts[i]) return null
} else {
result.push(parts[i])
}
}
return result
}
}

resolve(request, response) {
let path = parse(request.url).pathname

return this.routes.some(route => {
let match = route.method == request.method && this.match(route.url, path)
if (!match) return false

let urlParts = match.map(decodeURIComponent)
route.handler(request, response, ...urlParts)
return true
})
}
}
119 changes: 119 additions & 0 deletions src/server/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import {createServer} from "http"
import Promise from "promise"
import {Router} from "./route"
import ecstatic from "ecstatic"

import {Step} from "prosemirror/dist/transform"

import {getInstance, getSteps, addSteps} from "./instance"

const port = 8000

const router = new Router
const fileServer = ecstatic({root: __dirname + "/../../public"})

const server = createServer((req, resp) => {
router.resolve(req, resp) || fileServer(req, resp)
})

server.listen(port)

class Output {
constructor(code, body, type) {
this.code = code
this.body = body
this.type = type || "text/plain"
}

static json(data) {
return new Output(200, JSON.stringify(data), "application/json")
}

resp(resp) {
resp.writeHead(this.code, {"Content-Type": this.type})
resp.end(this.body)
}
}

function readStreamAsJSON(stream, callback) {
var data = "";
stream.on("data", function(chunk) {
data += chunk;
});
stream.on("end", function() {
var result, error;
try { result = JSON.parse(data); }
catch (e) { error = e; }
callback(error, result);
});
stream.on("error", function(error) {
callback(error);
});
}

function handle(method, url, f) {
router.add(method, url, (req, resp, ...args) => {
function finish() {
let output
try {
output = f(...args, req, resp)
} catch (err) {
output = new Output(500, err.toString())
}
if (output) output.resp(resp)
}

if (method == "PUT" || method == "POST")
readStreamAsJSON(req, (err, val) => {
if (err) new Output(500, err.toString()).resp(resp)
else { args.unshift(val); finish() }
})
else
finish()
})
}

handle("GET", ["doc", null], id => {
let inst = getInstance(id)
return Output.json({doc: inst.doc.toJSON(), version: inst.version})
})

function nonNegInteger(str) {
let num = Number(str)
if (!isNaN(num) && Math.floor(num) == num && num >= 0) return num
throw new Error("Not a non-negative integer: " + str)
}

class Waiting {
constructor(resp) {
this.resp = resp
this.done = false
setTimeout(() => this.send([]), 1000 * 60 * 5)
}

send(steps) {
if (this.done) return
Output.json(steps.map(s => s.toJSON())).resp(this.resp)
this.done = true
}
}

handle("GET", ["doc", null, "steps", null], (id, version, _, resp) => {
version = nonNegInteger(version)
let steps = getSteps(id, version)
if (steps === false)
return new Output(410, "steps no longer available")
if (steps.length)
return Output.json(steps.map(s => s.toJSON()))
let wait = new Waiting(resp)
getInstance(id).waiting.push(() => wait.send(getSteps(id, version)))
})

handle("POST", ["doc", null, "steps"], (data, id) => {
let version = nonNegInteger(data.version)
let steps = data.steps.map(s => Step.fromJSON(s))
if (addSteps(id, data.version, steps))
return new Output(204)
else
return new Output(406, "Version not current")
})
2 changes: 2 additions & 0 deletions src/server/start.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require("babel/register")
require("./server")

0 comments on commit 65bae40

Please sign in to comment.