Skip to content

Services

Build stateful services, require an OAuth 2.1 token for an API, serve assets, and validate incoming JSON. Container execution has its own dedicated page.

  • Libraries Runtime.Actors Runtime.DynamicWorkflows Runtime.WorkersOauthProvider Runtime.KvAssetHandler Runtime.Cabidela Runtime.Chanfana
  • npm See the library table below
  • Free plan 100,000 Worker requests a day

Image Resizer

The container-backed image resizer now has its own Containers page, including the current F# subclassing limitation.

Event RSVP

Each invitation is a Durable Object that stores RSVPs in SQLite and responds with the headcount, plus-ones included. The Actors Storage helper saves the number of the last migration it applied, so the second migration adds plus_ones once to every invitation, old or new. Invitation inherits the Workers DurableObject class because the current bindings do not support subclassing Actor directly from F#.

open Fable.Core

module Workers = FSharp.CloudEdge.Runtime.Workers
module Runtime = FSharp.CloudEdge.Runtime.Workers.Cloudflare.Workers
module Actors = FSharp.CloudEdge.Runtime.Actors
module ActorStorage = FSharp.CloudEdge.Runtime.Actors.Storage

type Rsvp = {| email: string; plusOnes: float |}
type Headcount = {| attending: float |}

type Invitation(ctx: Workers.DurableObjectState<obj>, env: obj) =
    inherit Runtime.DurableObject<obj, obj>(ctx, env)

    let schema = ActorStorage.Exports.Storage ctx.storage

    do schema.migrations <- [|
        Actors.SQLSchemaMigration.Create(1., "RSVPs", "CREATE TABLE IF NOT EXISTS rsvps (email TEXT PRIMARY KEY)")
        Actors.SQLSchemaMigration.Create(2., "Plus-ones", "ALTER TABLE rsvps ADD COLUMN plus_ones INTEGER DEFAULT 0")
    |]

    interface Runtime.DurableObject.IFetchHandler with
        member _.fetch request =
            async {
                let! _ = schema.runMigrations() |> Async.AwaitPromise
                if request.``method`` = "POST" then
                    let! rsvp = request.json<Rsvp>() |> Async.AwaitPromise
                    ctx.storage.sql.exec("INSERT OR REPLACE INTO rsvps VALUES (?, ?)", rsvp.email, rsvp.plusOnes) |> ignore
                let headcount = ctx.storage.sql.exec<Headcount>("SELECT COUNT(*) + TOTAL(plus_ones) AS attending FROM rsvps").one()
                return Workers.Exports.Response.json headcount
            }
            |> Async.StartAsPromise
            |> U2.Case1

Needs a Durable Object binding for Invitation with the SQLite storage backend, which ctx.storage.sql requires.

Account API

OAuthProvider publishes its OAuth metadata and implements the token and client registration endpoints. It checks the bearer token on every request under /api/ before it calls account, and home answers all other paths. ctx.props holds the props passed to completeAuthorization at sign-in.

open Fable.Core

module Workers = FSharp.CloudEdge.Runtime.Workers
module OAuth = FSharp.CloudEdge.Runtime.WorkersOauthProvider

type User = {| userId: string |}

let account: OAuth.OAuthProviderOptions.ApiHandler<obj> =
    OAuth.OAuthProviderOptions.ApiHandler.Create(
        fetch = fun _ _ (ctx: Workers.ExecutionContext<User>) ->
            Workers.Exports.Response.json {| userId = ctx.props.userId |})

let home: Workers.ExportedHandler<obj, obj, obj, obj> =
    Workers.ExportedHandler.Create(
        fetch = fun _ _ _ -> U2.Case2(Workers.Exports.Response.Create("Sign in to continue")))

[<ExportDefault>]
let provider =
    OAuth.Exports.OAuthProvider(
        OAuth.OAuthProviderOptions.Create(
            defaultHandler = home,
            authorizeEndpoint = "/authorize",
            tokenEndpoint = "/oauth/token",
            clientRegistrationEndpoint = "/oauth/register",
            apiRoute = U2.Case1 "/api/",
            apiHandler = U2.Case2 account))

Needs a KV namespace bound as OAUTH_KV, where the provider stores grants and tokens.

Emitted JavaScript
import { OAuthProvider } from "@cloudflare/workers-oauth-provider";

export const account = {
    fetch: (_arg, _arg_1, ctx) => globalThis.Response.json({
        userId: ctx.props.userId,
    }),
};

export const home = {
    fetch: (_arg, _arg_1, _arg_2) => (new globalThis.Response("Sign in to continue")),
};

