Skip to content

on

This feature is responsible for initiating requests to the server.

It supports the following request types:

  • XHR requests, including form submissions
  • full page transitions
  • History API navigation

Its only responsibility is configuring and sending the request. What happens after the request is outside the scope of this feature.


All XHR requests include an X-Requested-With header set to XMLHttpRequest, allowing the server to distinguish XHR requests from full page navigation when needed.

KEML sets a permanent cookie named tzo, containing the browser timezone after the first load of any page that includes the KEML runtime, all subsequent requests will include this cookie.

KEML relies heavily on and benefits from the existing web stack, without overriding its semantics or behavior, including the standard browser page caching mechanisms.

Thus, if your server is smart about sending the correct caching response headers at the correct time - that can help speed up KEML even more.

That's right, your application's performance optimizations also live on the server 🤯.


The on attribute is the only required attribute for an element to become capable of initiating requests to the server.

Even though most examples here show on being used on the same element as on:*, that is only for brevity and simplicity's sake. It can appear on any number of elements at once.

on provides a set of optional attributes for customizing its behavior. These are covered in the sections below.


Endpoint+Method Configuration

The default endpoint is an empty string ("").

The default HTTP method is GET.

Given the current URL:

1
https://www.example.com/some/path
Configuration Result
(empty) https://www.example.com/some/path/
list-todo or ./list-todo https://www.example.com/some/path/list-todo/
../list-todo https://www.example.com/some/list-todo/
/list-todo https://www.example.com/list-todo/
/file.html https://www.example.com/file.html

Automatic normalization:

  • paths ending in a file extension → exactly zero trailing slashes
  • paths without a file extension → exactly one trailing slash

get, href, action and src

These attributes override the default endpoint.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<button
  type="button"
  class="btn"
  on:click="submitOverrideGetEndpoint"
  on="submitOverrideGetEndpoint"
  action="/endpoint-override"
  result="overrideGetEndpointResult"
>
  click me
</button>

<div render="overrideGetEndpointResult"></div>
1
2
3
4
5
6
7
8
<small class="chip mv3">Request received</small>

<dl class="dl">
  <dt>Endpoint:</dt>
  <dd>{ server.url.pathname }</dd>
  <dt>Method:</dt>
  <dd>{ server.method }</dd>
</dl>

post, put and delete

These attributes override both the default endpoint and the default HTTP method.

The method they set is the same as the attribute name.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<button
  type="button"
  class="btn"
  on:click="submitOverridePostEndpoint"
  on="submitOverridePostEndpoint"
  post="/endpoint-override"
  result="overridePostEndpointResult"
>
  click me
</button>

<div render="overridePostEndpointResult"></div>
1
2
3
4
5
6
7
8
<small class="chip mv3">Request received</small>

<dl class="dl">
  <dt>Endpoint:</dt>
  <dd>{ server.url.pathname }</dd>
  <dt>Method:</dt>
  <dd>{ server.method }</dd>
</dl>

about:blank

You can "send a request" to the about:blank endpoint when no work is needed and an empty response should be produced.

This does not trigger any network request.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<button
  class="btn"
  on:click="sendToVoid"
  on="sendToVoid"
  src="about:blank"
  result="voidResult"
>
  Clear element content
</button>

<div render="voidResult">
  <p>Lorem ipsum dolor sit amet consectetur adipiscing elit.</p>

  <p>Sit amet consectetur adipiscing elit quisque faucibus ex.</p>

  <p class="mb0">
    Adipiscing elit quisque faucibus ex sapien vitae pellentesque.
  </p>
</div>

Lorem ipsum dolor sit amet consectetur adipiscing elit.

Sit amet consectetur adipiscing elit quisque faucibus ex.

Adipiscing elit quisque faucibus ex sapien vitae pellentesque.


method

This attribute overrides just the default HTTP method. The provided value will be converted into all uppercase.

There are no restrictions on the value — it can be anything as long as your server understands the verb.

