Skip to content

The universal ISSR server compatible with <your-favorite-programming-language>

License

Notifications You must be signed in to change notification settings

interactive-ssr/issr-server

Repository files navigation

Interactive Server Side Rendering (ISSR)

Make sure to see the main readme for more in-depth information.

Getting Started

1 Install and Run

Docker

$ docker pull charje/issr-server
$ docker run --rm \
  # this is needed for file upload
  - v /tmp:/tmp \
  # this is needed for running hooks (including file upload)
  -v ~/.local/share/issr/:~/.local/share/issr/ \
  # optional configuration file
  -v config.lisp:~/.config/issr/config.lisp \
  # optional environment variable configuration
  -e <environment variable configuration> \
  issr-server

The important thing is that your :application-destination configuration slot can get through to the correct network. You can do this with network sharing, docker compose, or using a special docker hostname.

Docker Compose

networks:
  shared:
    name: shared
my-http-server:
  build: .
  networks:
    - shared
  ports:
    - 8080:8080
services:
  issr:
    image: charje/issr-server
    environment:
      ISSR_APPLICATION: my-http-server:8080
    networks:
      - shared
    ports:
      - 3000:3000

see https://github.com/interactive-ssr/tally-demo for a full example.

Network Sharing

This one only works on linux. Use the special network host.

docker run --network host charje/issr-server

Docker Hostname

Only for Windows and Mac. Docker has special dns to access host network.

Configure the :application-destination to be something like host.docker.internal:8080.

Linux

Download latest release.

$ ./gnu/bin/issr-server <config> &> logfile &

Guix

$ guix install issr-server
$ issr-server <config> &> logfile &

This will probably be a service one day that you can put in your /etc/config.scm and deploy.

2 Configure

To get started, you don’t need any configuration; just run your development HTTP server on port 8080 and access it at http://localhost:3000. It is important to run your HTTP server without TLS. If you want encryption (https), Use the SSL configuration options.

For more information about configuration, see configuration document.

3 Re-Render

Call Re-Render:

<input name="x" value="1" type="number"/>
<input name="y" value="2" type="number"/>
<button action="add" onclick="rr(this)">
  I am add button
</button>

Now your server should have an argument who’s name is “add”. All other named elements on the page will be query-string arguments too. Clicking this button will send re-render the page equivalent to this query string: x=1&y=2&add=t.

The name attribute of any all the named arguments will be unique in the query string. For example the following is equivalent to the previous example (using JSON):

<button onclick="rr({action:'add',value:'t'})">
    I am add button
</button>

rr can also take no arguments.

The difference between attribute name and attribute action is that action will only be included in the query string if is an argument to rr. Named elements will appear regardless. Only one action can be sent at a time. Actions and Names share the same namespace so you should not use the string to identify an action and a name.

Data Binding

Variable to Element

This is for when you want to display data to your user. Just put the variable where you want it.

<p>{my-variable}</p>

Element to Variable

This is for when you want to get data from your user. Just name it!

<input name="my-variable"/>

In your handler you will have an argument called my-variable. Depending on how soon you want this data, you can add an rr.

<input
  name="my-variable"
  <!-- every time the user types -->
  oninput="rr()"
  <!-- when the user focuses on another element of the page -->
  onblur="rr()"
  <!-- 800 milliseconds after the user is done typing -->
  oninput="drr(this.id)()"
  <!-- 300 milliseconds after the user is done typing -->
  oninput="drr(this.id, 300)()"/> 

Two Way Binding

This is when you want to get input from your user, but you want to control it is some way. For the most part, you just do the previous 2 strategies together. Sometimes, this does not quite work. To remedy this, include the update attribute when the processed data is different than the raw data.

<input name="data" value={data} oninput="rr()"
       update={if (rawdata != data) "t"
               else ""}/>

See Input Control for a much more in-depth example

Radio Buttons

