Skip to content

Asset Uploads

Serve static files, such as your site's pages and images, from hello-worker. Your program sends Cloudflare a manifest of file hashes and uploads only the new and changed files. It then uploads hello-worker with a completion token, and Cloudflare attaches the files to the new version.

File Hash

Each file in the manifest has a hash of 32 hexadecimal characters. hashFile computes SHA-256 over the file's base64 text followed by its extension, such as html, and keeps the first 16 bytes. The example in Cloudflare's direct-upload guide hashes the same inputs. With the extension in the input, about.txt and about.html with the same bytes are two assets, each uploaded with its own content type.

module FileHash

open System
open System.IO
open System.Security.Cryptography
open System.Text

let hashFile (path: string) =
    let base64 = Convert.ToBase64String(File.ReadAllBytes path)
    let extension = Path.GetExtension(path).TrimStart '.'
    let digest = SHA256.HashData(Encoding.UTF8.GetBytes(base64 + extension))
    Convert.ToHexString(digest, 0, 16).ToLowerInvariant()

Manifest

scan returns an Asset record for each file under a folder. The key is the URL path of the file, such as /index.html. manifest builds the request's map from each key to the file's hash and its size in bytes. In generated type names such as workers_manifest_u002D_value, _u002D_ stands for a hyphen in the schema name.

module Manifest

open System.IO
open FSharp.CloudEdge.Core.Api.Types

type Asset = { Key: string; Path: string; Hash: string; Size: int }

let scan (folder: string) =
    [ for path in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories) ->
          { Key = "/" + Path.GetRelativePath(folder, path).Replace('\\', '/')
            Path = path
            Hash = FileHash.hashFile path
            Size = int (FileInfo path).Length } ]

let manifest (assets: Asset list) =
    assets
    |> List.map (fun asset -> asset.Key, workers_manifest_u002D_value.Create(asset.Hash, asset.Size))
    |> Map.ofList

Missing-File Check

WorkerScriptUpdateCreateAssetsUploadSession posts the manifest for hello-worker. The reply contains an upload token and buckets, which are groups of hashes to upload together. Files already uploaded for a recent version of hello-worker are left out of the buckets. When Cloudflare already has every file, the buckets are empty and the token is the completion token for the deployment.

module MissingFiles

open FSharp.CloudEdge.Core.Api.Types
open FSharp.CloudEdge.Management.Compute

let check (compute: ComputeClient) accountId (assets: Manifest.Asset list) =
    task {
        let request = workers_create_u002D_assets_u002D_upload_u002D_session_u002D_object.Create(Manifest.manifest assets)
        match! compute.WorkerScriptUpdateCreateAssetsUploadSession(accountId, "hello-worker", request) with
        | WorkerScriptUpdateCreateAssetsUploadSession.OK response ->
            match response.result with
            | Some { jwt = Some token; buckets = buckets } ->
                let buckets = defaultArg buckets []
                printfn "%d of %d files need uploading" (List.sumBy List.length buckets) assets.Length
                return Some(token, buckets)
            | _ -> return None
        | WorkerScriptUpdateCreateAssetsUploadSession.Status4XX(status, failure) ->
            for error in failure.errors do
                printfn "Upload session refused with HTTP %d: %s" status error.message
            return None
    }

Batched Upload

upload sends each bucket in one WorkerAssetsUpload request. These requests require the upload token in place of your API token, so upload creates a second HttpClient with that bearer token. Each file is one form field. The field name is the file's hash, and the value is the base64 contents. Cloudflare serves each file with the content type of its field.

The reply to the last bucket, HTTP 201, contains the completion token. The upload token and the completion token are each valid for one hour.

module BatchedUpload

open System
open System.IO
open System.Net.Http
open System.Net.Http.Headers
open FSharp.CloudEdge.Core.Api.Http
open FSharp.CloudEdge.Core.Api.Types
open FSharp.CloudEdge.Management.Compute

let contentType (path: string) =
    match Path.GetExtension(path).ToLowerInvariant() with
    | ".html" -> "text/html"
    | ".css" -> "text/css"
    | ".js" -> "text/javascript"
    | ".svg" -> "image/svg+xml"
    | ".png" -> "image/png"
    | _ -> "application/octet-stream"