It has higher precedence than the attributes shown above.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<button
  type="button"
  class="btn"
  on:click="submitOverrideMethod"
  on="submitOverrideMethod"
  put="/endpoint-override"
  method="custom"
  result="overrideMethodResult"
>
  click me
</button>

<div render="overrideMethodResult"></div>
1
2
3
4
5
6
7
8
<small class="chip mv3">Request received</small>

<dl class="dl">
  <dt>Endpoint:</dt>
  <dd>{ server.url.pathname }</dd>
  <dt>Method:</dt>
  <dd>{ server.method }</dd>
</dl>

debounce and throttle

These attributes can be used to rate limit the on feature.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<h4 class="mt0">
  The request is only sent when you stop typing for two seconds.
</h4>

<input
  name="message"
  autocomplete="off"
  class="input"
  on:input="submitExpensive"
  on="submitExpensive"
  debounce="2000"
  src="/expensive"
  result="expensiveResult"
>

<div render="expensiveResult"></div>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<small class="chip mt3">Complete</small>

<p>Imagine that this value was hard to compute!</p>

<dl class="dl">
  <dt>You said:</dt>
  <dd>{ server.getParam("message") }</dd>
  <dt>Answer:</dt>
  <dd>42</dd>
</dl>

The request is only sent when you stop typing for two seconds.


Request Data

Forms submit their fields the same way they always did in HTML, including native field validation rules.

Custom elements (e.g. Web Components) can also be made validatable; all they must do is implement the standard checkValidity method.

What's more, any element inside the one sending the request, including the element itself, can contribute values to the request data by specifying a name and a value attribute. For non-form fields, both attributes must be provided.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<h4 class="mt0">Enter your name and submit the form</h4>

<form
  action="/form-submit"
  on:submit="submitForm"
  on="submitForm"
  result="formSubmitResult"
>
  <label>
    Name:
    <!-- no KEML-specific attributes on the input -->
    <input
      type="text"
      name="name"
      autocomplete="off"
      required
      class="input"
    >
  </label>

  <button class="btn mt2">submit</button>
</form>

<div render="formSubmitResult"></div>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<small class="chip mt3">Received</small>

<p>
  Form submissions may not be exciting, but where would the web be without them?
</p>

<dl class="dl">
  <dt>Hello:</dt>
  <dd>{ server.getParam("name") }</dd>
</dl>

Enter your name and submit the form


Form fields can even submit themselves without a form at all.

This example works exactly the same.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<h4 class="mt0">Enter your name and submit the field</h4>

Name:
<!-- Look Ma, no form! -->
<input
  type="text"
  name="name"
  autocomplete="off"
  required
  class="input"
  action="/form-submit"
  on:keydown="submitField"
  event:keydown="key = Enter"
  on="submitField"
  result="fieldSubmitResult"
>

<button
  type="button"
  class="btn mt2"
  on:click="submitField"
>
  submit
</button>

<div render="fieldSubmitResult"></div>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<small class="chip mt3">Received</small>

<p>
  Form submissions may not be exciting, but where would the web be without them?
</p>

<dl class="dl">
  <dt>Hello:</dt>
  <dd>{ server.getParam("name") }</dd>
</dl>

Enter your name and submit the field

Name:

It does not even have to be a form field.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<h4 class="mt0">A div huh 🤔</h4>

Name:
<div
  name="name"
  value="Batman"
  class="input"
  action="/form-submit"
  on="submitDiv"
  result="divSubmitResult"
>
  I'm Batman!
</div>

<button
  type="button"
  class="btn mt2"
  on:click="submitDiv"
>
  submit
</button>

<div render="divSubmitResult"></div>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<small class="chip mt3">Received</small>

<p>
  Form submissions may not be exciting, but where would the web be without them?
</p>

<dl class="dl">
  <dt>Hello:</dt>
  <dd>{ server.getParam("name") }</dd>
</dl>

A div huh 🤔

Name:
I'm Batman!

Request Headers

Request headers can be set using:

