(require 'cl-lib)
(require 'json)
(require 'url-util)
(require 'simple-httpd)
(require 'js2-mode)
(require 'cache-table)
(defgroup skewer nil
"Live browser JavaScript interaction."
:group 'languages)
(defvar skewer-mode-map
(let ((map (make-sparse-keymap)))
(prog1 map
(define-key map (kbd "C-x C-e") 'skewer-eval-last-expression)
(define-key map (kbd "C-M-x") 'skewer-eval-defun)
(define-key map (kbd "C-c C-k") 'skewer-load-buffer)))
"Keymap for skewer-mode.")
(defvar skewer-data-root (file-name-directory load-file-name)
"Location of data files needed by impatient-mode.")
(defvar skewer-js-hook ()
"Hook to run when skewer.js is being served to the browser.
When hook functions are called, the current buffer is the buffer
to be served to the client (a defservlet), with skewer.js script
already inserted. This is the chance for other packages to insert
their own JavaScript to extend skewer in the browser, such as
adding a new type handler.")
(defvar skewer-response-hook ()
"Hook to run when a response arrives from the browser. Used for
catching messages from the browser with no associated
callback. The response object is passed to the hook function.")
(defvar skewer-timeout 3600
"Maximum time to wait on the browser to respond, in seconds.")
(defvar skewer-clients ()
"Browsers awaiting JavaScript snippets.")
(defvar skewer-callbacks (cache-table-create skewer-timeout :test 'equal)
"Maps evaluation IDs to local callbacks.")
(defvar skewer-queue ()
"Queued messages for the browser.")
(defvar skewer--last-timestamp 0
"Timestamp of the last browser response. Use
`skewer-last-seen-seconds' to access this.")
(cl-defstruct skewer-client
"A client connection awaiting a response."
proc agent)
(defun skewer-process-queue ()
"Send all queued messages to clients."
(when (and skewer-queue skewer-clients)
(let ((message (pop skewer-queue))
(sent nil))
(while skewer-clients
(ignore-errors
(progn
(let ((proc (skewer-client-proc (pop skewer-clients))))
(with-temp-buffer
(insert (json-encode message))
(httpd-send-header proc "text/plain" 200
:Cache-Control "no-cache"
:Access-Control-Allow-Origin "*")))
(setq skewer--last-timestamp (float-time))
(setq sent t))))
(if (not sent) (push message skewer-queue)))
(skewer-process-queue)))
(defun skewer-clients-tabulate ()
"Prepare client list for tabulated-list-mode."
(cl-loop for client in skewer-clients collect
(let ((proc (skewer-client-proc client))
(agent (skewer-client-agent client)))
(cl-destructuring-bind (host port) (process-contact proc)
`(,client [,host ,(format "%d" port) ,agent])))))
(define-derived-mode skewer-clients-mode tabulated-list-mode "skewer-clients"
"Mode for listing browsers attached to Emacs for skewer-mode."
(setq tabulated-list-format [("Host" 12 t)
("Port" 5 t)
("User Agent" 0 t)])
(setq tabulated-list-entries #'skewer-clients-tabulate)
(tabulated-list-init-header))
(define-key skewer-clients-mode-map (kbd "g")
(lambda ()
(interactive)
(skewer-ping)
(revert-buffer)))
(defun skewer-update-list-buffer ()
"Revert the client list, due to an update."
(save-window-excursion
(let ((list-buffer (get-buffer "*skewer-clients*")))
(when list-buffer
(with-current-buffer list-buffer
(revert-buffer))))))
(defun list-skewer-clients ()
"List the attached browsers in a buffer."
(interactive)
(pop-to-buffer (get-buffer-create "*skewer-clients*"))
(skewer-clients-mode)
(tabulated-list-print))
(defun skewer-queue-client (proc req)
"Add a client to the queue, given the HTTP header."
(let ((agent (cl-second (assoc "User-Agent" req))))
(push (make-skewer-client :proc proc :agent agent) skewer-clients))
(skewer-update-list-buffer)
(skewer-process-queue))
(defservlet skewer "text/javascript; charset=UTF-8" ()
(insert-file-contents (expand-file-name "skewer.js" skewer-data-root))
(goto-char (point-max))
(run-hooks 'skewer-js-hook))
(defun httpd/skewer/get (proc _path _query req &rest _args)
(skewer-queue-client proc req))
(defun httpd/skewer/post (proc _path _query req &rest _args)
(let* ((result (json-read-from-string (cadr (assoc "Content" req))))
(id (cdr (assoc 'id result)))
(callback (cache-table-get id skewer-callbacks)))
(setq skewer--last-timestamp (float-time))
(when callback
(funcall callback result))
(if id
(skewer-queue-client proc req)
(with-temp-buffer
(httpd-send-header proc "text/plain" 200
:Access-Control-Allow-Origin "*")))
(dolist (hook skewer-response-hook)
(funcall hook result))))
(defvar skewer-demo-source
(expand-file-name "example.html" skewer-data-root)
"Source file name or buffer for `httpd/skewer/demo' servlet.")
(defservlet skewer/demo "text/html; charset=UTF-8" ()
(cl-etypecase skewer-demo-source
(buffer (insert-buffer-substring skewer-demo-source))
(string (insert-file-contents skewer-demo-source))))
(defun skewer-success-p (result)
"Return T if result was a success."
(equal "success" (cdr (assoc 'status result))))
(define-derived-mode skewer-error-mode special-mode "skewer-error"
:group 'skewer
"Mode for displaying JavaScript errors returned by skewer-mode."
(setq truncate-lines t))
(defface skewer-error-face
'((((class color) (background light))
:foreground "red" :underline t)
(((class color) (background dark))
:foreground "red" :underline t))
"Face for JavaScript errors."
:group 'skewer)
(defun skewer--error (string)
"Return STRING propertized as an error message."
(propertize (or string "<unknown>") 'font-lock-face 'skewer-error-face))
(defun skewer-post-minibuffer (result)
"Report results in the minibuffer or the error buffer."
(if (skewer-success-p result)
(let ((value (cdr (assoc 'value result)))
(time (cdr (assoc 'time result))))
(if (and time (> time 1.0))
(message "%s (%.3f seconds)" value time)
(message "%s" value)))
(with-current-buffer (pop-to-buffer (get-buffer-create "*skewer-error*"))
(let ((inhibit-read-only t)
(error (cdr (assoc 'error result))))
(erase-buffer)
(skewer-error-mode)
(insert (skewer--error (cdr (assoc 'name error))) ": ")
(insert (or (cdr (assoc 'message error)) "") "\n\n")
(insert (or (cdr (assoc 'stack error)) "") "\n\n")
(insert (format "Expression: %s\n\n"
(if (cdr (assoc 'strict result)) "(strict)" ""))
(cdr (assoc 'eval error)))
(goto-char (point-min))))))
(cl-defun skewer-eval (string &optional callback
&key verbose strict (type "eval") extra)
"Evaluate STRING in the waiting browsers, giving the result to CALLBACK.
:VERBOSE -- if T, the return will try to be JSON encoded
:STRICT -- if T, expression is evaluated with 'use strict'
:TYPE -- chooses the JavaScript handler (default: eval)
:EXTRA -- additional alist keys to append to the request object"
(let* ((id (format "%x" (random most-positive-fixnum)))
(request `((type . ,type)
(eval . ,string)
(id . ,id)
(verbose . ,verbose)
(strict . ,strict)
,@extra)))
(prog1 request
(setf (cache-table-get id skewer-callbacks) callback)
(setq skewer-queue (append skewer-queue (list request)))
(skewer-process-queue))))
(defun skewer-eval-synchronously (string &rest args)
"Just like `skewer-eval' but synchronously, so don't provide a
callback. Use with caution."
(let ((result nil))
(apply #'skewer-eval string (lambda (v) (setq result v)) args)
(cl-loop until result
do (accept-process-output nil 0.01)
finally (return result))))
(defun skewer-apply (function args)
"Synchronously apply FUNCTION in the browser with the supplied
arguments, returning the result. All ARGS must be printable by
`json-encode'. For example,
(skewer-apply \"Math.atan2\" '(1 -2)) ; => 2.677945044588987
Uncaught exceptions propagate to Emacs as an error."
(let ((specials '(("undefined" . nil)
("NaN" . 0.0e+NaN)
("Infinity" . 1.0e+INF)
("-Infinity" . -1.0e+INF))))
(let* ((expr (concat function "(" (mapconcat #'json-encode args ", ") ")"))
(result (skewer-eval-synchronously expr :verbose t))
(value (cdr (assoc 'value result))))
(if (skewer-success-p result)
(if (assoc value specials)
(cdr (assoc value specials))
(condition-case _
(json-read-from-string value)
(json-readtable-error value)))
(signal 'javascript
(list (cdr (assoc 'message (cdr (assoc'error result))))))))))
(defun skewer-funcall (function &rest args)
"Synchronously call FUNCTION with the supplied ARGS. All ARGS
must be printable by `json-read-from-string. For example,
(skewer-funcall \"Math.sin\" 0.5) ; => 0.479425538604203
Uncaught exceptions propagate to Emacs as an error."
(skewer-apply function args))
(defun skewer--save-point (f &rest args)
"Return a function that calls F with point at the current point."
(let ((saved-point (point)))
(lambda (&rest more)
(save-excursion
(goto-char saved-point)
(apply f (append args more))))))
(defun skewer-ping ()
"Ping the browser to test that it's still alive."
(unless (null skewer-clients) (skewer-eval (prin1-to-string (float-time)) nil :type "ping")))
(defun skewer-last-seen-seconds ()
"Return the number of seconds since the browser was last seen."
(skewer-ping) (- (float-time) skewer--last-timestamp))
(defun skewer-mode-strict-p ()
"Return T if buffer contents indicates strict mode."
(save-excursion
(save-restriction
(widen)
(goto-char (point-min))
(js2-forward-sws)
(forward-char 1)
(let* ((stricts '("\"use strict\"" "'use strict'"))
(node (js2-node-at-point))
(code (buffer-substring-no-properties (js2-node-abs-pos node)
(js2-node-abs-end node))))
(and (member code stricts) t)))))
(defun skewer-flash-region (start end &optional timeout)
"Temporarily highlight region from START to END."
(let ((overlay (make-overlay start end)))
(overlay-put overlay 'face 'secondary-selection)
(run-with-timer (or timeout 0.2) nil 'delete-overlay overlay)))
(defun skewer-get-last-expression ()
"Return the JavaScript expression before the point as a
list: (string start end)."
(save-excursion
(js2-backward-sws)
(backward-char)
(let ((node (js2-node-at-point nil t)))
(when (eq js2-FUNCTION (js2-node-type (js2-node-parent node)))
(setq node (js2-node-parent node)))
(when (js2-ast-root-p node)
(error "no expression found"))
(let ((start (js2-node-abs-pos node))
(end (js2-node-abs-end node)))
(list (buffer-substring-no-properties start end) start end)))))
(defun skewer-eval-last-expression (&optional prefix)
"Evaluate the JavaScript expression before the point in the
waiting browser. If invoked with a prefix argument, insert the
result into the current buffer."
(interactive "P")
(if prefix
(skewer-eval-print-last-expression)
(if js2-mode-buffer-dirty-p
(js2-mode-wait-for-parse
(skewer--save-point #'skewer-eval-last-expression))
(cl-destructuring-bind (string start end) (skewer-get-last-expression)
(skewer-flash-region start end)
(skewer-eval string #'skewer-post-minibuffer)))))
(defun skewer-get-defun ()
"Return the toplevel JavaScript expression around the point as
a list: (string start end)."
(save-excursion
(js2-backward-sws)
(backward-char)
(let ((node (js2-node-at-point nil t)))
(when (js2-ast-root-p node)
(error "no expression found"))
(while (and (js2-node-parent node)
(not (js2-ast-root-p (js2-node-parent node))))
(setf node (js2-node-parent node)))
(let ((start (js2-node-abs-pos node))
(end (js2-node-abs-end node)))
(list (buffer-substring-no-properties start end) start end)))))
(defun skewer-eval-defun ()
"Evaluate the JavaScript expression before the point in the
waiting browser."
(interactive)
(if js2-mode-buffer-dirty-p
(js2-mode-wait-for-parse (skewer--save-point #'skewer-eval-defun))
(cl-destructuring-bind (string start end) (skewer-get-defun)
(skewer-flash-region start end)
(skewer-eval string #'skewer-post-minibuffer))))
(defvar skewer-eval-print-map (cache-table-create skewer-timeout :test 'equal)
"A mapping of evaluation IDs to insertion points.")
(defun skewer-post-print (result)
"Insert the result after its source expression."
(if (not (skewer-success-p result))
(skewer-post-minibuffer result)
(let* ((id (cdr (assoc 'id result)))
(pos (cache-table-get id skewer-eval-print-map)))
(when pos
(with-current-buffer (car pos)
(goto-char (cdr pos))
(insert (cdr (assoc 'value result)) "\n"))))))
(defun skewer-eval-print-last-expression ()
"Evaluate the JavaScript expression before the point in the
waiting browser and insert the result in the buffer at point."
(interactive)
(if js2-mode-buffer-dirty-p
(js2-mode-wait-for-parse
(skewer--save-point #'skewer-eval-print-last-expression))
(cl-destructuring-bind (string start end) (skewer-get-defun)
(skewer-flash-region start end)
(insert "\n")
(let* ((request (skewer-eval string #'skewer-post-print :verbose t))
(id (cdr (assoc 'id request)))
(pos (cons (current-buffer) (point))))
(setf (cache-table-get id skewer-eval-print-map) pos)))))
(defvar skewer-hosted-scripts (cache-table-create skewer-timeout)
"Map of hosted scripts to IDs.")
(defun skewer-host-script (string)
"Host script STRING from the script servlet, returning the script ID."
(let ((id (random most-positive-fixnum)))
(prog1 id
(setf (cache-table-get id skewer-hosted-scripts) string))))
(defun skewer-load-buffer ()
"Load the entire current buffer into the browser. A snapshot of
the buffer is hosted so that browsers visiting late won't see an
inconsistent buffer."
(interactive)
(let ((id (skewer-host-script (buffer-string)))
(buffer-name (buffer-name)))
(skewer-eval (format "/skewer/script/%d/%s"
id (url-hexify-string buffer-name))
(lambda (_) (message "%s loaded" buffer-name))
:type "script")))
(defservlet skewer/script "text/javascript; charset=UTF-8" (path)
(let ((id (string-to-number (nth 3 (split-string path "/")))))
(insert (cache-table-get id skewer-hosted-scripts ""))))
(define-minor-mode skewer-mode
"Minor mode for interacting with a browser."
:lighter " skewer"
:keymap skewer-mode-map
:group 'skewer)
(defun run-skewer (&optional arg)
"Attach a browser to Emacs for a skewer JavaScript REPL. Uses
`browse-url' to launch a browser.
With a prefix arugment (C-u), it will ask the filename of the
root document. With two prefix arguments (C-u C-u), it will use
the contents of the current buffer as the root document."
(interactive "p")
(cl-case arg
(4 (setf skewer-demo-source (read-file-name "Skewer filename: ")))
(16 (setf skewer-demo-source (current-buffer))))
(httpd-start)
(browse-url (format "http://127.0.0.1:%d/skewer/demo" httpd-port)))
(defvar phantomjs-program-name "/usr/bin/phantomjs"
"Path to the phantomjs executable.")
(defvar skewer-phantomjs-processes ()
"List of phantomjs processes connected to Skewer.")
(defun skewer-phantomjs-sentinel (proc event)
"Cleanup after phantomjs exits."
(when (cl-some (lambda (s) (string-match-p s event))
'("finished" "abnormal" "killed"))
(delete-file (process-get proc 'tempfile))))
(defun skewer-run-phantomjs ()
"Connect an inferior PhantomJS process to Skewer, returning the process."
(interactive)
(httpd-start)
(let ((script (make-temp-file "phantomjs-"))
(url (format "http://0:%d/skewer/demo" httpd-port)))
(with-temp-buffer
(insert (format "require('webpage').create().open('%s')" url))
(write-region nil nil script nil 0)
(let ((proc (start-process "phantomjs" nil
phantomjs-program-name script)))
(prog1 proc
(push proc skewer-phantomjs-processes)
(process-put proc 'tempfile script)
(set-process-sentinel proc 'skewer-phantomjs-sentinel))))))
(defun skewer-phantomjs-kill ()
"Kill all inferior phantomjs processes connected to Skewer."
(interactive)
(mapc #'delete-process skewer-phantomjs-processes)
(setf skewer-phantomjs-processes nil))
(provide 'skewer-mode)