Deploy a Cloudflare Edge Passport

Deploy a Cloudflare Workers app that reveals live edge request metadata.

Introduction

30 Second Summary

Every time you open a website, your connection travels across the internet to reach it. That journey is usually invisible, even when the page loads in a blink.

In this project, you will build Edge Passport, a responsive webpage that reveals where Cloudflare handles a visitor's connection. You will deploy its static interface with a Cloudflare Workers API to a public workers.dev URL.

What You'll Build

You'll select Check My Connection on a polished passport card to reveal live country, region, timezone, data center code, and check time.

By the end of this project, you'll have:

  • Responsive passport interface: Open a polished card that adapts to desktop and mobile screens.
  • Live connection details: Fill the card with location details from a live edge request.
  • Shareable deployment: Send someone your public workers.dev URL so they can run the same check from their browser.
  • Secret Mission: Add a Round trip field that displays each browser-to-Worker request time in milliseconds.

Are there any prerequisites?

You'll need a Cloudflare account, Visual Studio Code, and Node.js with npm.

Step 1 installs Wrangler 4.129.0 locally without requiring GitHub.

Before We Start

This is your moment to commit to building a public Edge Passport before the hands-on work begins. You are aiming for a live app that shows where Cloudflare handled each request.

Set Up the Local Worker

Your Edge Passport needs a dependable local home before it goes public. A local Cloudflare Workers runtime lets you test static assets without affecting a public deployment.

You'll use Visual Studio Code to assemble the project files. A project-local Wrangler installation keeps the deployment workflow pinned to version 4.129.0.

In this step, get ready to:
  • Create the local Worker project files.
  • Prepare the pinned Wrangler toolchain.
  • Verify the placeholder page through the local development server.
Lay out the project foundation

The project keeps its Worker code in src. The browser files live in public.

  • Create the edge-passport folder on your Desktop by running these commands in the terminal:
Set-Location -Path ([Environment]::GetFolderPath('Desktop'))
New-Item -Path ".\edge-passport" -ItemType Directory
Set-Location -Path ".\edge-passport"
New-Item -Path ".\src" -ItemType Directory
New-Item -Path ".\public" -ItemType Directory

What do these commands do?

  • The first command moves the terminal to your Windows Desktop.
  • The next commands create edge-passport with its src folder.
  • The final command adds the public folder for browser assets.
  • Confirm the terminal output lists the new edge-passport folder.
  • Return to Visual Studio Code.
  • Use the folder picker to select edge-passport from your Desktop.
  • Confirm the Explorer sidebar lists public and src.

Folder missing in Visual Studio Code?

Check that the terminal path ends with edge-passport. Use the folder picker again if Visual Studio Code still shows your previous workspace.

Help me find the edge-passport folder I created with PowerShell.

Why use plain project files?

This project focuses on Worker routing and deployment. A small set of plain files keeps every part of that workflow visible.

The frontend and Worker deploy as one unit through Workers Static Assets. Framework setup would hide the Cloudflare-specific pieces you are here to learn.

  • Create package.json inside edge-passport using the new-file control in the Explorer sidebar.
  • Add the project scripts and pinned dependency by pasting this code:
{"name":"edge-passport","version":"1.0.0","private":true,"scripts":{"dev":"wrangler dev","deploy":"wrangler deploy","cf-typegen":"wrangler types"},"devDependencies":{"wrangler":"4.129.0"}}

What does this file control?

  • The scripts object provides commands for local development.
  • The devDependencies object pins Wrangler to 4.129.0.
  • Save package.json.
  • Confirm package.json appears at the top level of the Explorer sidebar.
  • Create wrangler.jsonc inside edge-passport using the new-file control in the Explorer sidebar.
  • Configure the Worker and its asset routing by pasting this code:
{"$schema":"./node_modules/wrangler/config-schema.json","name":"edge-passport","main":"./src/index.ts","compatibility_date":"2026-09-07","assets":{"directory":"./public","run_worker_first":["/api/*"]}}

How is traffic routed?

  • The main value points Wrangler to the Worker entry file.
  • The directory value makes public the static asset source.
  • The run_worker_first pattern sends /api/* requests to Worker logic first.
  • Save wrangler.jsonc.
  • Confirm wrangler.jsonc appears beside package.json.
  • Create tsconfig.json inside edge-passport using the new-file control in the Explorer sidebar.
  • Configure the TypeScript compiler by pasting this code:
{"compilerOptions":{"target":"ES2022","module":"ES2022","lib":["ES2022"],"types":["./worker-configuration.d.ts"],"strict":true,"noEmit":true,"skipLibCheck":true},"include":["src/**/*.ts"]}

What does this configuration provide?

  • The compiler targets ES2022 for the Worker source.
  • The types setting loads generated Worker declarations.
  • The include pattern limits checks to TypeScript files inside src.
  • Save tsconfig.json.
  • Confirm tsconfig.json appears beside the other configuration files.
  • Create public/index.html using the new-file control in the Explorer sidebar.
  • Add the placeholder Edge Passport page by pasting this code:
<!doctype html><html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Edge Passport</title></head><body><main><h1>Edge Passport</h1><p>Your Cloudflare edge connection details will appear here.</p></main></body></html>

What does this page prove?

This small page gives the static asset pipeline something visible to serve. The heading makes the local success easy to recognize in your browser.

  • Save public/index.html.
  • Confirm index.html appears inside the public folder.
  • Create src/index.ts using the new-file control in the Explorer sidebar.
  • Add the starter Worker route by pasting this code:
export default { async fetch(request): Promise<Response> { const url = new URL(request.url); if (url.pathname === "/api/edge") { return new Response(JSON.stringify({ error: "API not ready yet" }), { status: 501, headers: { "content-type": "application/json;charset=UTF-8" } }); } return new Response(JSON.stringify({ error: "Not found" }), { status: 404, headers: { "content-type": "application/json;charset=UTF-8" } }); } } satisfies ExportedHandler;

What does the starter Worker do?

  • The handler reads the requested URL.
  • A request to /api/edge receives a planned 501 response.
  • Any other Worker-handled path receives a JSON 404 response.
  • Save src/index.ts.
  • Confirm index.ts appears inside the src folder.

Seeing red lines in the Worker file?

The generated Worker types do not exist yet. The next substep creates worker-configuration.d.ts for the compiler.

Help me check the files in my Edge Passport project.

✔️ Awesome, I've got everything!

Your five authored files are ready. Save every open editor tab before installing the dependency.

ⓧ I'd like to double check the full code

{"name":"edge-passport","version":"1.0.0","private":true,"scripts":{"dev":"wrangler dev","deploy":"wrangler deploy","cf-typegen":"wrangler types"},"devDependencies":{"wrangler":"4.129.0"}}
{"$schema":"./node_modules/wrangler/config-schema.json","name":"edge-passport","main":"./src/index.ts","compatibility_date":"2026-09-07","assets":{"directory":"./public","run_worker_first":["/api/*"]}}
{"compilerOptions":{"target":"ES2022","module":"ES2022","lib":["ES2022"],"types":["./worker-configuration.d.ts"],"strict":true,"noEmit":true,"skipLibCheck":true},"include":["src/**/*.ts"]}
<!doctype html><html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Edge Passport</title></head><body><main><h1>Edge Passport</h1><p>Your Cloudflare edge connection details will appear here.</p></main></body></html>
export default { async fetch(request): Promise<Response> { const url = new URL(request.url); if (url.pathname === "/api/edge") { return new Response(JSON.stringify({ error: "API not ready yet" }), { status: 501, headers: { "content-type": "application/json;charset=UTF-8" } }); } return new Response(JSON.stringify({ error: "Not found" }), { status: 404, headers: { "content-type": "application/json;charset=UTF-8" } }); } } satisfies ExportedHandler;
Prepare the Wrangler toolchain

The project declares Wrangler as a local development dependency. This gives the project its own pinned CLI instead of depending on a separate global installation.

The first installation can take a minute while npm downloads the dependency. A stream of package messages means the installation is progressing.

  • Install the dependency from the edge-passport terminal by running:
npm install

What does this command create?

npm reads package.json and installs the pinned development dependency. It also generates package-lock.json for repeatable installations.

  • Wait for the terminal prompt to return.
  • Confirm the Explorer sidebar lists node_modules and package-lock.json.

Did the installation fail?

Check that the terminal path ends with edge-passport. Confirm that package.json contains valid JSON.

Help me troubleshoot npm install in my Edge Passport project.

  • Check the installed Wrangler version by running:
npx wrangler --version

Why use npx here?

The command resolves Wrangler from the project's local dependency. This keeps every later command tied to the version declared in package.json.

✔️ I see the pinned version

You should see 4.129.0. Your project is using the expected local Wrangler release.

ⓧ I see another version

A different version usually means the terminal is outside edge-passport or the local installation is incomplete.

  • Select the Visual Studio Code terminal whose path ends with edge-passport.
  • Reinstall the pinned dependency before checking again by running:
npm install
npx wrangler --version

What should change?

The first command restores the dependency declared in package.json. The second command should print 4.129.0.

ⓧ Command not found

This result means the terminal could not run the local CLI. Reinstalling from the project folder restores the executable that npx expects.

  • Select the Visual Studio Code terminal whose path ends with edge-passport.
  • Restore the dependency before checking the version by running:
npm install
npx wrangler --version

What should you see?

A successful installation returns control to the terminal. The version check should print 4.129.0.

Wrangler is now available inside this project. The next command generates declarations for Worker-specific runtime types.

  • Generate the Worker type declarations by running:
npm run cf-typegen

What does type generation do?

The script runs Wrangler's type generator. It creates worker-configuration.d.ts at the top level of edge-passport.

The generated declarations let TypeScript recognize Cloudflare Worker APIs. Your tsconfig.json already points to this file.

  • Confirm the terminal finishes without an error.
  • Confirm worker-configuration.d.ts appears beside package.json.

Worker types not appearing?

Check that wrangler.jsonc is saved. Confirm that the terminal is inside edge-passport.

Help me troubleshoot Wrangler type generation.

Verify the local runtime

Wrangler can now serve the static page while sending API paths to the Worker. This is the local routing model that the public deployment uses later.

Before you run the server, which response do you expect at the base address: the placeholder webpage or a JSON error?

  • Launch the configured local development script by running:
npm run dev

What does the development script do?

npm runs the local wrangler dev script from package.json. Wrangler starts the development server at http://localhost:8787.

The command keeps control of this terminal while the server runs. Leave the process running for the browser checks.

  • Keep the development server running in the terminal.
  • Open a new browser tab.
  • Enter http://localhost:8787 in the address bar.

You should see the Edge Passport heading above the connection-details placeholder. That page proves Wrangler is serving files from public.

Local page not loading?

Check that the terminal still shows the running Wrangler process. Confirm that public/index.html is saved.

Help me troubleshoot my local Wrangler server.