h-<header name>="header value"

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<button
  type="button"
  class="btn"
  on:click="sendHeaders"
  on="sendHeaders"
  href="/request-headers"
  h-batman="Bruce Wayne"
  result="headersResult"
>
  Send Message
</button>

<div render="headersResult"></div>
1
2
3
4
5
6
7
8
<small class="chip mt3">Received</small>

<p>Just don't tell anyone 😀</p>

<dl class="dl">
  <dt>Secret identity:</dt>
  <dd>{ server.headers.get("batman") }</dd>
</dl>

credentials

RTFM (just kidding)

This is simply the standard mechanism for including credentials (e.g. cookies) when making requests to another domain, where they would normally not be included for security reasons.

Make sure you know what you're doing if you're using this.

This is a boolean attribute, so its value is ignored and only the presence determines whether or not the credentials are enabled.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<button
  type="button"
  class="btn"
  on:click="sendCredentials"
  on="sendCredentials"
  href="https://www.example.com/nefarious"
  credentials
  result="credentialsResult"
>
  Log In
</button>

<div render="credentialsResult"></div>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<small class="chip mt3">Ha!</small>

<p>What?!</p>

<p>You were expecting to see something, weren't you?</p>

<p>But the deed is done.</p>

<dl class="dl">
  <dt>Credentials included:</dt>
  <dd>{ server.withCredentials ? "Yes" : "No" }</dd>
</dl>

stream

This attribute allows the server to stream multiple responses after a single request. Responses do not need to arrive at the same time, and their number does not have to be finite.

Think of this as a middle ground between a normal XHR request and an SSE connection.

It behaves like an XHR request in most respects, but can remain persistent like SSE. It is also simpler on the backend, since you can send as much data as you like, as often as you like, and it will continue streaming until you finalize the response.

Use SSE when you want to send results to all subscribed clients, and use streaming when you want to send the same kind of results in response to a single request from a specific client.

This is made possible by a special delimiting HTML comment convention: <!-- KEML -->. Each distinct portion of HTML you send must be separated by this delimiter. You must ensure that the text between two delimiting comments is a valid, complete, and parsable HTML fragment. The comment is case- and whitespace-insensitive.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<button
  type="button"
  class="btn"
  on:click="requestStream"
>
  Get Streamed Results
</button>

<dl
  class="dl"
  on="requestStream"
  once
  href="/stream"
  stream
  result="streamResult"
  render="streamResult"
  position="append"
></dl>

Since the last message is not delimited by a <!-- KEML --> comment, it will not be received until server.end() is called.

Because there is a server.end() call, this stream is finite.

After receiving a single request, this endpoint will return four separate responses: the first immediately, and the next three spaced two seconds apart.

In this example server, the timing is a bit contrived for simplicity. In the real world, these streamed fragments may be delayed by database latency, a microservice response, a chatbot generating messages, or any other timing-dependent behavior.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{{

  const msg4 = () => setTimeout(() => (
    server.write("<dt>And so are</dt><dd>you</dd>"),
    server.end()
  ), 2000);

  const msg3 = () => setTimeout(() => (
    server.write("<dt>KEML is</dt><dd>awesome</dd><!-- KEML -->"),
    msg4()
  ), 2000);

  setTimeout(() => (
    server.write("<dt>Violets are</dt><dd>blue</dd><!-- KEML -->"),
    msg3()
  ), 2000);

}}

<dt>Roses are</dt><dd>red</dd>
<!-- KEML -->

redirect

This attribute switches the operational mode of on from sending requests to performing redirects.

The endpoint resolution logic remains the same. Headers and HTTP method are ignored. Form data (excluding file uploads) is applied to the query string.


assign

This option performs a full page navigation.

1
2
3
4
5
6
7
8
<a
  href="/page-b"
  on:click="redirectAssignA"
  on="redirectAssignA"
  redirect="assign"
>
  Go to page B
</a>
1
2
3
4
5
6
7
8
<a
  href="/page-c"
  on:click="redirectAssignB"
  on="redirectAssignB"
  redirect="assign"
>
  Go to page C
