
Eating my way through Web APIs
Follow my journey developing an offline-first recipe book Progressive Web App







Requirements
Offline first


True Serverless
But first...


const recipe = writable({
id: 'JdjtOympO6yuhGytoNBHv',
image: '/images/JdjtOympO6yuhGytoNBHv',
title: 'Pancakes',
description: 'With bananas and honey',
prepTime: 25,
portions: 2,
ingredients: ['3 eggs', '60gr flour'...],
instructions: ['Mix wet ingredients'...],
})
Persisting across refreshes


Read
Create

Update

Delete
Web Storage API
const persist = debounce(() => {
localStorage.setItem(
'recipes',
JSON.stringify(get(recipes).map((item) => get(item))),
)
}, 500)
export const recipes = writable([])
let suscriptions = []
// Listens for changes in the top level recipes array
recipes.subscribe(() => {
// Unsubscribe from every previous individual recipe subscription
suscriptions.forEach((unsubscribe) => {
unsubscribe()
})
// Create new subscriptions for every recipe object in the recipes list
suscriptions = get(recipes).map((store) => store.subscribe(persist))
persist()
})let items = []
export const restore = () => {
try {
items = JSON.parse(localStorage.getItem('recipes'))
if (!Array.isArray(items)) {
items = []
}
} catch {
items = []
}
recipes.set(items.map((item) => writable(item)))
}
Time to upload a photo!
<input
class="add-photo-input"
type="file"
accept="image/*"
on:change={(event) => {
let file = event.target.files[0]
$recipe.image = await saveImage(file, $recipe.id)
}}
/>
IndexedDB API
const imageDB = new Promise((resolve) => {
let db
let dbReady = false
let dbVersion = 2
const request = indexedDB.open('files', dbVersion)
request.onsuccess = (event) => {
db = event.target.result
}
request.onupgradeneeded = (event) => {
let db = event.target.result
db.createObjectStore('images', { keyPath: 'id', autoIncrement: true })
dbReady = true
resolve(db)
}
})export const saveImage = async (file) => {
const reader = new FileReader()
reader.readAsBinaryString(file)
return new Promise((resolve, reject) => {
reader.onload = (event) => {
const bits = event.target.result
const ob = {
created: new Date(),
data: bits,
}
const trans = (await imageDB).transaction(['images'], 'readwrite')
const addReq = trans.objectStore('images').add(ob)
addReq.onerror = (event) => {
reject(new Error('Error storing data'))
}
addReq.onsuccess = (event) => {
resolve(event.target.result)
}
}
})
}
Caches API
export const saveImage = async (file, id = nanoid()) => {
let newCache = await caches.open('images-cache')
const request = `/images/${id}`
await newCache.put(request, new Response(file))
return request
}export const getImage = async (request) => {
let newCache = await caches.open('images-cache')
const response = await newCache.match(request)
if (!response) return null
return URL.createObjectURL(await response.blob())
}
No internet connection?



FetchEvent API

Move recipes across devices




Replacing JSZIP


File vs Blob
new File(bits, name, options)new Blob(array, options)
Name prop

Streams or other blobs

Type, last modified...
Time to export!

Packing
export const pack = (files) => {
const dir = []
let start = 0
for (const item of files) {
dir.push([item.name, start, item.size, item.type])
start += item.size
}
const dirJSON = JSON.stringify(dir)
const dirSize = new Uint32Array([dirJSON.length])
return new File([...files, dirJSON, dirSize], 'myfiles.archive')
}
Compression Streams API
export const compress = async (file) => {
const readableStream = file.stream()
const compressedStream = readableStream.pipeThrough(
new CompressionStream('gzip'),
)
return streamToBlob(compressedStream)
}const streamToBlob = async (stream) => {
const res = new Response(stream)
return await res.blob()
}

Downloading

đ
Slice it up!
export const download = (blob, name) => {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = name
link.click()
URL.revokeObjectURL(url)
}

const handleDownload = async () => {
download(
new File([exportBlob], 'monchi.export.gz', { type: 'application/gzip' }),
'monchi.export.gz',
)
}Time to import!

Decompressing
export const decompress = async (file) => {
const readableStream = file.stream()
const decompressedStream = readableStream.pipeThrough(
new DecompressionStream('gzip'),
)
return streamToBlob(decompressedStream)
}
Recovering CentralDir
export const readCentralDir = async (blob) => {
const last4 = await blob.slice(-4).arrayBuffer()
const size = new DataView(last4).getUint32(0,true)
const jsonPart = blob.slice(blob.size - size - 4, blob.size - 4)
return new Response(jsonPart).json()
}
Unpacking
export const unpack = (file, centralDir) =>
centralDir.map((item) => {
const [name, start, size, type] = item
return new File([file.slice(start, start + size)], name, {
type: type,
})
})
Saving back into the DB
const importRecipes = async (files) => {
const file = files[0]
const blob = await decompress(file)
const centralDir = await readCentralDir(blob)
const fileList = unpack(blob, centralDir)
for (const file of fileList) {
if (file.name === 'recipes.json') {
const text = await file.text()
localStorage.setItem('recipes', text)
} else {
await saveImage(file, file.name)
}
}
restore()
}
Caveats and next steps


-
Image Compression -
Synch between devices
There hasnât been a better time to build web apps








BCN JS
By StĂvali Serna
BCN JS
- 548