<input type="radio" name="direction" value="north" selected="true"/><label for="north">North</label><br />
<input type="radio" name="direction" value="east" /><label for="east">East</label><br />
<input type="radio" name="direction" value="south" /><label for="south">South</label><br />
<input type="radio" name="direction" value="west" /><label for="west">West</label><br />
<button action="submit" onclick="rr(this)">
  Submit
</button>

Pressing the submit button will produce a query string like so: submit=t&direction=north. If no radio button is selected, the name for the radio button will not appear in the query string at all.

File Upload

<input type="file" name="your-file" />
<button action="upload" onclick="rr(this)">
  Upload
</button>

On the server the your-file http parameter will be set to a json with the following schema

{
  "file": string, // what the file is named on the server
  "name": string, // what the file was named on the client
  "content-type": string // the http content type of the file
}

This value can be customized and acted upon using the file-upload-hook provided by your language’s ISSR module.

Image Maps

Server Maps

<a onclick="rr({action:'smap',value:event.offsetX+','+event.offsetY})">
  <img src="picture.png" ismap />
</a>

Clicking the picture will produce produce include smap=x,y in the query string where x,y is the coordinate of the click and 0,0 is the top left corner of the picture (y is inverted).

Client Maps

<img src="picture.png" usemap="#cmap"
     width="200" height="200"/>
<map name="cmap">
  <area action="cmap" value="top-left" onclick="rr(this)"
        shape="rect" coords="0,0,100,100">
  <area action="cmap" value="top-right" onclick="rr(this)"
        shape="rect" coords="100,0,200,100">
  <area action="cmap" value="bottom-left" onclick="rr(this)"
        shape="rect" coords="0,100,100,200">
  <area action="cmap" value="bottom-right" onclick="rr(this)"
        shape="rect" coords="100,100,200,200">
</map>

Clicking the top left quadrant of the picture will include cmap=top-left in the query string. See area-tag for more options to define differently shaped areas.

Reusable Components

The trick to keeping state is to require reusable components to have the id attribute. This not only make it unique, but it gives us a string to use as a HTTP parameter or session variable. The HTTP parameter can remember any state required by the component. The reason this works is because it is not required for the web programmer to specify HTTP parameters. The person using a Component doesn’t need to worry about the existence of this variable (only not to use it for something else).

Tab Box

https://github.com/interactive-ssr/issr-server/blob/master/tab-box.png

The tab-box and tab tags will never make it to the client.

<tab-box id="tb-one">
  <tab title="Lorem">
    <p>
      Sit amet...
    </p>
  </tab>
  <tab title="Ipsum">
    <p>
      Nullam...
    </p>
  </tab>
  <tab title="Dolor">
    <p>
      Pellentesque...
    </p>
  </tab>
</tab-box>

For this example will use the Common Lisp with Hunchentoot and markup libraries for HTTP and HTML generation respectively.

First, we make the tab tag. The only purpose of this is to not make the user type a colon and ensure that the title attribute is present because the title text will be used for the tab buttons.

(deftag tab (children &key (title (error "tab must have a title")))
  <:tab title=title >
    ,@children
  </:tab>)

Second, we make the tab-box tag (id attribute required),