export const provider = new OAuthProvider({
    defaultHandler: home,
    authorizeEndpoint: "/authorize",
    tokenEndpoint: "/oauth/token",
    apiRoute: "/api/",
    apiHandler: account,
    clientRegistrationEndpoint: "/oauth/register",
});

export default provider;

Set consent as the provider's defaultHandler in place of home. The provider adds OAUTH_PROVIDER to env. parseAuthRequest validates the client and its redirect URI. After your own sign-in and consent steps, completeAuthorization stores the grant and returns the redirectTo URL.

open Fable.Core

module Workers = FSharp.CloudEdge.Runtime.Workers
module OAuth = FSharp.CloudEdge.Runtime.WorkersOauthProvider

type Env =
    abstract OAUTH_PROVIDER: OAuth.OAuthHelpers

let consent: Workers.ExportedHandler<Env, obj, obj, obj> =
    Workers.ExportedHandler.Create(
        fetch = fun request env _ ->
            async {
                let url = Workers.Exports.URL(U2.Case1 request.url)
                if url.pathname <> "/authorize" then
                    return Workers.Exports.Response.Create("Not found", Workers.ResponseInit.Create(status = 404.))
                else
                    let! authRequest = env.OAUTH_PROVIDER.parseAuthRequest request |> Async.AwaitPromise
                    let! client = env.OAUTH_PROVIDER.lookupClient authRequest.clientId |> Async.AwaitPromise
                    let name = client |> Option.bind (fun c -> c.clientName) |> Option.defaultValue authRequest.clientId
                    let scopes = String.concat ", " authRequest.scope
                    return Workers.Exports.Response.Create($"{name} asks for: {scopes}")
            }
            |> Async.StartAsPromise
            |> U2.Case1
    )

Single-Page App

The Worker serves a built front end from Workers KV. serveSinglePageApp maps page routes such as /settings to index.html, so the front end's own router handles them. Because the result of getAssetFromKV is typed obj in 0.1.0, this Worker is a plain record whose fetch returns that result unchanged.

open Fable.Core

module Workers = FSharp.CloudEdge.Runtime.Workers
module Assets = FSharp.CloudEdge.Runtime.KvAssetHandler

type Env =
    abstract SITE: Workers.KVNamespace<string>

let options (env: Env) =
    Assets.Options.MapRequestToAsset.Options.Create(
        ASSET_NAMESPACE = env.SITE,
        mapRequestToAsset = Assets.Options.MapRequestToAsset(fun request _ -> Assets.Exports.serveSinglePageApp request))

[<ExportDefault>]
let worker =
    {| fetch = fun (request: obj) (env: Env) (ctx: Workers.ExecutionContext<obj>) ->
        let lookup = Assets.GetAssetFromKV.Event.Create(request, fun work -> ctx.waitUntil work)
        Assets.Exports.getAssetFromKV(lookup, options env) |}

Needs a KV namespace bound as SITE that holds each file under its path without the leading slash, such as index.html or assets/app.js.

Emitted JavaScript
import { getAssetFromKV, serveSinglePageApp } from "@cloudflare/kv-asset-handler";

export function options(env) {
    return {
        ASSET_NAMESPACE: env.SITE,
        mapRequestToAsset: (request, _arg) => serveSinglePageApp(request),
    };
}

export const worker = {
    fetch: (request, env, ctx) => getAssetFromKV({
        request: request,
        waitUntil: (work) => {
            ctx.waitUntil(work);
        },
    }, options(env)),
};

export default worker;

Signup Check

The schema is an anonymous record, which Fable emits as a plain JSON Schema object. With applyDefaults, Cabidela sets plan to free if a submission omits it. When request.json or validate throws, the Worker responds with status 400 and the error message.

open Fable.Core

module Workers = FSharp.CloudEdge.Runtime.Workers
module Cabidela = FSharp.CloudEdge.Runtime.Cabidela

let schema =
    {| ``type`` = "object"
       required = [| "email" |]
       properties =
        {| email = {| ``type`` = "string"; minLength = 3 |}
           plan = {| ``type`` = "string"; enum = [| "free"; "team" |]; ``default`` = "free" |} |} |}

let signup = Cabidela.Exports.Cabidela(schema, Cabidela.CabidelaOptions.Create(applyDefaults = true))

[<ExportDefault>]
let worker: Workers.ExportedHandler<obj, obj, obj, obj> =
    Workers.ExportedHandler.Create(
        fetch = fun request _ _ ->
            async {
                try
                    let! form = request.json<obj>() |> Async.AwaitPromise
                    signup.validate form |> ignore
                    return Workers.Exports.Response.json form
                with error ->
                    let status = Workers.ResponseInit.Create(status = 400.)
                    return Workers.Exports.Response.json({| error = error.Message |}, U2.Case2 status)
            }
            |> Async.StartAsPromise
            |> U2.Case1
    )
