Living on the Edge

Web apps with SvelteKit and Cloudflare Pages

Nils Röhrig | REWE digital

Agenda

What is Edge Computing?

What use does Edge Computing have in web development?

What are SvelteKit & Cloudflare Pages?

How could an app be built with these tools?

What is Edge Computing?

Edge computing is computing that takes place at or near the physical location of either the user or the source of the data.

Parts of an edge network

Core

End user

Core

Service provider edge

End user

Core

End-user premises edge

Service provider edge

End user

Core

End-user premises edge

Device edge

Service provider edge

End user

Core

End-user premises edge

Device edge

Service provider edge

End user

Core

End-user premises edge

Device edge

Service provider edge

End user

Core

End-user premises edge

Device edge

Service provider edge

End user

Core

End-user premises edge

Device edge

Service provider edge

End user

What use does Edge Computing have in web development?

Core

End-user premises edge

Device edge

Service provider edge

End user

Core

End-user premises edge

Device edge

Service provider edge

End user

Core

CDN

Core

Web Frontend

Web Frontend

Web Frontend

Service Provider Edge

Edge Node

Parts of
Application

Edge Node

Parts of
Application

Edge Node

Parts of
Application

Core

What are SvelteKit & Cloudflare Pages?

SvelteKit is a framework for building extremely high-performance web apps.

Meta framework for

  • Server-side rendering
  • Static site generation
  • File-based routing
  • Client-side navigation
  • Platform independence
  • etc.

Meta framework for

  • Server-side rendering
  • Static site generation
  • File-based routing
  • Client-side navigation
  • Platform independence
  • etc.

Cloudflare Pages is a JAMstack platform for frontend developers to collaborate and deploy websites.

 CDN

deploys to

-Providers

 CDN

integrates with

deploys to

-Providers

 CDN

integrates with

deploys to

uses

-Providers

 CDN

integrates with

deploys to

uses

runs code at

Caution...

Caution...

How could an app be built with these tools?

Set it up in 4 simple steps...

Set it up in 4 simple steps...

1. Create a GitHub repository

1. Create a GitHub repository

2. Create a Cloudflare Pages project from repository

Set it up in 4 simple steps...

1. Create a GitHub repository

3. Create a SvelteKit app

2. Create a Cloudflare Pages project from repository

Set it up in 4 simple steps...

3. Create a SvelteKit app

4. Push app to repository

1. Create a GitHub repository

2. Create a Cloudflare Pages project from repository

Set it up in 4 simple steps...

Introducing svekom

Insights from developing svekom

Routing in SvelteKit

Routing in SvelteKit

  • File-based routing
  • Default path: src/routes
  • Route names and parameters: src/routes/name/[param]
  • Partition into pages, endpoints, layouts & error pages

Example: products

src/routes/products/[id]

Example: products

<script lang="ts">
  export let data: PageData;
  let product: Product, category: Category | string | undefined;

  $: ({ product } = data);
  $: ({ category } = product);
</script>