(deftag tab-box (children &key (id (error "tab-box must have an id"))
                 title class style)

Get list of tab titles, and decide the active one based on the HTTP parameter.

(let* ((tabs (mapcar
              ;; get the tag titles
              (lambda (tab)
                (cdr (assoc "title"
                            (xml-tag-attributes tab)
                            :test #'string=)))
              (remove-if-not
               ;; remove whitespace and comment elements
               (lambda (child)
                 (typep child 'xml-tag))
               children)))
       (active (or (parameter id) (first tabs))))

Let id class and style attributes fall through to the encompassing div and put a bold title if it was provided.

<div id=id class=(str:join " " (cons "tab-box" class))
     style=style >
  ,(when title
     <merge-tag>
       <b>,(progn title)</b>
       <br/>
     </merge-tag>)

Put a nav tag to hold the tab buttons. The action attribute will become the HTTP parameter with the value of whatever tab is selected. The name attribute will “remember” which tab we are on when we are not clicking tabs. the onclick will send the value to the server through the action attribute (which is whatever id is id).

<nav>
  ,@(mapcar
     (lambda (tab)
       <button action=id
               name=(when (string= tab active)
                      id)
               value=tab
               selected=(string= tab active)
               onclick="rr(this)">
         ,(progn tab)
       </button>)
     tabs)
</nav>

Dump out the children of the tab tags out wrapped in div class “tab-content”, so we can use CSS to chose which ones to hide and show.

  ,@(mapcar
     (lambda (tab child)
       <div selected=(string= tab active)
            class="tab-content">
         ,@(xml-tag-children child)
       </div>)
     tabs
     (remove-if-not
      ;; remove whitespace or comment elements
      (lambda (child)
        (typep child 'xml-tag))
      children))
</div>))

Lastly, add some CSS to hide the tab content that is not selected. Also lots of stuff to make it look pretty. Some dynamic variables to add customization can’t hurt either. The most important thing is the display: none and display: block.

.tab-box {
    --border-color: black;
    --background-color: white;
    --tab-color: lightgrey;
    background: var(--background-color);
    padding: .7rem;
    width: fit-content;
    margin: .5rem;
    border-radius: 5px;
    box-shadow: 0 0 3px black;
}
.tab-box > nav {
    color: inherit;
    padding: 0 .5rem 0 .5rem;
    border-bottom: 1px solid var(--border-color);
}
.tab-box > b {font-size: 1.3rem;}
.tab-box > nav > button {
    color:inherit;
    position: relative;
    bottom: -1px;
    margin-bottom: 0;
    border: 1px solid var(--border-color);
    border-radius: 6px 6px 0 0;
    background-color: var(--tab-color);
    cursor: pointer;
}
.tab-box > nav > button:focus {outline: none;}
.tab-box > nav > button[selected] {
    background-color: var(--background-color);
    border-bottom: 1px solid var(--background-color);
    cursor: default;
}
.tab-box > .tab-content {display: none;}
.tab-box > .tab-content[selected] {
    display: block;
    animation: fade 1s;
    animation-delay: .0001s;
    animation-fill-mode: both;
}
@keyframes fade {
    0% {opacity: 0}
    100% {opacity: 1}
}

Input Control

The way to do control what users can input into text boxes is to use the oninput event. The only issue with this is that if you are disabling some characters to be input, the final result will be the same as the original (empty) input. The solution is to use the update attribute which, if present, will force all attributes to be updated by the server.

Phone Number

https://github.com/interactive-ssr/issr-server/blob/master/phone-number.png

We don’t want the user to be able to enter anything but numbers, and we will put the hyphens in for them.

<input-phonenumber name="phone" value=phone />

First we will define a tag and create a local variable which is the user entered value with all the non-numbers removed and passed through our add-hyphens function.

(deftag input-phonenumber (&key name value)
  (let ((filtered (add-hyphens
                   (ppcre:regex-replace-all "[^0-9]" value ""))))

Next put the input tag with filtered value and the update if the value has changed. Just pass through the name attribute

<input name=name value=filtered
       update=(string/= value filtered)
       oninput="rr()" />))

Lastly we have to define our add-hyphens function. It also makes sure that the length is no longer than 12 (numbers plus hyphens).

(defun add-hyphens (number)
  (let ((length (length number)))
    (cond
      ;; missing first hyphen
      ((and (<= 4 length)
            (char/= #\- (elt number 3)))
       (add-hyphens (str:concat (subseq number 0 3) "-"
                                (subseq number 3))))
      ;; missing second hyphen
      ((and (<= 8 length)
            (char/= #\- (elt number 7)))
       (add-hyphens (str:concat (subseq number 0 7) "-"
                                (subseq number 7))))
      (:else
       (str:substring 0 12 number)))))