diff --git a/.gitignore b/.gitignore index 6b4aaf7e..cb9753a8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ compiled/ coverage/ tmp/ js-build/ +racketscript-doc/racketscript/doc # common backups, autosaves, lock files, OS meta-files *~ diff --git a/racketscript-doc/racketscript/create-login-form.png b/racketscript-doc/racketscript/create-login-form.png new file mode 100644 index 00000000..039de814 Binary files /dev/null and b/racketscript-doc/racketscript/create-login-form.png differ diff --git a/racketscript-doc/racketscript/scribblings/peer-universe.scrbl b/racketscript-doc/racketscript/scribblings/peer-universe.scrbl new file mode 100644 index 00000000..14c66c72 --- /dev/null +++ b/racketscript-doc/racketscript/scribblings/peer-universe.scrbl @@ -0,0 +1,154 @@ +#lang scribble/manual +@(require (for-label racketscript/base + (except-in 2htdp/universe + register) + (only-in racketscript/htdp/peer-universe + server-id + register + create-login-form))) + +@title{Peer-Universe for RacketScript} +@author[(author+email "Ayden Diel" "aydendiel@gmail.com")] + +@defmodule[racketscript/htdp/peer-universe] +@;{ + for some reason I can't link to the 2htdp/universe docs, so I just linked to the htdp docs instead + } +Experimental implementation of Racket's @racket[2htdp/universe] library for @seclink["top" #:doc '(lib "racketscript/scribblings/racketscript.scrbl") "RacketScript"] using peer-to-peer connections. Used to create distributed programs where both the server and the clients run in the browser. + +@itemlist[@item{@secref["getting-started"]} + @item{@secref["how-it-works"]} + @item{@secref["differences"]} + @item{@secref["page-setup"]}] + +@section[#:tag "getting-started"]{Getting Started} +Since this library is primarily an implementation of the @hyperlink["https://docs.racket-lang.org/teachpack/2htdpuniverse.html"]{2htdp/universe} API, use those docs as your main reference. These docs will contain info about how @seclink["Peer-Universe_for_RacketScript"]{peer-universe} works and how it differs from the original, but won't contain an in-depth API description. + +@margin-note{I reccomend using @racket[create-login-form] to quickly and conveniently set up connections between your clients and server.} + +To use the library, run a separate @racket[universe] and @racket[big-bang] instance in separate browser windows, and then pass the server's @racket{peer-id} to the client's @racket[big-bang] call, and a connection will be established. + +@bold{IMPORTANT:} This library requires you use the @racket[racketscript/htdp/image] module, which implements the @seclink["image" #:doc '(lib "teachpack/teachpack.scrbl")]{htdp/image} library. Refer to the @seclink["image" #:doc '(lib "teachpack/teachpack.scrbl")]{htdp/image} docs for help using the library, but note that some features are not yet supported in the racketscript port. + +@section[#:tag "how-it-works"]{How does it work?} + +We use @hyperlink["https://peerjs.com/"]{PeerJS} under the hood to mimic client-server behavior where both the client and server run in browser tabs. In reality everything is done with peer connections. + +@margin-note{PeerJS's @hyperlink["https://peerjs.com/peerserver"]{PeerServer Cloud Serrvice} handles all of the traffic behind the scenes so that you don't have to worry about it.} + +@section[#:tag "differences"]{Differences from the 2htdp/universe API} + +In practice, this library only differs from @hyperlink["https://docs.racket-lang.org/teachpack/2htdpuniverse.html"]{2htdp/universe} when setting up connections (plus some slight differences in dependencies). Here's everything you need to know on top of the original docs. + +@subsection{Differences for @racket[big-bang] Function} + +@italic{Original @racket[big-bang] docs.} + +Differences from the original @racket[big-bang] API include: +@nested[#:style 'inset]{@itemlist[ + @item{@racket[big-bang] takes and optional @italic{#:dom-root} keyword argument to specify a root element for the canvas that big-bang draws to.} + @item{@racket[register] takes a @racket[peer-id] argument instead of an @racket[ip-expr].} + @item{No @racket[on-pad] clause (as of now).} + @item{No @racket[record?] clause.} + @item{No @racket[close-on-stop] clause (yet).} + @item{No @racket[display-mode] clause.} + @item{No @racket[state] caluse.} + @item{No @racket[port] clause.}]} + +@defform[(register peer-id)#:contracts ([peer-id string?])]{ + Tells racket what the @racket[peer-id] of the @racket[universe] that you want your world to connect to, instead of an ip address. Because of this, racketscript-universe has no @racket[port] clauses, as they're not needed to connect via @racket[peer-id]. +} + +@margin-note{Because our peer connections are handled by one server in the cloud, clients can connect to servers on different networks as long as they know the server id.} + +@subsection{Differences for @racket[universe] Function} + +@italic{Original @racket[universe] docs.} + +@nested[#:style 'inset]{@itemlist[ + @item{The @racket[server-id] clause can be used with @racket[universe] to specify its peer id (which gets passed into the @racket[register] clause of a @racket[big-bang] call).} + @item{@racket[universe] takes and optional @italic{#:dom-root} keyword argument to specify a root element to insert the logging gui into.} + @item{No @racket[port] clause.} + @item{No @racket[state] clause (yet).} + @item{No @racket[to-string] clause (yet).} + @item{No @racket[check-with] clause (yet).}]} + + +@margin-note{If the @racket[server-id] clause is not provided, a random id will be generated and logged.} + +@defform[(server-id peer-id)#:contracts ([peer-id string?])]{ + Lets you specify the @racket[peer-id] of the @racket[universe] that you're initializing. Use this @racket[peer-id] with the @racket[register] clause in a @racket[big-bang] call to connect a client. +} + +@section[#:tag "page-setup"]{Starting a Server & Logging In} + +The @racket[create-login-form] function sets up some convenient boilerplate to start your app by generating this HTML form. + +Here's an example. + +@codeblock{ + ;; client.rkt + #lang racketscript/base + (require racketscript/htdp/peer-universe + racketscript/htdp/image) + (provide start-world) + + ;; + ;; define all of your event handlers here + ;; + + (define (start-world client-name server-id) + (big-bang WORLD0 + [on-tick move] + [to-draw draw] + [on-receive receive] + [register server-id] + [name client-name] + [on-key handle-key] + [stop-when stop?])) +} + +@codeblock{ + ;; server.rkt + #lang racketscript/base + (require racketscript/htdp/peer-universe) + (provide start-universe) + + ;; + ;; define all of your event handlers here + ;; + + (define (start-universe) + (universe '() + [on-new handle-new] + [on-msg handle-msg] + [on-tick handle-tick] + [on-disconnect handle-disconnect])) +} + +@codeblock{ + ;; app.rkt + #lang racketscript/base + (require racketscript/htdp/peer-universe + "./client.rkt" + "./server.rkt") + + (create-login-form start-world start-universe) +} + +@linebreak{} + +Here's what you'll see: + +@image["create-login-form.png" #:style "border: 1px solid black;"] + +The @italic{Username} and @italic{Universe's Peer ID} fields allow the user to pick their username and the @racket[peer-id] of the @racket[universe] that they want to connect to. The @italic{Join!} button calls @racket[start-world] passing in the username and @racket[peer-id], and the @italic{Start Universe} button calls @racket[start-universe]. When either button is pressed, the form is removed from the document and replaced by the UI for the @racket[universe] or @racket[big-bang] respectively. + +@defform/subs[(create-login-form bb-callback + u-callback + root) + [(bb-callback bb-callback?) + (u-callback u-callback?) + (root html-element?)]]{ + Generates an HTML form for your application that allows users to join an existing @racket[universe] server as a @racket[big-bang] client, or start a new @racket[universe] server. The @racket[root] parameter allows you to provide a parent element which peer-universe will insert your app's HTML into. By default, the page's body tag will be used. If you do provide an alternate root, I reccomend you use a div unless you know what you're doing.} + diff --git a/racketscript-doc/racketscript/scribblings/racketscript.scrbl b/racketscript-doc/racketscript/scribblings/racketscript.scrbl index 591709b8..2e9c1653 100644 --- a/racketscript-doc/racketscript/scribblings/racketscript.scrbl +++ b/racketscript-doc/racketscript/scribblings/racketscript.scrbl @@ -17,3 +17,4 @@ possible. @include-section{start.scrbl} @include-section{ffi.scrbl} +@include-section{peer-universe.scrbl} diff --git a/racketscript-extras/racketscript/htdp/peer-universe.rkt b/racketscript-extras/racketscript/htdp/peer-universe.rkt new file mode 100644 index 00000000..c92c3b93 --- /dev/null +++ b/racketscript-extras/racketscript/htdp/peer-universe.rkt @@ -0,0 +1,576 @@ +#lang racketscript/base + +(require (for-syntax racketscript/base + syntax/parse) + "../private/jscommon.rkt" + "./private/peer-universe/universe-primitives.rkt" + "./private/peer-universe/encode-decode.rkt" + "./private/peer-universe/universe-server.rkt" + "./private/peer-universe/login-form.rkt") + +(provide on-mouse + on-tick + on-key + on-release + on-receive + register + name + to-draw + stop-when + big-bang + + on-new + on-msg + on-disconnect + server-id + universe + + package? + make-package + + bundle? + make-bundle + mail? + make-mail + + iworld-name + iworld? + iworld=? + + key=? + mouse=? + + create-login-form) + +(define *default-frames-per-second* 70) + +(define (make-big-bang init-world handlers dom-root) + (new (BigBang init-world handlers + (if ($/binop != dom-root $/null) ;; Workaround for problem with + dom-root #js*.document.body)))) ;; default args in nested functions + +(define (big-bang init-world #:dom-root [dom-root $/null] . handlers) + ($> (make-big-bang init-world handlers dom-root) + (setup) + (start))) + +(define-proto BigBang + (λ (init-world handlers dom-root) + #:with-this this + (:= #js.this.world init-world) + (:= #js.this.interval (/ 1000 *default-frames-per-second*)) + (:= #js.this.handlers handlers) + + ;; Lets evt handlers check whether they're being passed a universe or + ;; big-bang instance, so they can adjust their behavior + (:= #js.this.is-universe? #false) + + (:= #js.this.dom-root dom-root) + + (:= #js.this.-active-handlers ($/obj)) + (:= #js.this.-world-change-listeners ($/array)) + (:= #js.this.-package-listeners ($/array)) + + (:= #js.this.-uses-peer #f) + (:= #js.this.-peer-name #js"client") ;; Default name + (:= #js.this.-server-id #js"server") ;; Default server + + (:= #js.this.-peer $/undefined) + (:= #js.this.-conn $/undefined) + (:= #js.this.-peer-init-tasks ($/array)) + + (:= #js.this.-idle #t) + (:= #js.this.-stopped #t) + (:= #js.this.-events ($/array)) + + (define canvas (#js.document.createElement #js"canvas")) + (define ctx (#js.canvas.getContext #js"2d")) + (#js.canvas.setAttribute #js"tabindex" 1) + (#js.canvas.setAttribute #js"style" #js"outline: none") + (:= #js.this.-canvas canvas) + (:= #js.this.-context ctx)) + [setup + (λ () + #:with-this this + + (define canvas #js.this.-canvas) + + (#js.this.dom-root.appendChild canvas) + (#js.canvas.focus) + + (#js.this.register-handlers) + + (when #js.this.-uses-peer (#js.this.init-peer-connection)) + + ;; Set canvas size as the size of first world + (define draw-handler ($ #js.this.-active-handlers #js"to-draw")) + (unless draw-handler + (error 'big-bang "to-draw handle not provided")) + (define img ($$ draw-handler.callback #js.this.world)) + (:= #js.canvas.width #js.img.width) + (:= #js.canvas.height #js.img.height) + + ;; We are reassigning using change-world so that change world + ;; callbacks gets invoked at start of big-bang + (#js.this.change-world #js.this.world) + + this)] + [register-handlers + (λ () + #:with-this this + (define active-handlers #js.this.-active-handlers) + (let loop ([handlers #js.this.handlers]) + (when (pair? handlers) + (define h ((car handlers) this)) + (#js.h.register) + (:= ($ active-handlers #js.h.name) h) + (loop (cdr handlers)))))] + [deregister-handlers + (λ () + #:with-this this + (define active-handlers #js.this.-active-handlers) + ($> (#js*.Object.keys active-handlers) + (forEach + (λ (key) + (define h ($ active-handlers key)) + (#js.h.deregister) + (:= ($ #js.active-handlers #js.h.name) *undefined*)))))] + [start + (λ () + #:with-this this + (:= #js.this.-stopped #f) + ; always draw first, in case no on-tick handler provided + (#js.this.queue-event ($/obj [type #js"to-draw"])) + (#js.this.process-events))] + [stop + (λ () + #:with-this this + (#js.this.clear-event-queue) + (set-object! this + [-stopped #t] + [-idle #t]) + (#js.this.deregister-handlers) + (#js.this.-canvas.remove) + (set-object! #js.this + [-active-handlers ($/obj)] + [handlers '()]))] + [clear-event-queue + (λ () + #:with-this this + (#js.this.-events.splice 0 #js.this.-events.length))] + [queue-event + (λ (e) + #:with-this this + (#js.this.-events.push e) + (when #js.this.-idle + (schedule-animation-frame #js.this 'process_events)))] + [change-world + (λ (handler-result) + #:with-this this + + (define new-world handler-result) + (when (package? handler-result) + (set! new-world (package-world handler-result)) + (#js.this.handle-package handler-result)) + + (define listeners #js.this.-world-change-listeners) + (let loop ([i 0]) + (when (< i #js.listeners.length) + (define listener ($ #js.listeners i)) + (listener new-world) + (loop (add1 i)))) + (:= #js.this.world new-world))] + [add-world-change-listener + (λ (cb) + #:with-this this + (#js.this.-world-change-listeners.push cb))] + [remove-world-change-listener + (λ (cb) + #:with-this this + (define index (#js.this.-world-change-listeners.indexOf cb)) + (#js.this.-world-change-listeners.splice index 1))] + [handle-package + (λ (pkg) + #:with-this this + (define message (package-message pkg)) + (define listeners #js.this.-package-listeners) + (let loop ([i 0]) + (when (< i #js.listeners.length) + (define listener ($ #js.listeners i)) + (listener message) + (loop (add1 i)))))] + [add-package-listener + (λ (cb) + #:with-this this + (#js.this.-package-listeners.push cb))] + [remove-package-listener + (λ (cb) + #:with-this this + (define index (#js.this.-package-listeners.indexOf cb)) + (#js.this.-package-listeners.splice index 1))] + [process-events + (λ () + #:with-this this + (define events #js.this.-events) + + (:= #js.this.-idle #f) + + (let loop ([world-changed? #f]) + (cond + [(> #js.events.length 0) + (define evt (#js.events.shift)) + (define handler ($ #js.this.-active-handlers #js.evt.type)) + + (define changed? + (cond + ; raw evt must be checked 1st; bc handler will be undefined + [(equal? #js.evt.type #js"raw") + (#js.evt.invoke #js.this.world evt)] + [($/binop === handler $/undefined) + (begin (#js*.console.warn #js"WARNING: processing event w/ undefined handler.") (void))] + [handler (#js.handler.invoke #js.this.world evt)] + [else + (#js.console.warn "ignoring unknown/unregistered event type: " evt)])) + (loop (or world-changed? changed?))] + [(and world-changed? (not #js.this.-stopped)) + (#js.this.queue-event ($/obj [type #js"to-draw"])) + (loop #f)])) + + (:= #js.this.-idle #t))] + [init-peer-connection + (λ () + #:with-this this + (define peer (new (Peer))) + (:= #js.this.-peer peer) + + (#js.peer.on #js"open" + (λ () + (define conn (#js.peer.connect (js-string #js.this.-server-id) + ($/obj [label #js.this.-peer-name]))) + (:= #js.this.-conn conn) + (define init-tasks #js.this.-peer-init-tasks) + + (define (on-conn-open) + ;; Loop through this.-peer-init-tasks[] and execute all callbacks + (let loop ([i 0]) + (when (< i #js.init-tasks.length) + (define task ($ #js.init-tasks i)) + (task peer conn) + (loop (add1 i)))) + ;; Let the server know we've disconnected when the window closes + (#js*.window.addEventListener #js"beforeunload" + (λ (_) + (#js.conn.close))) + (#js*.window.addEventListener #js"unload" + (λ (_) + (#js.conn.close)))) + (#js.conn.on #js"open" on-conn-open) + (#js.conn.on #js"close" + (λ (_) + (#js*.console.log #js"conn closed") + (#js*.alert #js"Client disconnected."))))))] + [add-peer-init-task + (λ (cb) ;; cb: (peer: Peer, conn: DataConnection) => void + #:with-this this + ;; If peer and conn already exist, execute callback + ;; else, append callback to this.-peer-init-tasks[] + (define conn #js.this.-conn) + (define peer #js.this.-peer) + (define conn-open? + (if ($/typeof conn "undefined") + #f #js.conn.open)) + (if conn-open? + (cb peer conn) + (#js.this.-peer-init-tasks.push cb)))]) + +(define (to-draw cb) + (λ (bb) + (define on-tick-evt ($/obj [type #js"to-draw"])) + ($/obj + [name #js"to-draw"] + [register (λ () (void))] + [deregister (λ () (void))] + [callback cb] + [invoke (λ (world evt) + (define ctx #js.bb.-context) + (define img (cb #js.bb.world)) + (define height #js.img.height) + (define width #js.img.width) + + (#js.ctx.clearRect 0 0 width height) + (#js.img.render ctx (half width) (half height)) + + #f)]))) + +(define (on-tick cb rate) + (λ (bb-u) + (define on-tick-evt ($/obj [type #js"on-tick"])) + ($/obj + [name #js"on-tick"] + [register (λ () + #:with-this this + (#js.bb-u.queue-event on-tick-evt) + (if rate + (set! rate (* 1000 rate)) + (set! rate #js.bb-u.interval)))] + [deregister (λ () + #:with-this this + (define last-cb #js.this.last-cb) + (when last-cb + ;; TODO: This sometimes doesn't work, + ;; particularly with high fps, so we need to do + ;; something at event loop itself. + (#js*.window.clearTimeout last-cb)))] + [invoke (λ (state _) + #:with-this this + (if #js.bb-u.is-universe? + (#js.bb-u.change-state (cb state)) + (#js.bb-u.change-world (cb state))) + (:= #js.this.last-cb (#js*.setTimeout + (λ () + (#js.bb-u.queue-event on-tick-evt)) + rate)) + #t)]))) + +(define (on-mouse cb) + (λ (bb) + ($/obj + [name #js"on-mouse"] + [listeners ($/obj)] + [register + (λ () + #:with-this this + (define canvas #js.bb.-canvas) + (define (make-listener r-evt-name) + (λ (evt) + (define posn (canvas-posn-δ canvas evt)) + (#js.bb.queue-event ($/obj [type #js"on-mouse"] + [evt (js-string->string r-evt-name)] + [x ($ posn 'x)] + [y ($ posn 'y)])))) + + (define (register-listener evt-name r-evt-name) + (define cb (make-listener r-evt-name)) + (#js.canvas.addEventListener evt-name cb) + (:= ($ #js.this.listeners evt-name) cb)) + + (register-listener #js"mousemove" #js"move") + (register-listener #js"mousedown" #js"button-down") + (register-listener #js"mouseup" #js"button-up") + (register-listener #js"mouseout" #js"leave") + (register-listener #js"mouseover" #js"enter") + (register-listener #js"drag" #js"drag"))] + [deregister + (λ () + #:with-this this + (define (remove-listener evt-name) + (define cb ($ #js.this.listeners evt-name)) + (#js.bb.-canvas.removeEventListener evt-name cb)) + (remove-listener #js"mousemove") + (remove-listener #js"mousedown") + (remove-listener #js"mouseup") + (remove-listener #js"mouseout") + (remove-listener #js"mouseover") + (remove-listener #js"drag"))] + [invoke + (λ (world evt) + (define new-world (cb world #js.evt.x #js.evt.y #js.evt.evt)) + (#js.bb.change-world new-world) + #t)]))) + +(define-syntax-rule (-on-key-* r-evt-name evt-name) + (λ (cb) + (λ (bb) + ($/obj + [name r-evt-name] + [register + (λ () + #:with-this this + (define canvas #js.bb.-canvas) + (:= #js.this.listener + (λ (evt) + (#js.evt.preventDefault) + (#js.evt.stopPropagation) + (#js.bb.queue-event ($/obj [type r-evt-name] + [key (key-event->key-name evt)])))) + (#js.canvas.addEventListener evt-name #js.this.listener))] + [deregister + (λ () + #:with-this this + (#js.bb.-canvas.removeEventListener evt-name #js.this.listener) + (:= #js.this.listener *undefined*))] + [invoke + (λ (world evt) + (define new-world (cb world #js.evt.key)) + (#js.bb.change-world new-world) + #t)])))) + +(define on-key (-on-key-* #js"on-key" #js"keydown")) +(define on-release (-on-key-* #js"on-release" #js"keyup")) + +(define (stop-when last-world? [last-picture #f]) + (λ (bb) + ($/obj + [name #js"stop-when"] + [predicate last-world?] + [lastpicture last-picture] + [register + (λ () + #:with-this this + (#js.bb.add-world-change-listener #js.this.invoke))] + [deregister + (λ () + #:with-this this + (#js.bb.remove-world-change-listener #js.this.invoke))] + [invoke + (λ (w) + (when (last-world? w) + (#js.bb.stop) + (when last-picture + (define handler ((to-draw last-picture) bb)) + (#js.bb.queue-event + ($/obj [type #js"raw"] + [invoke #js.handler.invoke])))))]))) + +;; maps JS KeyboardEvent.key to big-bang KeyEvent +(define key-table + ($/obj [Backspace "\b"] + [Enter "\r"] + [Tab "\t"] + [ArrowLeft "left"] + [ArrowRight "right"] + [ArrowDown "down"] + [ArrowUp "up"] + [Shift "shift"] + [Control "control"] + [ControlRight "rcontrol"] + [ControlLeft "control"] + [ShiftRight "rshift"] + [ShiftLeft "shift"] + [Escape "escape"] + [Home "home"] + [End "end"] + [Insert "insert"] ; no pageup/down in big-bang? + [Delete "\u007F"] ; rubout + [Pause "pause"] + [NumLock "numlock"] + [F1 "f1"] + [F2 "f2"] + [F3 "f3"] + [F4 "f4"] + [F5 "f5"] + [F6 "f6"] + [F7 "f7"] + [F8 "f8"] + [F9 "f9"] + [F10 "f10"] + [F11 "f11"] + [F12 "f12"] + ; unsure about these big bang KeyEvents: + ;; "start" + ;; "cancel" + ;; "clear" + ;; "menu" + ;; "capital" + ;; "prior" + ;; "next" + ;; "select" + ;; "print" + ;; "execute" + ;; "snapshot" + ;; "help" + ;; "scroll" + )) + +(define (key-event->key-name e) + (define k #js.e.key) + (define code ; use .code to differentiate left/right shift, ctrl, alt + (if (or ($/binop === k #js"Shift") ($/binop === k #js"Control") ($/binop === k #js"Alt")) + #js.e.code + k)) + (let ([key-table-code ($ key-table code)]) + (if (void? key-table-code) + (js-string->string code) + key-table-code))) + +(define (canvas-posn-δ canvas evt) + (define rect (#js.canvas.getBoundingClientRect)) + ($/obj + [x (- #js.evt.clientX #js.rect.left)] + [y (- #js.evt.clientY #js.rect.top)])) + +(define (key=? k1 k2) + (equal? k1 k2)) +(define (mouse=? m1 m2) + (equal? m1 m2)) + +(define (on-receive cb) + (λ (bb) + (define on-receive-evt ($/obj [type #js"on-receive"])) + ($/obj + [name #js"on-receive"] + [register + (λ () + #:with-this this + (#js.bb.add-peer-init-task + (λ (peer conn) + (:= #js.this.conn-data-listener + (λ (data) + (#js.bb.queue-event ($/obj [type #js.on-receive-evt.type] + [msg data])))) + (#js.conn.on #js"data" #js.this.conn-data-listener) + (:= #js.this.package-listener + (λ (message) + #:with-this this + (#js.conn.send (encode-data message)))) + (#js.bb.add-package-listener #js.this.package-listener))))] + [deregister + (λ () + #:with-this this + (define peer #js.bb.-peer) + (define should-destroy-peer? + (if ($/typeof peer "undefined") + #f + (not #js.peer.disconnected))) + (when should-destroy-peer? + (#js.peer.disconnect) + (#js.peer.destroy)) + (#js.bb.remove-package-listener #js.this.package-listener))] + [invoke + (λ (world evt) + #:with-this this + (#js.bb.change-world (cb world (decode-data #js.evt.msg))) + #t)]))) + +(define (register server-id) + (λ (bb) + ($/obj + [name #js"register"] + [register + (λ () + #:with-this this + (:= #js.bb.-server-id server-id) + (:= #js.bb.-uses-peer #t))] + [deregister + (λ () + #:with-this this + (define conn #js.bb.-conn) + (define conn-open? + (if ($/typeof conn "undefined") + #f #js.conn.open)) + (#js*.console.log conn-open?) + (when conn-open? (#js.conn.close)))] + [invoke + (λ (world evt) + #:with-this this + #t)]))) + +(define (name name) + (λ (bb) + ($/obj + [name #js"name"] + [register + (λ () + #:with-this this + (:= #js.bb.-peer-name (js-string name)))] + [deregister (λ () (void))]))) \ No newline at end of file diff --git a/racketscript-extras/racketscript/htdp/private/peer-universe/encode-decode.rkt b/racketscript-extras/racketscript/htdp/private/peer-universe/encode-decode.rkt new file mode 100644 index 00000000..9ac2dba0 --- /dev/null +++ b/racketscript-extras/racketscript/htdp/private/peer-universe/encode-decode.rkt @@ -0,0 +1,96 @@ +#lang racketscript/base + +(provide encode-data + decode-data) + +(require "util.rkt") + +; +; --------------------------------------------- +; +; Encoding data to be sent via json and decoded +; by receiver into regular JS +; +; --------------------------------------------- +; +; example: +; +; 'sym +; | encoded and sent over peer connection +; V +; { +; val: "sym", type: "symbol" +; } +; | received and decoded +; V +; 'sym +; +; --------------------------------------------- +; + +(define DATA-TYPE-WARNING + #js"racketscript/htdp/universe: Unsupported datatype being passed to/from server.") + +(define (encode-array arr) + (#js.arr.map (lambda (elem) (encode-data elem)))) + +(define (decode-array arr) + (#js.arr.map (lambda (elem) (decode-data elem)))) + +(define (encode-object obj) + (define keys (#js*.Object.keys obj)) + (#js.keys.reduce (lambda (res key) + ($/:= ($ res key) (encode-data ($ obj key))) + res) + ($/obj))) + +(define (decode-object obj) + (define keys (#js*.Object.keys obj)) + (#js.keys.reduce (lambda (res key) + ($/:= ($ res key) (decode-data ($ obj key))) + res) + ($/obj))) + +(define (encode-data data) + (cond [(list? data) (foldl (lambda (curr result) + (#js.result.push (encode-data curr)) + result) + ($/array) + data)] + [(null? data) ($/obj [type #js"null"])] + [(undefined? data) ($/obj [type #js"undefined"])] + [(number? data) ($/obj [type #js"number"] + [val data])] + [(string? data) ($/obj [type #js"string"] + [val (js-string data)])] + [(symbol? data) ($/obj [type #js"symbol"] + [val (js-string (symbol->string data))])] + [(boolean? data) ($/obj [type #js"boolean"] + [val data])] + [(js-string? data) ($/obj [type #js"js-string"] + [val data])] + [(js-array? data) ($/obj [type #js"js-array"] + [val (encode-array data)])] + [(js-object? data) ($/obj [type #js"js-object"] + [val (encode-object data)])] + [else (begin + (#js*.console.warn ($/array DATA-TYPE-WARNING data)) + ($/obj [type #js"unknown"] + [val data]))])) + +(define (decode-data data) + (cond [(#js*.Array.isArray data) (#js.data.reduce (lambda (result curr) + (append result (list (decode-data curr)))) + '())] + [($/binop == #js.data.type #js"null") $/null] + [($/binop == #js.data.type #js"undefined") $/undefined] + [($/binop == #js.data.type #js"number") #js.data.val] + [($/binop == #js.data.type #js"string") (js-string->string #js.data.val)] + [($/binop == #js.data.type #js"symbol") (string->symbol (js-string->string #js.data.val))] + [($/binop == #js.data.type #js"boolean") #js.data.val] + [($/binop == #js.data.type #js"js-string") #js.data.val] + [($/binop == #js.data.type #js"js-array") (decode-array #js.data.val)] + [($/binop == #js.data.type #js"js-object") (decode-object #js.data.val)] + [($/binop == #js.data.type #js"unknown") (begin + (#js*.console.warn DATA-TYPE-WARNING) + #js.data.val)])) \ No newline at end of file diff --git a/racketscript-extras/racketscript/htdp/private/peer-universe/login-form.rkt b/racketscript-extras/racketscript/htdp/private/peer-universe/login-form.rkt new file mode 100644 index 00000000..bffc74dd --- /dev/null +++ b/racketscript-extras/racketscript/htdp/private/peer-universe/login-form.rkt @@ -0,0 +1,66 @@ +#lang racketscript/base + +(require (for-syntax racketscript/base + syntax/parse) + "../../../private/jscommon.rkt" + "util.rkt") + +(provide create-login-form) + + +;; +;; User login UI +;; + +(define (create-login-form big-bang-callback universe-callback [root #js*.document.body]) + (define container (#js*.document.createElement #js"div")) + + (define join-form (#js*.document.createElement #js"form")) + (define name-label (#js*.document.createElement #js"label")) + (define br-1 (#js*.document.createElement #js"br")) + (define name-input (#js*.document.createElement #js"input")) + (define br-2 (#js*.document.createElement #js"br")) + (define server-id-label (#js*.document.createElement #js"label")) + (define br-3 (#js*.document.createElement #js"br")) + (define server-id-input (#js*.document.createElement #js"input")) + (define br-4 (#js*.document.createElement #js"br")) + (define form-submit (#js*.document.createElement #js"input")) + + (define hr (#js*.document.createElement #js"hr")) + (define universe-button (#js*.document.createElement #js"button")) + + (:= #js.name-label.innerHTML #js"Username") + (:= #js.server-id-label.innerHTML #js"Universe's Peer ID") + (:= #js.name-input.placeholder #js"michael1234") + (:= #js.server-id-input.placeholder (js-string (generate-id))) + (:= #js.form-submit.type #js"submit") + (:= #js.form-submit.value #js"Join!") + + (:= #js.universe-button.innerHTML #js"Start Universe") + + (for-each (λ (el) + (#js.join-form.appendChild el) + 0) + (list name-label br-1 name-input + br-2 + server-id-label br-3 server-id-input + br-4 + form-submit)) + + (:= #js.join-form.onsubmit + (λ () + (big-bang-callback (js-string->string #js.name-input.value) + (js-string->string #js.server-id-input.value) + root) + (#js.container.remove))) + + (:= #js.universe-button.onclick + (λ () + (universe-callback root) + (#js.container.remove))) + + (#js.container.appendChild join-form) + (#js.container.appendChild hr) + (#js.container.appendChild universe-button) + + (#js.root.appendChild container)) \ No newline at end of file diff --git a/racketscript-extras/racketscript/htdp/private/peer-universe/server-gui.rkt b/racketscript-extras/racketscript/htdp/private/peer-universe/server-gui.rkt new file mode 100644 index 00000000..6c929a72 --- /dev/null +++ b/racketscript-extras/racketscript/htdp/private/peer-universe/server-gui.rkt @@ -0,0 +1,100 @@ +#lang racketscript/base + +(require (for-syntax racketscript/base + syntax/parse) + "encode-decode.rkt" + "universe-primitives.rkt" + "../../../private/jscommon.rkt") + +(provide server-gui) + +(define WIDTH 500) +(define HEIGHT 300) + +(define-proto ServerLogger + (λ (root) + #:with-this this + + (:= #js.this.logs ($/array)) + (:= #js.this.autoscroll? #true) + + (:= #js.this.peer-id #js"") + + ;; Create elements + (:= #js.this.container (#js*.document.createElement #js"div")) + (:= #js.this.textbox (#js*.document.createElement #js"textarea")) + (:= #js.this.checkbox-div (#js*.document.createElement #js"div")) + (:= #js.this.checkbox-label (#js*.document.createElement #js"label")) + (:= #js.this.checkbox (#js*.document.createElement #js"input")) + (:= #js.this.id-text (#js*.document.createElement #js"em")) + (:= #js.this.id-copy-button (#js*.document.createElement #js"button")) + + ;; Configure elements + (:= #js.this.container.style.display #js"none") + (:= #js.this.container.style.width (js-string (format "~apx" WIDTH))) + (:= #js.this.container.style.height (js-string (format "~apx" HEIGHT))) + + (:= #js.this.textbox.style.width #js"inherit") + (:= #js.this.textbox.style.height #js"inherit") + + (:= #js.this.checkbox-label.for #js"autoscroll") + (:= #js.this.checkbox-label.innerHTML #js"autoscroll with new input") + (:= #js.this.checkbox.type #js"checkbox") + (:= #js.this.checkbox.onclick (lambda () (:= #js.this.autoscroll? #js.this.checkbox.checked))) + (:= #js.this.checkbox.checked #true) + + (:= #js.this.id-text.innerHTML #js"peer id: undefined ") + (:= #js.this.id-copy-button.innerHTML #js"copy") + (:= #js.this.id-copy-button.style.margin-left #js"5px") + + (:= #js.this.id-copy-button.onclick + (λ () + (#js*.navigator.clipboard.writeText #js.this.peer-id) + (#js*.alert #js"Copied peer ID to clipboard."))) + + ;; Add elements to document + (#js.this.checkbox-div.appendChild #js.this.checkbox-label) + (#js.this.checkbox-div.appendChild #js.this.checkbox) + + (#js.this.container.appendChild #js.this.id-text) + (#js.this.container.appendChild #js.this.id-copy-button) + (#js.this.container.appendChild #js.this.textbox) + (#js.this.container.appendChild #js.this.checkbox-div) + (#js.root.appendChild #js.this.container) + this) + [log + (λ (text) + #:with-this this + (#js.this.logs.push (js-string text)) + (#js.this.render) + (#js*.console.log (js-string text)))] + [show + (λ () + #:with-this this + (:= #js.this.container.style.display "block"))] + [hide + (λ () + #:with-this this + (:= #js.this.container.style.display #js"none"))] + [set-id! + (λ (new-id) + #:with-this this + (:= #js.this.peer-id new-id) + (:= #js.this.id-text.innerHTML (js-string (format "peer id: ~a " new-id))))] + [render + (λ () + #:with-this this + (define log-string (#js.this.logs.reduce (λ (res curr) + (if ($/binop === res #js"") + (js-string curr) + ($/+ res #js"\n\n" (js-string curr)))) + #js"")) + (:= #js.this.textbox.innerHTML log-string) + (when (equal? #js.this.autoscroll? #true) + (:= #js.this.textbox.scrollTop #js.this.textbox.scrollHeight)))]) + +(define (make-gui root) + (new (ServerLogger root))) + +(define (server-gui [root-element #js*.document.body]) + (make-gui root-element)) \ No newline at end of file diff --git a/racketscript-extras/racketscript/htdp/private/peer-universe/universe-primitives.rkt b/racketscript-extras/racketscript/htdp/private/peer-universe/universe-primitives.rkt new file mode 100644 index 00000000..583f0e88 --- /dev/null +++ b/racketscript-extras/racketscript/htdp/private/peer-universe/universe-primitives.rkt @@ -0,0 +1,80 @@ +#lang racketscript/base + +(require ;htdp/error + racket/list) + +(provide make-package + package? + package-world + package-message + + make-bundle + bundle? + + make-mail + mail? + + iworld-name + iworld? + iworld=? + + ;; private + bundle-state + bundle-mails + bundle-low-to-remove + + ;; private + mail-to + mail-content + + ;; private + make-iworld + iworld-conn) + +(struct u-package (world message)) +(define (make-package world message) + (u-package world message)) +(define (package? p) + (u-package? p)) +(define (package-world p) + (u-package-world p)) +(define (package-message p) + (u-package-message p)) + +(struct u-bundle (state mails low-to-remove)) +(define (make-bundle state mails low-to-remove) + (u-bundle state mails low-to-remove)) +(define (bundle? bundle) + (u-bundle? bundle)) +(define (bundle-state b) + (u-bundle-state b)) +(define (bundle-mails b) + (u-bundle-mails b)) +(define (bundle-low-to-remove b) + (u-bundle-low-to-remove b)) + +(struct u-mail (to content)) +(define (make-mail to content) + (u-mail to content)) +(define (mail? mail) + (u-mail? mail)) +(define (mail-to mail) + (u-mail-to mail)) +(define (mail-content mail) + (u-mail-content mail)) + +(struct u-iworld (conn name)) +;; for client code use +(define (iworld-name iworld) + (u-iworld-name iworld)) +(define (iworld? iworld) + (u-iworld? iworld)) +(define (iworld=? iw1 iw2) + (define conn1 (u-iworld-conn iw1)) + (define conn2 (u-iworld-conn iw2)) + ($/binop === conn1 conn2)) +;; not for client code use +(define (make-iworld conn name) + (u-iworld conn name)) +(define (iworld-conn iw) + (u-iworld-conn iw)) \ No newline at end of file diff --git a/racketscript-extras/racketscript/htdp/private/peer-universe/universe-server.rkt b/racketscript-extras/racketscript/htdp/private/peer-universe/universe-server.rkt new file mode 100644 index 00000000..4e860371 --- /dev/null +++ b/racketscript-extras/racketscript/htdp/private/peer-universe/universe-server.rkt @@ -0,0 +1,321 @@ +#lang racketscript/base + +(require (for-syntax racketscript/base + syntax/parse) + "../../../private/jscommon.rkt" + "server-gui.rkt" + "encode-decode.rkt" + "universe-primitives.rkt" + "util.rkt") + +(provide universe + + on-new + on-msg + on-disconnect + server-id + + Peer) + +;; Adds peerjs package exports to global window object as properties +(define peerjs ($/require "https://cdnjs.cloudflare.com/ajax/libs/peerjs/1.4.7/peerjs.min.js" *)) + +(define Peer #js*.window.Peer) + +(define *default-frames-per-second* 70) + +(define (make-universe init-state handlers gui-root) + (new (Universe init-state handlers + (if ($/binop != gui-root $/null) ;; Workaround for problem with + gui-root #js*.document.body)))) ;; default args in nested functions + +(define (universe init-state #:dom-root [gui-root $/null] . handlers) + ($> (make-universe init-state handlers gui-root) + (setup) + (start))) + +(define-proto Universe + (λ (init-state handlers gui-root) + #:with-this this + (:= #js.this.state init-state) + (:= #js.this.interval (/ 1000 *default-frames-per-second*)) + (:= #js.this.handlers handlers) + + ;; Lets evt handlers check whether they're being passed a universe or + ;; big-bang instance, so they can adjust their behavior + (:= #js.this.is-universe? #true) + + (:= #js.this.gui (server-gui gui-root)) + + (:= #js.this.-active-handlers ($/obj)) + (:= #js.this.-state-change-listeners ($/array)) + (:= #js.this.-message-listeners ($/array)) + + (:= #js.this.-peer $/undefined) + (:= #js.this.-peer-init-tasks ($/array)) + (:= #js.this.-active-iworlds ($/array)) + (:= #js.this.-disconnect-tasks ($/array)) + + (:= #js.this.-peer-id (generate-id)) + + (:= #js.this.-idle #t) + (:= #js.this.-stopped #t) + (:= #js.this.-events ($/array))) + [setup + (λ () + #:with-this this + (#js.this.register-handlers) + (#js.this.gui.show) + + (define (log-connection conn) + (#js.this.gui.log (format "~a signed up" + (js-string->string #js.conn.label)))) + (define (log-new-msg iw data) + (#js.this.gui.log (format "~a --> universe:\n<~a>" + (iworld-name iw) (msg->string (decode-data data))))) + + (#js.this.add-peer-init-task (λ (peer) + (#js.peer.on #js"connection" + log-connection))) + (#js.this.-message-listeners.push log-new-msg) + + this)] + [start + (λ () + #:with-this this + (#js.this.init-peer-connection) + (define peer-id (js-string->string #js.this.-peer.id)) + (#js.this.gui.log (format "a new universe is up and running with id ~s" + peer-id)) + (#js.this.gui.set-id! peer-id) + this)] + [register-handlers + (λ () + #:with-this this + (define active-handlers #js.this.-active-handlers) + (let loop ([handlers #js.this.handlers]) + (when (pair? handlers) + (define h ((car handlers) this)) + (#js.h.register) + (:= ($ active-handlers #js.h.name) h) + (loop (cdr handlers)))))] + [deregister-handlers + (λ () + #:with-this this + (define active-handlers #js.this.-active-handlers) + ($> (#js*.Object.keys active-handlers) + (forEach + (λ (key) + (define h ($ active-handlers key)) + (#js.h.deregister) + (:= ($ #js.active-handlers #js.h.name) *undefined*)))))] + [stop + (λ () + #:with-this this + (#js.this.gui.log "stopping the universe\n----------------------------------") + (#js.this.clear-event-queue) + (set-object! this + [-stopped #t] + [-idle #t]) + (#js.this.deregister-handlers) + (#js.this.-canvas.remove) + (set-object! #js.this + [-active-handlers ($/obj)] + [handlers '()]))] + [clear-event-queue + (λ () + #:with-this this + (#js.this.-events.splice 0 #js.this.-events.length))] + [add-state-change-listener + (λ (cb) + #:with-this this + (#js.this.-state-change-listeners.push cb))] + [remove-state-change-listener + (λ (cb) + #:with-this this + (define index (#js.this.-state-change-listeners.indexOf cb)) + (#js.this.-state-change-listeners.splice index 1))] + [queue-event + (λ (e) + #:with-this this + (#js.this.-events.push e) + (when #js.this.-idle + (schedule-animation-frame #js.this 'process_events)))] + [process-events + (λ () + #:with-this this + (define events #js.this.-events) + + (:= #js.this.-idle #f) + + (let loop ([state-changed? #f]) + (cond + [(> #js.events.length 0) + (define evt (#js.events.shift)) + (define handler ($ #js.this.-active-handlers #js.evt.type)) + (define changed? + (cond + ; raw evt must be checked 1st; bc handler will be undefined + [(equal? #js.evt.type #js"raw") + (#js.evt.invoke #js.this.state evt)] + [(not ($/typeof handler "undefined")) + (#js.handler.invoke #js.this.state evt)] + [else + (#js.console.warn "ignoring unknown/unregistered event type: " evt)])) + (loop (or state-changed? changed?))])) + + (:= #js.this.-idle #t))] + [change-state + (λ (result-bundle) + #:with-this this + + ;; bundle? + ;; | + ;; V + ;; https://docs.racket-lang.org/teachpack/2htdpuniverse.html#%28def._%28%28lib._2htdp%2Funiverse..rkt%29._bundle~3f%29%29 + + (define new-state (bundle-state result-bundle)) + (define mails (bundle-mails result-bundle)) + (define low-to-remove (bundle-low-to-remove result-bundle)) + + ;; Send all mails + (for-each (lambda (curr-mail) + (define iworld (mail-to curr-mail)) + (define conn (iworld-conn iworld)) + (#js.conn.send (encode-data (mail-content curr-mail))) + (#js.this.gui.log (format "universe --> ~a:\n<~a>" + (iworld-name iworld) + (mail-content curr-mail)))) + mails) + + ;; Remove all worlds in low-to-remove + (for-each (lambda (iw) + (define conn (iworld-conn iw)) + (define index (#js.this.-active-iworlds.indexOf iw)) + (#js.conn.close) + (when (> index -1) (#js.this.-active-iworlds.splice index 1))) + low-to-remove) + + (define listeners #js.this.-state-change-listeners) + (let loop ([i 0]) + (when (< i #js.listeners.length) + (define listener ($ #js.listeners i)) + (listener new-state) + (loop (add1 i)))) + (:= #js.this.state new-state))] + [init-peer-connection + (λ (id) + #:with-this this + (define peer (new (Peer #js.this.-peer-id))) + (:= #js.this.-peer peer) + (#js.peer.on #js"open" + (λ () + (define init-tasks #js.this.-peer-init-tasks) + (let loop ([i 0]) + (when (< i #js.init-tasks.length) + (define task ($ #js.init-tasks i)) + (task peer) + (loop (add1 i)))))))] + [add-peer-init-task + (λ (cb) ;; cb = (peer: Peer) => void + #:with-this this + ;; If peer already exists, execute callback + ;; else, append callback to this.-peer-init-tasks[] + (define peer #js.this.-peer) + (define peer-started? (not ($/typeof peer "undefined"))) + + (if peer-started? + (cb peer) + (#js.this.-peer-init-tasks.push cb)))] + [pass-message ;; Passes message abd sender's iworld instance to this.-message-listeners + (λ (sender-iw data) + #:with-this this + (#js.this.-message-listeners.forEach + (λ (cb) (cb sender-iw data))))] + [handle-disconnect + (λ (iw) + #:with-this this + (define tasks #js.this.-disconnect-tasks) + (let loop ([i 0]) + (when (< i #js.tasks.length) + (define task ($ tasks i)) + (task iw) + (loop (add1 i)))) + (#js.this.gui.log (format "~a !! closed port" (iworld-name iw))))]) + +(define (on-new cb) + (λ (u) + (define on-new-evt ($/obj [type #js"on-new"])) + ($/obj + [name #js"on-new"] + [register (λ () + #:with-this this + (define (init-task peer) + (define (handle-connection conn) + (define name "client name") + (when #js.conn.label + (set! name (js-string->string #js.conn.label))) + (define iw (make-iworld conn name)) + (#js.u.-active-iworlds.push iw) + (#js.u.queue-event ($/obj [type #js"on-new"] + [iWorld iw])) + (#js.conn.on #js"close" (λ () (#js.u.handle-disconnect iw))) + (#js.conn.on #js"data" (λ (data) (#js.u.pass-message iw data)))) + + (#js.peer.on #js"connection" handle-connection)) + + (#js.u.add-peer-init-task init-task))] + [deregister (λ () ;; TODO: implement this + #:with-this this + 0)] + [invoke (λ (state evt) + #:with-this this + (define conn (iworld-conn #js.evt.iWorld)) + (#js.conn.on #js"open" + (λ (_) + (#js.u.change-state (cb state #js.evt.iWorld)))) + #t)]))) + +(define (on-disconnect cb) + (λ (u) + (define on-disconnect-evt ($/obj [type #js"on-disconnect"])) + ($/obj + [name #js"on-disconnect"] + [register (λ () + #:with-this this + (#js.u.-disconnect-tasks.push + (λ (iworld) + (#js.u.queue-event ($/obj [type #js"on-disconnect"] + [iWorld iworld])))))] + [deregister (λ () ; TODO: implement this + 0)] + [invoke (λ (state evt) + #:with-this this + (#js.u.change-state (cb state #js.evt.iWorld)) + #t)]))) + +(define (server-id id) + (λ (u) + ($/obj + [name #js"server-id"] + [register (λ () (:= #js.u.-peer-id (js-string id)))] + [deregister (λ () (:= #js.u.-peer-id (generate-id)))]))) + +(define (on-msg cb) + (λ (u) + (define on-msg-evt ($/obj [type #js"on-msg"])) + ($/obj + [name #js"on-msg"] + [register (λ () + #:with-this this + (define (handle-msg sender data) + (#js.u.queue-event ($/obj [type #js"on-msg"] + [iWorld sender] + [msg data]))) + (#js.u.-message-listeners.push handle-msg))] + [deregister (λ () ;; TODO: implement this + #:with-this this + 0)] + [invoke (λ (state evt) + (#js.u.change-state (cb state #js.evt.iWorld (decode-data #js.evt.msg))) + #t)]))) \ No newline at end of file diff --git a/racketscript-extras/racketscript/htdp/private/peer-universe/util.rkt b/racketscript-extras/racketscript/htdp/private/peer-universe/util.rkt new file mode 100644 index 00000000..d3ad49f4 --- /dev/null +++ b/racketscript-extras/racketscript/htdp/private/peer-universe/util.rkt @@ -0,0 +1,123 @@ +#lang racketscript/base + +(require (for-syntax racketscript/base + syntax/parse)) + +(provide format-js-str + generate-id + js-string? + js-object? + null? + undefined? + js-array? + msg->string) + +(define-syntax-rule (format-js-str fmt-str args ...) + (js-string (format fmt-str args ...))) + + +;; +;; Funny words courtesy of ChatGPT +;; + +(define funny-adjectives (list "bumbling" + "quizzical" + "wacky" + "zany" + "fluffy" + "bizarre" + "hilarious" + "whimsical" + "absurd" + "goofy" + "ridiculous" + "loopy" + "nutty" + "eccentric" + "silly" + "quirky" + "jovial" + "giggly" + "mirthful" + "haphazard" + "chucklesome" + "fanciful" + "droll" + "boisterous" + "offbeat" + "hysterical" + "peculiar" + "lighthearted" + "playful" + "amusing")) + +(define funny-nouns (list "goober" + "banana" + "sock-puppet" + "llama" + "rubber-chicken" + "pajamas" + "gobbledygook" + "poodle" + "bubble-wrap" + "tater-tot" + "cheeseburger" + "wiggle" + "snorkel" + "ticklemonster" + "jello" + "balloon-animal" + "slinky" + "spaghetti" + "bumblebee" + "dingleberry" + "flapdoodle" + "doohickey" + "noodle" + "gobbledygook" + "whatchamacallit" + "snickerdoodle" + "popsicle" + "gigglesnort" + "wobble" + "hootenanny" + "noodle")) + +(define (generate-id) + (define adjective (list-ref funny-adjectives (random (length funny-adjectives)))) + (define noun (list-ref funny-nouns (random (length funny-nouns)))) + (format "~a-~a" adjective noun)) + + +(define (js-string? s) + (or ($/typeof s "string") ($/instanceof s #js*.String))) + +;; NOTE: because every racket datatype in +;; racketscript is stored as a js object, +;; ($/typeof obj ) +;; will always be true +(define (js-object? obj) + (and (not (string? obj) + (number? obj) + (boolean? obj) + (list? obj) + (symbol? obj) + (struct? obj)) + ($/typeof obj "object"))) + +(define (null? val) + ($/binop === val $/null)) + +(define (undefined? val) + ($/binop === val $/undefined)) + +(define (js-array? arr) + (#js*.Array.isArray arr)) + +(define (msg->string msg) + (cond [(undefined? msg) "undefined"] + [(js-string? msg) (js-string->string msg)] + [(or (js-object? msg) (js-array? msg) (null? msg)) + (#js*.JSON.stringify msg)] + [else (format "~a" msg)])) +