There are a lot of Jamstack frameworks, and most SPA frameworks now have a Jamstack counterpart, which statically generates some of the pages for them to be available as HTML at CDN nodes. There is debate as to whether you want a website that has no dynamic parts, but after initial load, proceeds to load SPA library worth of data to be responsive to user input. It’s usually the case that these same Jamstack frameworks also support a worker environment based on fetch API, to run at CDN data centers as cloud functions or in serverless edge. This begs the question, why not just have a server-side app serve that same HTML?
With products like workers KV and durable objects from Cloudflare that both run on the edge, you’d get the same performance as a Jamstack site, which renders a performance advantage by prefetching data to be preprocessed for storage at CDN. The key-value store, and durable objects are unique to Cloudflare, closing the performance gap with Jamstack sites. I’ll admit that this won’t go mainstream anytime soon, since Jamstack sites get the luxury of DB choice, meanwhile, KV and DO are just storage. Another caveat is a vastly superior ecosystem of packages meant for Jamstack as serverless edge services all use fetch based API environment leading to incompatibilities, not to speak of services and packages aimed at SPAs with which in mind Jamstack was created as they’re meant to handle the dynamic parts of the app. Authentication, for example, is as simple as installing auth0 or firebase on the client-side bundle of Jamstack but isn’t available for CF Worker.
With that short rundown of the state of javascript, and downsides to the approach, out of the way. Here’s what makes it worthwhile, scalability and performance in dynamic parts of the site and bundle size. There are trade-offs with KV storage and durable objects that I skimmed over, but the reason why I mention them both as a solution to storage is that they represent 2 extremes. Durable objects aren’t scalable, but unlike their counterpart handle transactions and writes much better. I will only use KV here, using the Worktop KV module. Even though data writes become consistently identical only within 60 seconds of a write across all CDN nodes, the worker that initiated the write will see the change reflected immediately. This is good enough for a counter app for personal use (single user), but you’d otherwise want to use a durable object.
Templating will be done with html library. It is similar to react in that JavaScript intermingles with HTML and is inspired by the likes of hyperhtml. In addition to templating, it will stream HTML that is reliant on async results on their completion after sending preceding static HTML to the client, but I will just async/await to avoid the round trip of 10ms that KV ops take within the worker. I’ll use the Itty router to handle increment form submissions. To shore up performance and UX, we will use Hotwired turbo. It eliminates browser reloads on navigation and form submission, which prevents js and CSS from having to be reapplied.
Now here’s an example todo application: https://github.com/janat08/miniflare-typescript-esbuild-jest/tree/todo-example
index.ts
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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import {
Router
} from 'itty-router'
import home from './response'
import todos from './todos'
const API = Router()
API.get('/', async (event, env, ctx) => {
const {
list
} = todos(env)
return home(await list())
})
API.get('/incomplete', async (event, env, ctx) => {
const {
list
} = todos(env)
return home(await list('false'))
})
//real api stuff, proper http method isn't used
API.post('/add', async (req, env) => {
const {
insert
} = todos(env)
const formData = await req.formData();
const body = {};
for (const entry of formData.entries()) {
body[entry[0]] = entry[1];
}
await insert({
createdAt: Date.now(),
...body
})
return new Response('', {
status: 302,
headers: {
'Location': 'http://localhost:8787',
},
})
})
API.get('/complete/:createdAt', async (req, env) => {
console.log('params', req.params.createdAt)
const {
find,
save,
destroy
} = todos(env)
const createdAt = req.params.createdAt
const {
val
} = await find(createdAt)
//since we're changing a key value too, we can't overwrite a record
await save({
createdAt,
completed: true,
val
})
await destroy({
createdAt,
completed: false
})
return new Response('', {
status: 302,
headers: {
'Location': 'http://localhost:8787',
},
})
})
API.get('/delete/:createdAt/:completed', async (req, env) => {
const {
destroy
} = todos(env)
const {
createdAt,
completed
} = req.params
await destroy({
createdAt,
completed
})
return new Response('', {
status: 302,
headers: {
'Location': 'http://localhost:8787',
},
})
})
export default {
async fetch(request, environment, ctx) {
return await API.handle(request, environment, ctx)
}
}
todos.ts
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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import * as DB from 'worktop/kv';
//kv binding from wrangler.toml isn't global, so it must be passed down
//it creates a kv store for this worker
export default function globals(env) {
//env.kv isn't a defualt value, I namespaced it so
const kv = env.kv
//method of filtering and sorting occurs by key text in KV store, so we can only sort by completed status as it comes first
//we may also filter by createdAt field, but we have to run two operations guessing completed status if we must
const toPrefix = (completed: boolean) => `todo::{completed}::`;
const toKeyname = (completed: boolean, createdAt: number) => toPrefix(completed) + createdAt;
async function list(completed ? : boolean, options: {
limit ? : number;page ? : number
} = {
limit: 1000
}): Promise < string[] > {
const prefix = toPrefix(completed);
const keys = await DB.paginate < string[] > (kv, {
...options,
prefix: typeof completed != 'undefined' ? prefix : ''
});
//some of the kv data is embedded as key text, so it's appended to actual value- todo text.
return Promise.all(keys.map(x => x.substring(6))
.map(async x => {
const keys = x.split('::')
return {
completed: keys[0] == 'true',
...(await find(keys[1])),
createdAt: keys[1] * 1
}
}))
}
function save({
createdAt,
completed,
val
}) {
const key = toKeyname(completed, createdAt);
return DB.write(kv, key, {
val
});
}
async function find(createdAt) {
//KV store can't filter by affix, and with completed status coming first, we just have perform two operations when searching by createdAt
const key = toKeyname(true, createdAt);
const key2 = toKeyname(false, createdAt)
const res1 = await DB.read(kv, key, 'json')
return res1 ? res1 : DB.read(kv, key2, 'json')
}
async function insert(item) {
if (await save({
completed: false,
...item
})) {
return true
} else {
throw new Error('no insert completed' + JSON.stringify(item))
}
}
function destroy({
createdAt,
completed
}) {
const key = toKeyname(completed, createdAt);
return DB.remove(kv, key);
}
return {
destroy,
save,
insert,
find,
list
}
}
As you can see in the modeling part, using almost any other store or DB would be easier. There are also limitations in that we can’t sort complete and incomplete items together. You could fix that by putting the createdAt value first, and complete status after it into the key, but you wouldn’t be able to filter out complete items from incomplete ones. A real-world fix to this might be to have separate keys for each todo with reverse order of key values (completed::createdAt, and createdAt::completed). To sort and filter, you’d have to do the sort first and then try and filter insufficient number keys for the view.
You’d want to use KV and Workers in applications that are infrequently used in favor of the smallest bundle size and when the dynamic search is the core of your product but that can be modeled with KV.
Duplicating search data into the KV store in addition to a more convenient storage option on a Jamstack site is also an option. Hotwired has other sister components to turbo aimed at competing with SPAs if you don’t care for better packages for Jamstack.