Before you check the API path, do you expect a completed passport payload or the starter error?

  • Enter http://localhost:8787/api/edge in the browser address bar.

You should see {"error":"API not ready yet"} in the browser. This confirms that /api/* requests reach the Worker instead of the static asset handler.

Why return a 501 response?

The 501 response marks the API as deliberately incomplete. It gives the interface a real server response to expose before you build the metadata payload.

Your local Worker now serves the placeholder page and owns the API route. Next, you'll turn that plain starter into a finished passport interface.

Build the Passport Interface

Your local Wrangler runtime already serves static assets from the public/ folder. The placeholder page proves that the routing foundation works.

This step turns that placeholder into the visible Edge Passport. Its button deliberately reaches the incomplete Worker API so you can see the missing server capability before you build it.

In this step, get ready to:
  • Build the passport card with five connection fields.
  • Style a responsive interface with clear button states.
  • Connect the button to the incomplete API.
Build the passport card

The card needs a clear before state so each successful API value has an obvious destination. Every connection field begins with Not checked.

  • Select public/index.html from the file tree in Visual Studio Code.
  • Replace its current contents with this page foundation:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="A small Cloudflare Workers app that reveals where an edge request was handled."
    />
    <title>Edge Passport</title>
    <link rel="stylesheet" href="/styles.css" />
    <script src="/app.js" defer></script>
  </head>
  <body>
    <main class="shell">
      <section class="passport" aria-labelledby="passport-title">
        <div class="passport__header">
          <span class="eyebrow">Cloudflare Workers demo</span>
          <h1 id="passport-title">Your Edge Passport</h1>
          <p>
            Ask the Worker where this request reached Cloudflare's network.
          </p>
        </div>
      </section>
    </main>
  </body>
</html>

What does this structure provide?

  • The shell and passport classes give the stylesheet clear layout targets.
  • The passport-title identifier connects the card to its heading for assistive technology.
  • The stylesheet link loads styles.css when you create it.
  • The deferred script loads app.js after the page structure is ready.
  • Save public/index.html.
  • Refresh http://localhost:8787 in the browser.

You should see the new Your Edge Passport heading with its Cloudflare description.

Still seeing the placeholder page?

Confirm that you edited public/index.html inside the open edge-passport folder. Check that the local Wrangler process is still running.

Ask for help with the file change if the old page remains: Help me find why my updated public/index.html is not appearing in my local Wrangler app.

The data list groups each label with the value that changes after a request. The button and live status message make the interaction visible.

  • In public/index.html, locate the closing </div> for passport__header.
  • Insert this interface content immediately below that closing tag:
        <dl class="passport__grid">
          <div class="passport__item">
            <dt>Country</dt>
            <dd id="country">Not checked</dd>
          </div>
          <div class="passport__item">
            <dt>Region</dt>
            <dd id="region">Not checked</dd>
          </div>
          <div class="passport__item">
            <dt>Timezone</dt>
            <dd id="timezone">Not checked</dd>
          </div>
          <div class="passport__item">
            <dt>Data center</dt>
            <dd id="colo">Not checked</dd>
          </div>
          <div class="passport__item passport__item--wide">
            <dt>Checked at</dt>
            <dd id="checked-at">Not checked</dd>
          </div>
        </dl>

        <button id="check-button" type="button">Check My Connection</button>
        <p id="status" class="status" aria-live="polite">
          Ready to contact the edge.
        </p>

How will the card update?

  • Each dd element has an identifier that matches one value returned by the API.
  • The check-button identifier gives the browser script control of the button.
  • The status element announces loading results through its live region.
  • Save public/index.html.
  • Refresh the local page.

You should see five fields initialized to Not checked. You should also see the Check My Connection button with a ready message.

Missing a field or button?

Make sure the new content sits inside the passport section. Confirm that every opening element in the pasted block keeps its matching closing element.

Ask for a structure check if part of the card is missing: Help me check the HTML structure of my Edge Passport card.

✔️ Awesome, I've got everything!

Great. Save public/index.html before moving on.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="A small Cloudflare Workers app that reveals where an edge request was handled."
    />
    <title>Edge Passport</title>
    <link rel="stylesheet" href="/styles.css" />
    <script src="/app.js" defer></script>
  </head>
  <body>
    <main class="shell">
      <section class="passport" aria-labelledby="passport-title">
        <div class="passport__header">
          <span class="eyebrow">Cloudflare Workers demo</span>
          <h1 id="passport-title">Your Edge Passport</h1>
          <p>
            Ask the Worker where this request reached Cloudflare's network.
          </p>
        </div>

        <dl class="passport__grid">
          <div class="passport__item">
            <dt>Country</dt>
            <dd id="country">Not checked</dd>
          </div>
          <div class="passport__item">
            <dt>Region</dt>
            <dd id="region">Not checked</dd>
          </div>
          <div class="passport__item">
            <dt>Timezone</dt>
            <dd id="timezone">Not checked</dd>
          </div>
          <div class="passport__item">
            <dt>Data center</dt>
            <dd id="colo">Not checked</dd>
          </div>
          <div class="passport__item passport__item--wide">
            <dt>Checked at</dt>
            <dd id="checked-at">Not checked</dd>
          </div>
        </dl>

        <button id="check-button" type="button">Check My Connection</button>
        <p id="status" class="status" aria-live="polite">
          Ready to contact the edge.
        </p>
      </section>
    </main>
  </body>
</html>
Style the passport

CSS gives the interface a visual hierarchy. The first layer establishes the dark color system and page background.

  • Select the public folder in Visual Studio Code's file tree.
  • Create styles.css inside that folder with the new-file control.
  • Add the visual foundation by pasting this code:
:root {
  color-scheme: dark;
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
  background: #07111f;
  color: #eff6ff;
}

* {
  box-sizing: border-box;
}

body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top left, rgba(249, 115, 22, 0.24), transparent 35%),
    radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.2), transparent 38%),
    #07111f;
}

button {
  font: inherit;
}

What does the foundation control?

  • The :root rules establish the page colors and font stack.
  • The universal selector makes padding and borders fit predictably inside element widths.
  • The body rules create the full-height gradient backdrop.
  • Save public/styles.css.
  • Refresh the local page.

You should see a dark blue background with orange and blue gradient highlights. The page text should use the new light color.

Still seeing a white background?

Confirm that the file is named styles.css inside public. Check that index.html still links to /styles.css.

Ask for help if the stylesheet does not load: Help me find why public/styles.css is not styling my local page.

The next layer centers the interface and turns the section into a contained passport card. Its translucent background keeps the gradients visible around the edges.

  • Append these layout rules to public/styles.css by pasting them below the existing code:
.shell {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
}

.passport {
  width: min(680px, 100%);
  padding: clamp(24px, 5vw, 48px);
  border: 1px solid rgba(148, 163, 184, 0.24);
  border-radius: 28px;
  background: rgba(15, 23, 42, 0.9);
  box-shadow: 0 24px 80px rgba(2, 6, 23, 0.55);
  backdrop-filter: blur(18px);
}

How does the card stay centered?

  • The shell grid centers its content across the viewport.
  • The passport width remains fluid until it reaches 680px.
  • The translucent background and shadow separate the card from the page.
  • Save public/styles.css.
  • Refresh the local page.

You should see the interface centered inside a rounded dark card. The card should have space around every edge.

Is the card stuck at the top?

Check that the outer main element still has class="shell". Confirm that the section uses class="passport".

Ask for help with the layout selectors: Help me troubleshoot why my Edge Passport card is not centered.

Strong heading contrast makes the purpose of the card clear before the visitor checks a connection. The eyebrow label adds context without competing with the title.

  • Append these header styles below the current rules in public/styles.css.
.passport__header {
  margin-bottom: 28px;
}

.eyebrow {
  display: inline-block;
  margin-bottom: 10px;
  color: #fb923c;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

h1 {
  margin: 0;
  font-size: clamp(2rem, 7vw, 4rem);
  line-height: 1;
}

.passport__header p,
.status {
  color: #cbd5e1;
}

How does the header create hierarchy?

  • The eyebrow uses compact uppercase text to label the demo.
  • The heading grows with the viewport while keeping a readable maximum size.
  • The supporting text uses a softer color than the heading.
  • Save public/styles.css.
  • Refresh the local page.

You should see an orange eyebrow above a large title. The description should appear in muted text.

Is the title still small?

Check that the page heading remains an h1 element. Confirm that the h1 selector is outside the preceding rule's closing brace.

Ask for help if the header styles are skipped: Help me debug the header styles in public/styles.css.

A two-column grid lets visitors scan related values quickly. Each value gets its own bordered cell so the before state remains easy to compare.

  • Append these grid rules to public/styles.css.
.passport__grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
  margin: 0 0 24px;
}

.passport__item {
  min-width: 0;
  padding: 16px;
  border: 1px solid rgba(148, 163, 184, 0.16);
  border-radius: 16px;
  background: rgba(30, 41, 59, 0.72);
}

.passport__item--wide {
  grid-column: 1 / -1;
}

How does the grid organize values?

  • The grid creates two equal columns with consistent gaps.
  • Each passport item becomes a rounded data cell.
  • The wide modifier stretches the checked time across both columns.
  • Save public/styles.css.
  • Refresh the local page.

You should see the first four fields arranged in two columns. The checked time should span the full width below them.

Are the fields still in one plain list?

Confirm that the data list uses class="passport__grid". Check that every field wrapper uses class="passport__item".

Ask for help matching the grid classes: Help me find why my passport grid styles are not applying.

Labels need less visual weight than the values they describe. Compact uppercase labels make the current results easier to scan.

  • Append these label and value styles to public/styles.css.
dt {
  margin-bottom: 6px;
  color: #94a3b8;
  font-size: 0.75rem;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

dd {
  margin: 0;
  overflow-wrap: anywhere;
  font-size: 1.05rem;
  font-weight: 700;
}

Why separate labels from values?

  • The dt rules make field labels compact and muted.
  • The dd rules give connection values stronger emphasis.
  • Long values can wrap inside their cells without widening the card.
  • Save public/styles.css.
  • Refresh the local page.

You should see small uppercase field labels above larger Not checked values.

Do labels and values look identical?

Check that the labels use dt elements. Confirm that the values use dd elements.

Ask for help if the typography remains unchanged: Help me troubleshoot my dt and dd styles.

The button needs clear default states for hover, keyboard focus, and loading. These states make the same control understandable across mouse and keyboard interactions.

  • Append these button styles to public/styles.css.
#check-button {
  width: 100%;
  padding: 14px 18px;
  border: 0;
  border-radius: 14px;
  background: linear-gradient(135deg, #f97316, #fb923c);
  color: #111827;
  cursor: pointer;
  font-weight: 900;
}

#check-button:hover {
  filter: brightness(1.06);
}

#check-button:focus-visible {
  outline: 3px solid #93c5fd;
  outline-offset: 3px;
}

#check-button:disabled {
  cursor: wait;
  opacity: 0.68;
}

What do the button states communicate?

  • The default rule creates a full-width orange action button.
  • The hover state brightens the button under a pointer.
  • The focus state adds a visible keyboard outline.
  • The disabled state signals that a request is in progress.
  • Save public/styles.css.
  • Refresh the local page.
  • Move your pointer over Check My Connection.

You should see a full-width orange button. Its color should brighten slightly under your pointer.

Is the button still unstyled?

Confirm that the button keeps id="check-button". Check that the CSS selector begins with #check-button.

Ask for help with the button selector: Help me debug why the check button styles are not applying.

Status colors distinguish a successful request from an error at a glance. The mobile rule changes the data grid to one column when horizontal space becomes tight.

  • Finish public/styles.css by appending these status and mobile rules:
.status {
  min-height: 1.5em;
  margin: 14px 0 0;
  text-align: center;
}

.status[data-state="success"] {
  color: #86efac;
}

.status[data-state="error"] {
  color: #fca5a5;
}

@media (max-width: 560px) {
  .passport__grid {
    grid-template-columns: 1fr;
  }

  .passport__item--wide {
    grid-column: auto;
  }
}

How does the interface adapt?

  • The status area keeps enough height to prevent the card from jumping when its message changes.
  • Successful status text becomes green.
  • Error status text becomes red.
  • The media query switches the data grid to one column on narrow screens.
  • Save public/styles.css.
  • Refresh the local page.
  • Narrow the browser window below 560px.

You should see every field move into a single column. Widening the browser should restore the two-column layout.

Does the grid stay in two columns?

Confirm that the media query appears after the default grid rules. Check that both closing braces remain at the end of the file.

Ask for help if the responsive rule does not activate: Help me troubleshoot the mobile grid in public/styles.css.

✔️ Awesome, I've got everything!

Your card now has its complete responsive design. Save public/styles.css once more.

ⓧ I'd like to double check the full code

:root {
  color-scheme: dark;
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
  background: #07111f;
  color: #eff6ff;
}

* {
  box-sizing: border-box;
}

body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top left, rgba(249, 115, 22, 0.24), transparent 35%),
    radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.2), transparent 38%),
    #07111f;
}

button {
  font: inherit;
}

.shell {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
}

.passport {
  width: min(680px, 100%);
  padding: clamp(24px, 5vw, 48px);
  border: 1px solid rgba(148, 163, 184, 0.24);
  border-radius: 28px;
  background: rgba(15, 23, 42, 0.9);
  box-shadow: 0 24px 80px rgba(2, 6, 23, 0.55);
  backdrop-filter: blur(18px);
}

.passport__header {
  margin-bottom: 28px;
}

.eyebrow {
  display: inline-block;
  margin-bottom: 10px;
  color: #fb923c;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

h1 {
  margin: 0;
  font-size: clamp(2rem, 7vw, 4rem);
  line-height: 1;
}

.passport__header p,
.status {
  color: #cbd5e1;
}

.passport__grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
  margin: 0 0 24px;
}

.passport__item {
  min-width: 0;
  padding: 16px;
  border: 1px solid rgba(148, 163, 184, 0.16);
  border-radius: 16px;
  background: rgba(30, 41, 59, 0.72);
}

.passport__item--wide {
  grid-column: 1 / -1;
}

dt {
  margin-bottom: 6px;
  color: #94a3b8;
  font-size: 0.75rem;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

dd {
  margin: 0;
  overflow-wrap: anywhere;
  font-size: 1.05rem;
  font-weight: 700;
}

#check-button {
  width: 100%;
  padding: 14px 18px;
  border: 0;
  border-radius: 14px;
  background: linear-gradient(135deg, #f97316, #fb923c);
  color: #111827;
  cursor: pointer;
  font-weight: 900;
}

#check-button:hover {
  filter: brightness(1.06);
}

#check-button:focus-visible {
  outline: 3px solid #93c5fd;
  outline-offset: 3px;
}

#check-button:disabled {
  cursor: wait;
  opacity: 0.68;
}

.status {
  min-height: 1.5em;
  margin: 14px 0 0;
  text-align: center;
}

.status[data-state="success"] {
  color: #86efac;
}

.status[data-state="error"] {
  color: #fca5a5;
}

@media (max-width: 560px) {
  .passport__grid {
    grid-template-columns: 1fr;
  }

  .passport__item--wide {
    grid-column: auto;
  }
}
Connect the incomplete API

Browser JavaScript turns the static card into an interaction. The first runnable version sends the request and surfaces the Worker's JSON error through the status message.

  • Select the public folder in Visual Studio Code's file tree.
  • Create app.js inside that folder with the new-file control.
  • Add the request flow by pasting this code:
const button = document.querySelector("#check-button");
const statusMessage = document.querySelector("#status");

function showStatus(message, state) {
  statusMessage.textContent = message;
  statusMessage.dataset.state = state;
}

button.addEventListener("click", async () => {
  button.disabled = true;
  showStatus("Contacting the Cloudflare edge...", "loading");

  try {
    const response = await fetch("/api/edge");
    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.error ?? "The edge request failed.");
    }

    showStatus("Passport updated from the Worker.", "success");
  } catch (error) {
    const message =
      error instanceof Error ? error.message : "Could not load edge data.";
    showStatus(message, "error");
  } finally {
    button.disabled = false;
  }
});

What does the request flow do?

  • The script finds the button and status elements by their identifiers.
  • The click handler disables the button while the request runs.
  • The fetch("/api/edge") call reaches the reserved Worker route.
  • A non-successful response becomes an error that the card can display.
  • The finally block restores the button after every outcome.
  • Save public/app.js.
  • Refresh the local page.

Before you select the button, do you think the current Worker can provide connection values?

  • Select Check My Connection.

You should see API not ready yet in red. This is the planned shortfall from the starter Worker.

What did the error prove?

The browser reached /api/edge and parsed the Worker's JSON response. The 501 result proves that the server capability is the missing piece.

Does the button stay on the ready message?

Confirm that app.js is saved inside public. Check that index.html still loads /app.js with the deferred script element.

Ask for help if the click does not run: Help me troubleshoot why my Edge Passport button is not calling the API.

The successful response will contain five values. A field map connects each JSON property to the matching output element without repeating selectors inside the click handler.

  • In public/app.js, locate the statusMessage declaration.
  • Insert this field map immediately below that declaration:
const fields = {
  country: document.querySelector("#country"),
  region: document.querySelector("#region"),
  timezone: document.querySelector("#timezone"),
  colo: document.querySelector("#colo"),
  checkedAt: document.querySelector("#checked-at"),
};

What does the field map hold?

Each property stores the page element for one connection value. The property names match the successful API payload that the Worker will return.

The final browser logic copies successful values into the card. Its fallbacks keep every field readable when a value is unavailable.

  • In the click handler, locate the closing brace for the if (!response.ok) block.
  • Insert these assignments immediately below that closing brace:
    fields.country.textContent = data.country ?? "Unknown";
    fields.region.textContent = data.region ?? "Unknown";
    fields.timezone.textContent = data.timezone ?? "Unknown";
    fields.colo.textContent = data.colo ?? "Unknown";
    fields.checkedAt.textContent = data.checkedAt ?? "Unknown";

How are results displayed?

Each assignment copies one API value into its matching card field. The Unknown fallback prevents missing metadata from leaving an empty cell.

  • Save public/app.js.
  • Refresh http://localhost:8787.

Before you check again, will the complete browser logic change the response from the starter Worker?

  • Select Check My Connection.

You should see the button disable briefly. The status should change to API not ready yet in red before the button becomes available again.

Seeing a different result?

Confirm that the local Wrangler process from the previous step is still running. Check that src/index.ts still returns the API not ready yet JSON error for /api/edge.

Ask for help if the request or error display behaves differently: Help me trace the Edge Passport request from app.js to src/index.ts.

✔️ Awesome, I've got everything!

Your browser interaction is complete. The visible API error confirms that the interface is ready for a working Worker endpoint.

ⓧ I'd like to double check the full code

const button = document.querySelector("#check-button");
const statusMessage = document.querySelector("#status");

const fields = {
  country: document.querySelector("#country"),
  region: document.querySelector("#region"),
  timezone: document.querySelector("#timezone"),
  colo: document.querySelector("#colo"),
  checkedAt: document.querySelector("#checked-at"),
};

function showStatus(message, state) {
  statusMessage.textContent = message;
  statusMessage.dataset.state = state;
}

button.addEventListener("click", async () => {
  button.disabled = true;
  showStatus("Contacting the Cloudflare edge...", "loading");

  try {
    const response = await fetch("/api/edge");
    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.error ?? "The edge request failed.");
    }

    fields.country.textContent = data.country ?? "Unknown";
    fields.region.textContent = data.region ?? "Unknown";
    fields.timezone.textContent = data.timezone ?? "Unknown";
    fields.colo.textContent = data.colo ?? "Unknown";
    fields.checkedAt.textContent = data.checkedAt ?? "Unknown";
    showStatus("Passport updated from the Worker.", "success");
  } catch (error) {
    const message =
      error instanceof Error ? error.message : "Could not load edge data.";
    showStatus(message, "error");
  } finally {
    button.disabled = false;
  }
});

Your polished interface now makes the missing server capability visible. Next, you will replace the planned error with live edge metadata.

Create the Worker Endpoint

Your passport interface now reaches /api/edge. The 501 response proves the browser wiring works while exposing the missing server capability.

Now you will turn that placeholder into a TypeScript endpoint powered by Cloudflare Workers. Wrangler reloads the local Worker after each saved change.

In this step, get ready to:
  • Define the passport response shape with reusable JSON headers.
  • Route /api/edge requests to a live metadata payload.
  • Confirm the passport card replaces its error with Worker data.
Define the API contract

The browser expects five values from the endpoint. A shared header object also keeps every Worker response consistent.

  • In the Visual Studio Code Explorer sidebar, select src/index.ts.
  • Place your cursor on the blank line above the existing export default handler.
  • Add the response contract with its shared headers by pasting this code:
interface EdgePassport {
  country: string;
  region: string;
  timezone: string;
  colo: string;
  checkedAt: string;
}

const jsonHeaders = {
  "content-type": "application/json;charset=UTF-8",
};

What does this code set up?

  • The EdgePassport interface defines the five values that every successful response contains.
  • The jsonHeaders object identifies each Worker response as JSON.
  • The exact response shape helps TypeScript catch missing fields before a request reaches the browser.
  • Save src/index.ts.
  • Return to the terminal panel from earlier.

You should see Wrangler reload the Worker without reporting a TypeScript problem.

Seeing a TypeScript problem?

  • Check that EdgePassport sits above the existing export default handler.
  • Check the comma after the content-type value.
  • Check that both declarations have their closing braces.

Help me locate the TypeScript problem in my src/index.ts declarations.

Replace the placeholder handler

The handler decides which requests receive Worker logic. Its edge payload comes from request.cf before the browser receives the response.

  • In src/index.ts, find the line that begins with export default.
  • Select from that line through the final line of the file.
  • Replace the selected handler by pasting this code:
export default {
  async fetch(request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname !== "/api/edge") {
      return new Response(JSON.stringify({ error: "Not found" }), {
        status: 404,
        headers: jsonHeaders,
      });
    }

    const cf = request.cf;
    const passport: EdgePassport = {
      country: cf?.country ?? "Unknown",
      region: cf?.region ?? "Unknown",
      timezone: cf?.timezone ?? "Unknown",
      colo: cf?.colo ?? "Unknown",
      checkedAt: new Date().toISOString(),
    };

    return new Response(JSON.stringify(passport), {
      headers: jsonHeaders,
    });
  },
} satisfies ExportedHandler;

How does the endpoint work?

  • The URL object reveals the requested path.
  • Requests outside /api/edge receive a JSON response with status 404.
  • The request.cf object supplies country, region, timezone, and data-center metadata.
  • The Unknown fallbacks keep every field readable when local metadata is unavailable.
  • The checkedAt field records the check time as an ISO timestamp.
  • The final response serializes the completed passport object with application/json;charset=UTF-8.
  • Save src/index.ts.
  • Return to the terminal panel from earlier.

You should see Wrangler reload the updated Worker without errors. The new handler is now serving local requests.

Worker not reloading cleanly?

  • Check that the old placeholder handler was fully replaced.
  • Confirm the new block begins with export default.
  • Confirm EdgePassport and jsonHeaders remain above the handler.

Help me troubleshoot why my updated Worker handler is not reloading.

✔️ Awesome, I've got everything!

Your src/index.ts file now contains the completed Worker endpoint.

ⓧ I'd like to double check the full code

interface EdgePassport {
  country: string;
  region: string;
  timezone: string;
  colo: string;
  checkedAt: string;
}

const jsonHeaders = {
  "content-type": "application/json;charset=UTF-8",
};

export default {
  async fetch(request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname !== "/api/edge") {
      return new Response(JSON.stringify({ error: "Not found" }), {
        status: 404,
        headers: jsonHeaders,
      });
    }

    const cf = request.cf;
    const passport: EdgePassport = {
      country: cf?.country ?? "Unknown",
      region: cf?.region ?? "Unknown",
      timezone: cf?.timezone ?? "Unknown",
      colo: cf?.colo ?? "Unknown",
      checkedAt: new Date().toISOString(),
    };

    return new Response(JSON.stringify(passport), {
      headers: jsonHeaders,
    });
  },
} satisfies ExportedHandler;
Test the completed endpoint

Your browser has already seen the placeholder response fail. Before you check again, do you think the same button will now reach the new payload?

  • Switch back to the browser tab at http://localhost:8787.
  • Refresh the page.
  • Select Check My Connection.

The API not ready yet error is gone. You should see metadata values or Unknown in the location fields.

You should also see an ISO timestamp under Checked at. That is the server-side gap closed: your passport now receives a complete Worker response.

Why can local values show Unknown?

The local check proves that routing, JSON serialization, and browser rendering work together. The public deployment in the next step provides the authoritative check for Cloudflare production metadata.

Your local Edge Passport now has a working API behind it. Next, you will deploy the same Worker and static interface to a public URL.

Deploy the Edge Passport

Your local Cloudflare Workers app now returns edge metadata from /api/edge whenever you select Check My Connection. That working card proves your browser can reach its API.

Local success does not prove that Cloudflare's global network handled the request. A public deployment turns that local result into a shareable production check.

In this step, you'll use Wrangler to connect the project to your Cloudflare account. You'll publish the app at a public workers.dev URL.

In this step, get ready to:
  • Authenticate local Wrangler through Cloudflare OAuth.
  • Deploy the Edge Passport as one production unit.
  • Verify the active production deployment.
Authenticate the local CLI

Wrangler uses OAuth to authorize the local CLI. The --use-keyring option stores the resulting credential in Windows Credential Manager.

Authentication can feel sensitive. This flow keeps your account password out of the terminal.

  • In the Visual Studio Code terminal panel from earlier, select the plus button to create a second terminal.
  • Confirm the new terminal prompt points to the edge-passport folder.

The login command opens a browser window for Cloudflare approval.

  • Start Wrangler's OAuth flow by running this command:
npx wrangler login --use-keyring

What does this command do?

The command starts Cloudflare's browser-based OAuth flow. The --use-keyring option protects the local credential with Windows Credential Manager.

  • Approve the Cloudflare OAuth request in the browser window.
  • Return to the second terminal panel in Visual Studio Code.
  • Wait for Wrangler to confirm authentication.
  • Verify the connected Cloudflare account by running this command:
npx wrangler whoami

What does this check prove?

This command retrieves information about the current authenticated user. It confirms that local Wrangler can identify your Cloudflare account.

Authentication is complete. Your local CLI can now deploy resources to the correct Cloudflare account.

Login not completing?

  • Retry the login command above if the browser approval did not complete.
  • Check that the second terminal still points to the edge-passport folder before you retry.

Help me troubleshoot Wrangler authentication on Windows 11.

Deploy the Worker and assets

A deployment combines your Worker code with its static assets as one Cloudflare unit. The same public URL serves the interface plus the /api/edge endpoint.

Workers Free limits

Publishing creates a live cloud resource. Your account remains on Workers Free for this project.

Workers Free includes 100,000 Worker requests per day. Requests served as static assets are free and unlimited.

Normal learning use keeps this project at $0.

A first deployment may pause for a one-time workers.dev subdomain prompt.

  • Deploy the Edge Passport by running this command:
npm run deploy

What does this command deploy?

The deploy script invokes the local Wrangler dependency from package.json. Wrangler uploads src/index.ts with the files in public/ as one deployment.

  • Follow the terminal prompt to create your workers.dev subdomain if it appears.
  • Wait for Wrangler to print the public deployment URL.
  • Save the public URL printed by Wrangler here: your public workers.dev URL.
  • Confirm the terminal output reports a completed deployment.

Deployment not completing?

  • Check that the terminal points to the edge-passport folder.
  • Run the account check above again if Wrangler reports an authentication problem.
  • Complete the one-time subdomain prompt if the deployment is waiting for a response.

Help me troubleshoot my Edge Passport deployment.

Verify production behavior

The public deployment is the authoritative check for Cloudflare-provided request metadata. A successful card update proves that the deployed interface can reach the production Worker endpoint.

Before you check the public app, do you expect the card to use local fallback values or metadata from Cloudflare's production network?

  • Switch back to the browser from earlier.
  • Paste your public workers.dev URL into the address bar.
  • Press Enter.
  • Select Check My Connection.

The card now displays production edge metadata. The Checked at field shows a fresh ISO timestamp.

Before you run the final check, what deployment state do you expect Wrangler to report?

  • Confirm the production deployment state by running this command:
npx wrangler deployments status

What does this status check prove?

This command shows the current state of the production deployment. It confirms that Cloudflare recognizes the deployed Worker as active.

Wrangler reports the active production deployment. Your saved public URL now serves the complete Edge Passport.

Production check not working?

  • Refresh the public page if the browser loaded an older cached response.
  • Confirm the address bar contains your saved workers.dev URL.
  • Run the deployment command above again if Wrangler does not report an active production deployment.

Help me troubleshoot my public Edge Passport verification.

That is the hard part done. Your Edge Passport is live on Cloudflare's network with a working public interface plus production edge metadata.

Secret mission

Measure the Edge Round Trip

Your passport already reveals where Cloudflare handled each request. Add a browser-measured round-trip time to show how long each Worker response takes.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your deployed app, pause local development, or delete the project resources. Normal learning use on Cloudflare Workers costs $0 while your account remains on Workers Free.

Resources you used:

  • A public Cloudflare Worker named edge-passport, with its static assets and active workers.dev deployment.
  • The local edge-passport folder, including src/index.ts, public/index.html, public/styles.css, public/app.js, project configuration, node_modules, package-lock.json, and worker-configuration.d.ts.

Keep everything running

No action is needed. Choose this if you want to keep sharing your Edge Passport or continue improving it.

  • Keep the edge-passport Worker deployed at your public workers.dev URL.
  • Keep the local edge-passport folder for future edits.

What Stays Free?

Workers Free includes 100,000 Worker requests per day for your API calls.

Requests served as static assets are free and unlimited.

Pause - I'll come back to this later

Pausing stops the local Wrangler server to free your computer's resources. Your files stay in place.

The deployed workers.dev URL remains online.

  • Switch back to the Visual Studio Code terminal from earlier.
  • Stop the local Wrangler server by pressing Ctrl+C.

You'll see the terminal prompt return. Your public Edge Passport continues serving requests.

Delete - I don't want to use this again

Deletion removes the public app and your local code permanently. This option gives you a clean finish when you no longer need the project.

Delete the deployed Worker:

  • Return to the Visual Studio Code terminal for edge-passport.
  • Stop any running local server by pressing Ctrl+C.

Before You Delete

Wrangler asks for confirmation before it removes anything. Once confirmed, your public Edge Passport goes offline.

  • Start the Worker deletion by running this command:
npx wrangler delete

What Does This Command Delete?

The command reads the Worker name from wrangler.jsonc, so it targets edge-passport.

The Worker code and deployed static assets belong to the same deployment unit. Deleting the Worker removes both.

  • Confirm the deletion when Wrangler asks.
  • Refresh the public workers.dev URL from earlier.

Your Edge Passport no longer loads from its public URL. That confirms the production Worker has been removed.

Worker Still Deployed?

If Wrangler cannot identify the Worker, check that the terminal is inside edge-passport and that wrangler.jsonc is still present.

Get help with this Worker deletion.

Delete the local project files:

  • Delete the local edge-passport folder through File Explorer.
  • Confirm that edge-passport no longer appears in File Explorer.

The folder deletion also removes node_modules, package-lock.json, and worker-configuration.d.ts from your computer.

Folder Won't Delete?

If Windows reports that the folder is in use, close any terminal or editor window using edge-passport before trying again.

Get help removing the local folder.

Nice Work!

Nice Work!

Your Edge Passport is live on Cloudflare Workers! Visitors can check the Cloudflare location plus the browser-measured round-trip time from its public workers.dev URL.

You've learned how to:

  • Build a responsive Edge Passport interface that turns each connection check into readable location details.
  • Create a TypeScript Worker API route at /api/edge that reads edge request metadata from request.cf. Return location values plus a fresh ISO timestamp to the passport.
  • Authenticate Wrangler through OAuth. Deploy Worker code plus static assets to a public URL. Confirm the active production deployment.
  • Complete the optional Secret Mission by adding browser-measured round-trip timing that updates after every passport check.

Ready to quiz yourself?