</a>
1
2
3
<p>Congrats you have reached the last page 🎉</p>

<p>Now use the browser buttons to go back and forth.</p>
https://www.assign-example.com/page-a/

replace

This option performs a full page navigation, but replaces the current history entry instead of adding a new one.

1
2
3
4
5
6
7
8
<a
  href="/page-b"
  on:click="redirectReplaceA"
  on="redirectReplaceA"
  redirect="assign"
>
  Go to page B
</a>
1
2
3
4
5
6
7
8
<a
  href="/page-c"
  on:click="redirectReplaceB"
  on="redirectReplaceB"
  redirect="replace"
>
  Replace this page with page C
</a>
1
2
3
<p>Congrats you have reached the last page 🎉</p>

<p>Now use the browser buttons to go back and forth.</p>
https://www.replace-example.com/page-a/

pushState and replaceState

These options work exactly the same, but use the History API and do not cause a full page navigation.

Oh, and did I forget to mention that you get SSR for free? It is not some separate thing you have to implement, and it does not impose restrictions on your server infrastructure 🤯.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<a
  href="/page-a"
  class="mr3"
  on:click="redirectPushStateA"
  on="redirectPushStateA"
  redirect="pushState"
>
  Push A
</a>

<a
  href="/page-b"
  class="mr3"
  on:click="redirectPushStateB"
  on="redirectPushStateB"
  redirect="pushState"
>
  Push B
</a>

<a
  href="/page-c"
  class="mr3"
  on:click="redirectPushStateC"
  on="redirectPushStateC"
  redirect="pushState"
>
  Push C
</a>
|
<a
  href="/page-a"
  class="mh3"
  on:click="redirectReplaceStateA"
  on="redirectReplaceStateA"
  redirect="replaceState"
>
  Replace A
</a>

<a
  href="/page-b"
  class="mr3"
  on:click="redirectReplaceStateB"
  on="redirectReplaceStateB"
  redirect="replaceState"
>
  Replace B
</a>

<a
  href="/page-c"
  on:click="redirectReplaceStateC"
  on="redirectReplaceStateC"
  redirect="replaceState"
>
  Replace C
</a>

<div
  on:navigate="showHistoryPage"
  on="showHistoryPage"
  result="historyResult"
  render="historyResult"
>
  { server.partial } <!-- SSR partial render -->
</div>
1
<p>The A page content.</p>
1
<p>The B page content.</p>
1
<p>The C page content.</p>
https://www.history-example.com/page-a/

once

This is a self-destruct instruction for the on attribute. It is removed after the first invocation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<h4 class="mt0">You can only add a single entry to the log.</h4>

<button
  type="button"
  class="btn"
  on:click="sendOnce"
  on="sendOnce"
  href="/once"
  once
  result="onceResult"
>
  Append Log
</button>

<ul
  class="mv0"
  render="onceResult"
  position="append"
></ul>
1
<li class="mt3">Log entry from: { new Date().toLocaleString() }</li>