Emitted JavaScript
export const schema = {
    properties: {
        email: {
            minLength: 3,
            type: "string",
        },
        plan: {
            default: "free",
            enum: ["free", "team"],
            type: "string",
        },
    },
    required: ["email"],
    type: "object",
};

export const signup = new Cabidela(schema, ({
    applyDefaults: true,
}));

API Reference

getSwaggerUI and getReDocUI each build a standalone HTML page. That HTML loads its viewer from jsDelivr and fetches the OpenAPI document at the URL you pass. This Worker serves the document itself at /openapi.json and ReDoc at /redoc. Swagger UI is the response for every other path.

Chanfana's routes are OpenAPIRoute subclasses registered on a Hono or itty-router app. In 0.1.0 OpenAPIRoute is an interface that F# code cannot subclass, and neither router has an F# binding. This Worker calls the two documentation functions directly, since each takes only a URL.

open Fable.Core

module Workers = FSharp.CloudEdge.Runtime.Workers
module Chanfana = FSharp.CloudEdge.Runtime.Chanfana

let listTasks = {| summary = "List tasks"; responses = {| ``200`` = {| description = "The task list" |} |} |}

let spec =
    {| openapi = "3.1.0"
       info = {| title = "Tasks API"; version = "1.0.0" |}
       paths = {| ``/tasks`` = {| get = listTasks |} |} |}

let html = Workers.ResponseInit.Create(headers = {| ``content-type`` = "text/html; charset=utf-8" |})

[<ExportDefault>]
let worker: Workers.ExportedHandler<obj, obj, obj, obj> =
    Workers.ExportedHandler.Create(
        fetch = fun request _ _ ->
            match Workers.Exports.URL(U2.Case1 request.url).pathname with
            | "/openapi.json" -> U2.Case2(Workers.Exports.Response.json spec)
            | "/redoc" -> U2.Case2(Workers.Exports.Response.Create(Chanfana.Exports.getReDocUI "/openapi.json", html))
            | _ -> U2.Case2(Workers.Exports.Response.Create(Chanfana.Exports.getSwaggerUI "/openapi.json", html))
    )
Emitted JavaScript
import { getSwaggerUI, getReDocUI } from "chanfana";

export const listTasks = {
    responses: {
        "200": {
            description: "The task list",
        },
    },
    summary: "List tasks",
};

export const spec = {
    info: {
        title: "Tasks API",
        version: "1.0.0",
    },
    openapi: "3.1.0",
    paths: {
        "/tasks": {
            get: listTasks,
        },
    },
};

export const html = {
    headers: {
        "content-type": "text/html; charset=utf-8",
    },
};

export const worker = {
    fetch: (request, _arg, _arg_1) => {
        const matchValue = (new URL(request.url)).pathname;
        return (matchValue === "/openapi.json") ? globalThis.Response.json(spec) : ((matchValue === "/redoc") ? (new globalThis.Response(getReDocUI("/openapi.json"), html)) : (new globalThis.Response(getSwaggerUI("/openapi.json"), html)));
    },
};

export default worker;

Library Table

Library npm package and version What it covers
Runtime.Actors @cloudflare/actors 0.0.1-beta.6 Durable Object helpers
Runtime.DynamicWorkflows @cloudflare/dynamic-workflows 0.1.1 Workflows per tenant
Runtime.WorkersOauthProvider @cloudflare/workers-oauth-provider 0.10.3 OAuth 2.1 provider
Runtime.KvAssetHandler @cloudflare/kv-asset-handler 0.5.0 Static files from KV
Runtime.Cabidela @cloudflare/cabidela 0.2.4 JSON Schema validation
Runtime.Chanfana chanfana 3.4.0 OpenAPI schemas and docs

Using Dynamic Workflows from F# requires a cast in 0.1.0, so the library has no example here. A Worker Loader stub's getEntrypoint is typed obj, and createDynamicWorkflowEntrypoint takes a loader that must produce a typed WorkflowRunner.

NuGet packages

Runtime.Actors 0.1.0, Runtime.Cabidela 0.1.0, Runtime.Chanfana 0.1.0, Runtime.DynamicWorkflows 0.1.0, Runtime.KvAssetHandler 0.1.0, Runtime.Workers 0.1.0, Runtime.WorkersOauthProvider 0.1.0.

See installation and release availability.

Edit this page