let upload accountId (uploadToken: string) (assets: Manifest.Asset list) (buckets: string list list) =
    task {
        use http = new HttpClient(BaseAddress = Uri "https://api.cloudflare.com/client/v4")
        http.DefaultRequestHeaders.Authorization <- AuthenticationHeaderValue("Bearer", uploadToken)
        let uploads = ComputeClient http
        let pathOf = assets |> List.map (fun asset -> asset.Hash, asset.Path) |> Map.ofList
        let mutable completionToken = None
        for index, bucket in List.indexed buckets do
            printfn "Uploading bucket %d of %d" (index + 1) buckets.Length
            let files: MultipartTextField list =
                [ for hash in bucket ->
                      { Name = hash
                        Value = Convert.ToBase64String(File.ReadAllBytes pathOf[hash])
                        ContentType = contentType pathOf[hash] } ]
            match! uploads.WorkerAssetsUpload(accountId, true, files) with
            | WorkerAssetsUpload.Created finished -> completionToken <- finished.result |> Option.bind (fun result -> result.jwt)
            | WorkerAssetsUpload.Accepted _ -> ()
            | WorkerAssetsUpload.Status4XX(status, _) -> printfn "Bucket %d refused with HTTP %d" (index + 1) status
        return completionToken
    }

Deployment

The deployment is the Worker upload from First Deploy, with the completion token in the metadata's assets.jwt. By default, Cloudflare serves a request whose URL matches a file from the assets and invokes the Worker for every other request.

module Deployment

open System.IO
open System.Text.Json
open System.Text.Json.Nodes
open FSharp.CloudEdge.Core.Api.Http
open FSharp.CloudEdge.Core.Api.Types
open FSharp.CloudEdge.Management.Compute

let uploadWorker (compute: ComputeClient) accountId (metadata: JsonObject) =
    task {
        let worker: MultipartFile =
            { Bytes = File.ReadAllBytes "dist/worker.js"
              FileName = "worker.js"
              ContentType = Some "application/javascript+module"
              PartName = Some "worker.js" }
        match! compute.WorkerScriptUploadWorkerModule(accountId, "hello-worker", metadata, files = [ worker ]) with
        | WorkerScriptUploadWorkerModule.OK payload ->
            printfn "Deployed hello-worker, has assets: %b" (payload.result.has_assets = Some true)
        | WorkerScriptUploadWorkerModule.Status4XX(status, failure) ->
            printfn "Deployment rejected with HTTP %d: %O" status failure.JsonValue
    }

let deploy compute accountId (completionToken: string) =
    let settings =
        {| main_module = "worker.js"
           compatibility_date = "2026-09-06"
           assets = {| jwt = completionToken |} |}
    uploadWorker compute accountId (JsonSerializer.SerializeToNode(settings).AsObject())

Needs dist/worker.js from First Worker.

Code-Only Redeploy

When only dist/worker.js has changed, call redeploy. It sets keep_assets in the metadata in place of a completion token. The new version then has the same assets as the current one, and the program skips the upload session and the buckets.

module CodeOnlyRedeploy

open System.Text.Json

let redeploy compute accountId =
    let settings =
        {| main_module = "worker.js"
           compatibility_date = "2026-09-06"
           keep_assets = true |}
    Deployment.uploadWorker compute accountId (JsonSerializer.SerializeToNode(settings).AsObject())

Site Publisher

publish calls the modules above in order for the public folder in hello-worker. With --code-only, it calls the code-only redeploy.

module Program

open FSharp.CloudEdge.Management.Compute

let publish (args: string[]) =
    task {
        use http = ClientSetup.cloudflareHttp ()
        let compute = ComputeClient http
        let accountId = ClientSetup.accountId ()
        if args = [| "--code-only" |] then
            do! CodeOnlyRedeploy.redeploy compute accountId
        else
            let assets = Manifest.scan "public"
            match! MissingFiles.check compute accountId assets with
            | Some(completionToken, []) -> do! Deployment.deploy compute accountId completionToken
            | Some(uploadToken, buckets) ->
                match! BatchedUpload.upload accountId uploadToken assets buckets with
                | Some completionToken -> do! Deployment.deploy compute accountId completionToken
                | None -> printfn "The upload returned no completion token"
            | None -> ()
    }

[<EntryPoint>]
let main args =
    (publish args).GetAwaiter().GetResult()
    0

Needs a public folder in hello-worker and the ClientSetup module from Credentials. The publisher's project file is like the one on First Deploy, and its Compile list contains ClientSetup.fs, then the seven files of this page in order. Start it from hello-worker, since it reads public and dist/worker.js relative to the working directory.

On the first run, all four files in public are new.

4 of 4 files need uploading
Uploading bucket 1 of 2
Uploading bucket 2 of 2
Deployed hello-worker, has assets: true

On the second run, the four files are the same as before.

0 of 4 files need uploading
Deployed hello-worker, has assets: true

On the third run, style.css has new contents.

1 of 4 files need uploading
Uploading bucket 1 of 1
Deployed hello-worker, has assets: true

These runs were recorded against a local test server in place of Cloudflare's API. That server returned buckets of two hashes.

Library Table

Library Operations What it covers
Management.Compute 304 Asset and Worker uploads
Core.Api 0 Multipart fields and response types

NuGet packages

Core.Api 0.1.0, Management.Compute 0.1.0.

See installation and release availability.

Edit this page