You can only add a single entry to the log.


    Polling

    Info

    Example only provided for completeness. You are discouraged from doing this. Use SSE if possible.

    KEML does not implement polling as a dedicated feature, but it can still be assembled from the basic building blocks shown above.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    {{ const colors = ["red", "orange", "gold", "yellow", "purple", "pink", "green",
      "navy", "blue"]; }}
    
    <div
      class="w3 h3 bg-{ colors[Math.random() * (colors.length - 1) | 0] }"
      on:discover="startPolling"
      on="startPolling"
      debounce="5000"
      get="/polling"
      on:result="startPolling"
      result="pollingResult"
      render="pollingResult"
      position="replaceWith"
    ></div>
    

    Changes color every 5 seconds.


    Virtualization

    KEML does not implement virtualization as a dedicated feature, but it can still be assembled from the basic building blocks shown above.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    <p class="mt0">
      The point is not the grid, but the fact that it is a completely normal
      <code>div</code> element, with no grid libraries or any kind of special
      rendering needed.
    </p>
    
    <p>
      This approach does come with certain limitations, however. Browsers have a
      hard limit on total scrollable layout height, beyond which layout calculations
      and scroll positioning may start to break down
      (e.g. <code>17895697px</code> in Firefox).
    </p>
    
    <p>
      That means this example supports only around 350,000 rows as a practical
      maximum at typical row heights.
    </p>
    
    <p>
      This example shows 300,000 rows (only 😅), so we are comfortably within the
      safe range.
    </p>
    
    <div
      class="h5 overflow-y-auto"
      on:discover="loadGrid_0"
      on="loadGrid_0"
      src="/virtualization?path=0"
      result="virtualizationResult_0"
      render="virtualizationResult_0"
    ></div>
    
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    const tempLabels = [
      "Deep Freezing",
      "Freezing",
      "Cold",
      "Cool",
      "Mild Moderate",
      "Moderate",
      "Warm",
      "Hot",
      "Very Hot",
      "Scorching",
    ];
    // Create a dataset with 300,000 rows
    server.table = Array.from({ length: 300_000 }, (_, i) => ({
      num: i + 1,
      temperature: (i = Math.random() * 100).toFixed(2),
      label: tempLabels[Math.max(0, ((i - 1) / 10) | 0)],
    }));
    
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    {{ const path = server.getParam("path"); }}
    {{ const { children, start, end, level, size } = server.getNode(path); }}
    
    {{ if (level) { }}
      <div
        { 'style="height: ' + size * 50 + 'px;"' }
        on:reveal="cancelClearGrid_{ path }"
        on:conceal="clearGrid_{ path } cancelLoadGrid_{ path }"
        on="clearGrid_{ path }"
        debounce="1000"
        clear-timeout="cancelClearGrid_{ path }"
        src="/vir-cleanup?path={ path }"
        result="virtualizationResult_{ path }"
        render="virtualizationResult_{ path }"
        position="replaceWith"
      >
    {{ } }}
    
    {{ if (children) { }}
      {{ let i = 0; }}
      {{ for (const { size } of children) { }}
        {{ const p = path + "-" + i++; }}
        <div
          { 'style="height: ' + size * 50 + 'px;"' }
          on:reveal="loadGrid_{ p } cancelClearGrid_{ p }"
          on:conceal="cancelLoadGrid_{ p }"
          on="loadGrid_{ p }"
          debounce="200"
          clear-timeout="cancelLoadGrid_{ p }"
          src="/virtualization?path={ p }"
          result="virtualizationResult_{ p }"
          render="virtualizationResult_{ p }"
          position="replaceWith"
        ></div>
      {{ } }}
    {{ } else { }}
      {{ for (let i = start, l = end; i < l; ++i) { }}
        <div class="grid-row">
          <div>{ server.table[i].num }</div>
          <div>{ server.table[i].label }</div>
          <div>{ server.table[i].temperature }</div>
        </div>
      {{ } }}
    {{ } }}
    
    {{ if (level) { }}
      </div>
    {{ } }}
    

    This route cleans up the invisible slices.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    {{ const path = server.getParam("path"); }}
    
    <div
      { 'style="height: ' + server.getNode(path).size * 50 + 'px;"' }
      on:reveal="loadGrid_{ path } cancelClearGrid_{ path }"
      on:conceal="cancelLoadGrid_{ path }"
      on="loadGrid_{ path }"
      debounce="200"
      clear-timeout="cancelLoadGrid_{ path }"
      src="/virtualization?path={ path }"
      result="virtualizationResult_{ path }"
      render="virtualizationResult_{ path }"
      position="replaceWith"
    ></div>
    

    The point is not the grid, but the fact that it is a completely normal div element, with no grid libraries or any kind of special rendering needed.

    This approach does come with certain limitations, however. Browsers have a hard limit on total scrollable layout height, beyond which layout calculations and scroll positioning may start to break down (e.g. 17895697px in Firefox).

    That means this example supports only around 350,000 rows as a practical maximum at typical row heights.

    This example shows 300,000 rows (only 😅), so we are comfortably within the safe range.