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