<article>
  <Card>
    <Section first>
      <img class="image" alt="" src="/products/{product.filename}"/>
    </Section>
  </Card>

  <div class="info">
    {#if category && typeof category !== 'string'}
      <Badge>{category.name}</Badge>
    {/if}
    <h2 class="title">{product.brand} {product.name}</h2>
    <p class="short-description">{product.shortDescription}</p>
    <div class="price">{formatPrice(product.price)}</div>
    <AddToCart {product}/>
    <div class="description">
      <p class="origin">Country of origin: {product.origin}</p>
      {@html product.description}
    </div>
  </div>
</article>
const converter = new showdown.Converter();

export const load: PageServerLoad = async ({ params, platform }) => {
  const productService = createProductService(platform);
  const categoryService = createCategoryService(platform);

  const product = await productService
    .getSingleProduct(params.id)
    .then((option) => option.getOrThrow(error(404)));

  const { description, category: categoryId, ...rest } = product;
  const category = await categoryService
    .getSingleCategory(categoryId)
    .then((category) => category.getOrUndefined());

  return {
    product: {
      ...rest,
      category,
      description: converter.makeHtml(product.description),
    },
  };
};

+page.svelte

+page.server.ts

src/routes/products/[id]

<script lang="ts">
  export let data: PageData;
  let product: Product, category: Category | string | undefined;

  $: ({ product } = data);
  $: ({ category } = product);
</script>

<article>
  <Card>
    <Section first>
      <img class="image" alt="" src="/products/{product.filename}"/>
    </Section>
  </Card>

  <div class="info">
    {#if category && typeof category !== 'string'}
      <Badge>{category.name}</Badge>
    {/if}
    <h2 class="title">{product.brand} {product.name}</h2>
    <p class="short-description">{product.shortDescription}</p>
    <div class="price">{formatPrice(product.price)}</div>
    <AddToCart {product}/>
    <div class="description">
      <p class="origin">Country of origin: {product.origin}</p>
      {@html product.description}
    </div>
  </div>
</article>
const converter = new showdown.Converter();

export const load: PageServerLoad = async ({ params, platform }) => {
  const productService = createProductService(platform);
  const categoryService = createCategoryService(platform);

  const product = await productService
    .getSingleProduct(params.id)
    .then((option) => option.getOrThrow(error(404)));

  const { description, category: categoryId, ...rest } = product;
  const category = await categoryService
    .getSingleCategory(categoryId)
    .then((category) => category.getOrUndefined());

  return {
    product: {
      ...rest,
      category,
      description: converter.makeHtml(product.description),
    },
  };
};

+page.svelte

+page.server.ts

Example: products

src/routes/products/[id]

<script lang="ts">
  export let data: PageData;
  let product: Product, category: Category | string | undefined;

  $: ({ product } = data);
  $: ({ category } = product);
</script>

<article>
  <Card>
    <Section first>
      <img class="image" alt="" src="/products/{product.filename}"/>
    </Section>
  </Card>

  <div class="info">
    {#if category && typeof category !== 'string'}
      <Badge>{category.name}</Badge>
    {/if}
    <h2 class="title">{product.brand} {product.name}</h2>
    <p class="short-description">{product.shortDescription}</p>
    <div class="price">{formatPrice(product.price)}</div>
    <AddToCart {product}/>
    <div class="description">
      <p class="origin">Country of origin: {product.origin}</p>
      {@html product.description}
    </div>
  </div>
</article>
const converter = new showdown.Converter();

export const load: PageServerLoad = async ({ params, platform }) => {
	const productService = createProductService(platform);
	const categoryService = createCategoryService(platform);

	const product = await productService
		.getSingleProduct(params.id)
		.then((option) => option.getOrThrow(error(404)));

	const { description, category: categoryId, ...rest } = product;
	const category = await categoryService
		.getSingleCategory(categoryId)
		.then((category) => category.getOrUndefined());

	return {
		product: {
			...rest,
			category,
			description: converter.makeHtml(product.description),
		},
	};
};

+page.svelte

+page.server.ts

Example: products

src/routes/products/[id]

const converter = new showdown.Converter();

export const load: PageServerLoad = async ({ params, platform }) => {
  const productService = createProductService(platform);
  const categoryService = createCategoryService(platform);

  const product = await productService
    .getSingleProduct(params.id)
    .then((option) => option.getOrThrow(error(404)));

  const { description, category: categoryId, ...rest } = product;
  const category = await categoryService
    .getSingleCategory(categoryId)
    .then((category) => category.getOrUndefined());

  return {
    product: {
      ...rest,
      category,
      description: converter.makeHtml(product.description),
    },
  };
};

+page.svelte

+page.server.ts

<script lang="ts">
  export let data: PageData;
  let product: Product, category: Category | string | undefined;

  $: ({ product } = data);
  $: ({ category } = product);
</script>

<article>
  <Card>
    <Section first>
      <img class="image" alt="" src="/products/{product.filename}"/>
    </Section>
  </Card>

  <div class="info">
    {#if category && typeof category !== 'string'}
      <Badge>{category.name}</Badge>
    {/if}
    <h2 class="title">{product.brand} {product.name}</h2>
    <p class="short-description">{product.shortDescription}</p>
    <div class="price">{formatPrice(product.price)}</div>
    <AddToCart {product}/>
    <div class="description">
      <p class="origin">Country of origin: {product.origin}</p>
      {@html product.description}
    </div>
  </div>
</article>

Example: products

src/routes/products/[id]

const converter = new showdown.Converter();

export const load: PageServerLoad = async ({ params, platform }) => {
  const productService = createProductService(platform);
  const categoryService = createCategoryService(platform);

  const product = await productService
    .getSingleProduct(params.id)
    .then((option) => option.getOrThrow(error(404)));

  const { description, category: categoryId, ...rest } = product;
  const category = await categoryService
    .getSingleCategory(categoryId)
    .then((category) => category.getOrUndefined());

  return {
    product: {
      ...rest,
      category,
      description: converter.makeHtml(product.description),
    },
  };
};

+page.svelte

+page.server.ts

<script lang="ts">
  export let data: PageData;
  let product: Product, category: Category | string | undefined;

  $: ({ product } = data);
  $: ({ category } = product);
</script>

<article>
  <Card>
    <Section first>
      <img class="image" alt="" src="/products/{product.filename}"/>
    </Section>
  </Card>

  <div class="info">
    {#if category && typeof category !== 'string'}
      <Badge>{category.name}</Badge>
    {/if}
    <h2 class="title">{product.brand} {product.name}</h2>
    <p class="short-description">{product.shortDescription}</p>
    <div class="price">{formatPrice(product.price)}</div>
    <AddToCart {product}/>
    <div class="description">
      <p class="origin">Country of origin: {product.origin}</p>
      {@html product.description}
    </div>
  </div>
</article>

Example: products

src/routes/products/[id]

Storage in Workers KV

Storage in Workers KV

Worker Code </>

Worker Code </>

uses

Runtime API

list()

get()

put()

delete()

Storage in Workers KV

Worker Code </>

Runtime API

list()

get()

put()

delete()

uses

updates

Edge Nodes

KV Cache

KV Cache

KV Cache

Storage in Workers KV

Worker Code </>

Runtime API

list()

get()

put()

delete()

uses

updates

updates

Edge Nodes

KV Cache

KV Cache

KV Cache

Storage in Workers KV

Worker Code </>

Edge Nodes

KV Cache

KV Cache

KV Cache

Runtime API

list()

get()

put()

delete()

uses

replicates

replicates

updates

updates

Storage in Workers KV

  • Pairs are eventually-consistent
  • Optimized for frequent reading
  • Suboptimal frequent writing

Storage in Workers KV

Example: product data

Example: product data

interface Product {
  id: string;
  name: string;
  shortDescription: string;
  description: string;
  price: number;
  brand: string;
  origin: string;
  category: Category | string | undefined;
  filename: string;
}
export function createProductService(platform: App.Platform) {
  const store = platform.env.PRODUCTS;
  return {
    getProductsByCategory(category: Category): Promise<Product[]> {
      return store
        .list()
        .then(prop('keys'))
        .then(map(prop('name'))).
      	.then(keyNames => Promise.all(
          map(keyName => store.get<Product>(keyName, { type: 'json'}))
        ))
      	.then(filter(propEq('category', category.id)))
    }
  };
}

Product.ts

ProductService.ts

Example: product data

interface Product {
  id: string;
  name: string;
  shortDescription: string;
  description: string;
  price: number;
  brand: string;
  origin: string;
  category: Category | string | undefined;
  filename: string;
}
export function createProductService(platform: App.Platform) {
  const store = platform.env.PRODUCTS;
  return {
    getProductsByCategory(category: Category): Promise<Product[]> {
      return store
        .list()
        .then(prop('keys'))
        .then(map(prop('name'))).
      	.then(keyNames => Promise.all(
          map(keyName => store.get<Product>(keyName, { type: 'json'}))
        ))
      	.then(filter(propEq('category', category.id)))
    }
  };
}

Product.ts

ProductService.ts

Example: product data

interface Product {
  id: string;
  name: string;
  shortDescription: string;
  description: string;
  price: number;
  brand: string;
  origin: string;
  category: Category | string | undefined;
  filename: string;
}
export function createProductService(platform: App.Platform) {
  const store = platform.env.PRODUCTS;
  return {
    getProductsByCategory(category: Category): Promise<Product[]> {
      return store
        .list()
        .then(prop('keys'))
        .then(map(prop('name'))).
      	.then(keyNames => Promise.all(
          map(keyName => store.get<Product>(keyName, { type: 'json'}))
        ))
      	.then(filter(propEq('category', category.id)))
    }
  };
}

Product.ts

ProductService.ts

SvelteKit adapters

application

Target platforms

application

Target platforms

Platform-specific adapter

application

Target platforms

Platform-specific adapter

uses

application

Target platforms

Platform-specific adapter

uses

adapts to

Example: adapter-cloudflare

Example: adapter-cloudflare

svelte.config.js

app.d.ts

import adapter from '@sveltejs/adapter-cloudflare';
import preprocess from 'svelte-preprocess';

const config = {
  preprocess: preprocess(),

  kit: {
    adapter: adapter(),
  },
};

export default config;
/// <reference types="@sveltejs/adapter-cloudflare" />

declare namespace App {
  interface Platform {
    env?: {
      PRODUCTS: KVNamespace;
      CATEGORIES: KVNamespace;
      CONTENT: KVNamespace;
    };
  }
}

svelte.config.js

app.d.ts

import adapter from '@sveltejs/adapter-cloudflare';
import preprocess from 'svelte-preprocess';

const config = {
  preprocess: preprocess(),

  kit: {
    adapter: adapter(),
  },
};

export default config;
/// <reference types="@sveltejs/adapter-cloudflare" />

declare namespace App {
  interface Platform {
    env?: {
      PRODUCTS: KVNamespace;
      CATEGORIES: KVNamespace;
      CONTENT: KVNamespace;
    };
  }
}

Example: adapter-cloudflare

svelte.config.js

app.d.ts

import adapter from '@sveltejs/adapter-cloudflare';
import preprocess from 'svelte-preprocess';

const config = {
  preprocess: preprocess(),

  kit: {
    adapter: adapter(),
  },
};

export default config;
/// <reference types="@sveltejs/adapter-cloudflare" />

declare namespace App {
  interface Platform {
    env?: {
      PRODUCTS: KVNamespace;
      CATEGORIES: KVNamespace;
      CONTENT: KVNamespace;
    };
  }
}

Example: adapter-cloudflare

Findings

Findings

Edge web apps run close to the user

Edge web apps run close to the user

Tools like SvelteKit can ease the development

Findings

Tools like SvelteKit can ease the development

Platforms like Cloudflare can ease deployment & execution

Edge web apps run close to the user

Findings

Auf ein Wort zur lokalen Entwicklung...

Auf ein Wort zur lokalen Entwicklung...

import { dev } from '$app/environment';
import type { Handle } from '@sveltejs/kit';
import { isNil } from 'ramda';

export const handle: Handle = async ({ event, resolve }) => {
  if (dev && isNil(event.platform)) {
    const { categoryStore, contentStore, productStore } = await import(
      './kv-mock'
    );
    event.platform = {
      env: {
        PRODUCTS: productStore,
        CATEGORIES: categoryStore,
        CONTENT: contentStore,
      },
    };
  }
  return resolve(event);
};

src/hooks/index.ts

src/hooks/kvMock.ts

import { KVNamespace } from '@miniflare/kv';
import { MemoryStorage } from '@miniflare/storage-memory';

export const productStore = new KVNamespace(new MemoryStorage());
export const categoryStore = new KVNamespace(new MemoryStorage());
export const contentStore = new KVNamespace(new MemoryStorage());

Auf ein Wort zur lokalen Entwicklung...

import { dev } from '$app/environment';
import type { Handle } from '@sveltejs/kit';
import { isNil } from 'ramda';

export const handle: Handle = async ({ event, resolve }) => {
  if (dev && isNil(event.platform)) {
    const { categoryStore, contentStore, productStore } = await import(
      './kv-mock'
    );
    event.platform = {
      env: {
        PRODUCTS: productStore,
        CATEGORIES: categoryStore,
        CONTENT: contentStore,
      },
    };
  }
  return resolve(event);
};

src/hooks/index.ts

src/hooks/kvMock.ts

import { KVNamespace } from '@miniflare/kv';
import { MemoryStorage } from '@miniflare/storage-memory';

export const productStore = new KVNamespace(new MemoryStorage());
export const categoryStore = new KVNamespace(new MemoryStorage());
export const contentStore = new KVNamespace(new MemoryStorage());

Auf ein Wort zur lokalen Entwicklung...

src/hooks/index.ts

src/hooks/kvMock.ts

import { KVNamespace } from '@miniflare/kv';
import { MemoryStorage } from '@miniflare/storage-memory';

export const productStore = new KVNamespace(new MemoryStorage());
export const categoryStore = new KVNamespace(new MemoryStorage());
export const contentStore = new KVNamespace(new MemoryStorage());
import { dev } from '$app/environment';
import type { Handle } from '@sveltejs/kit';
import { isNil } from 'ramda';

export const handle: Handle = async ({ event, resolve }) => {
  if (dev && isNil(event.platform)) {
    const { categoryStore, contentStore, productStore } = await import(
      './kv-mock'
    );
    event.platform = {
      env: {
        PRODUCTS: productStore,
        CATEGORIES: categoryStore,
        CONTENT: contentStore,
      },
    };
  }
  return resolve(event);
};

Auf ein Wort zur lokalen Entwicklung...

src/hooks/index.ts

src/hooks/kvMock.ts

import { KVNamespace } from '@miniflare/kv';
import { MemoryStorage } from '@miniflare/storage-memory';

export const productStore = new KVNamespace(new MemoryStorage());
export const categoryStore = new KVNamespace(new MemoryStorage());
export const contentStore = new KVNamespace(new MemoryStorage());
import { dev } from '$app/environment';
import type { Handle } from '@sveltejs/kit';
import { isNil } from 'ramda';

export const handle: Handle = async ({ event, resolve }) => {
  if (dev && isNil(event.platform)) {
    const { categoryStore, contentStore, productStore } = await import(
      './kv-mock'
    );
    event.platform = {
      env: {
        PRODUCTS: productStore,
        CATEGORIES: categoryStore,
        CONTENT: contentStore,
      },
    };
  }
  return resolve(event);
};

Living on the Edge (Cologne.js 10/2022)

By Nils Röhrig

Living on the Edge (Cologne.js 10/2022)

  • 1,561