Deno Deploy

Deno Deploy runs JavaScript and TypeScript at the edge in Deno isolates. Because Deno resolves npm packages natively, @avsbhq/edge works with no build step. By the end of this guide your function will evaluate flags from a Deno KV-cached datafile and send its queued events before the response leaves.

1

Map the package

Deno resolves npm packages at runtime with the npm: prefix. Pin the version in an import map so the specifier in your code stays short and reproducible.

2

Obtain your SDK key

Open your A vs B project, find Environments in the sidebar, and copy the SDK key for this function's environment. Add it on dash.deno.com as a project environment variable named AVSB_SDK_KEY.

3

Bootstrap with createDenoHandler

The factory builds one client per request, binds the visitor context, awaits init, and awaits the flush before the response returns.

4

Cache the datafile in Deno KV

Hand the factory a Deno.openKv() instance and a new isolate reads the datafile from KV instead of the A vs B CDN.

5

Read a flag

The client inside your handler is already bound to the visitor, so reads take no context argument.

6

Track an event

Call client.track(eventKey, payload). The factory flushes for you.

Mapping the package

Pin the version in deno.json:

JSON
{  "imports": {    "@avsbhq/edge": "npm:@avsbhq/edge@^1.0.1",    "@avsbhq/edge/": "npm:@avsbhq/edge@^1.0.1/"  }}
JSON6 lines

The second entry is what lets @avsbhq/edge/deno resolve to the Deno adapter subpath. Without an import map you can write the specifier inline instead, as npm:@avsbhq/edge/deno, though then the version lives in every import.

The package has no peer dependencies. Every Deno surface it touches is declared structurally inside it, so nothing else is installed.

Your SDK key

Set AVSB_SDK_KEY in the project's environment variables on dash.deno.com, using the key for the environment this deployment serves. Keys are shaped sdk_<environment>_<id>, so a production key reads sdk_production_....

Environments is its own item in the sidebar. Click Reveal, then Copy it into AVSB_SDK_KEY on dash.deno.com.
  1. Environments lives in the sidebar on its own, not inside Settings.
  2. Click Reveal to see the full key, then Copy to copy it.
Your SDK key is public
Your SDK key is a public identifier, not a secret: it is safe to ship in browser and mobile bundles, it can only fetch that environment's flag configuration and send events, and it can never read or change anything in your dashboard. Credentials covers all four A vs B credentials and which one to reach for.

Bootstrap the client

TypeScript
// docs-example: not typechecked here, because Deno's global types ship with the// deno CLI rather than from npm, so this repo has nothing to check Deno.openKv,// Deno.env and Deno.serve against.import { createDenoHandler } from '@avsbhq/edge/deno'const kv = await Deno.openKv()Deno.serve(  createDenoHandler({    sdkKey: Deno.env.get('AVSB_SDK_KEY') ?? '',    kv,    handler: async (req, client) => {      const checkout = client.getBoolFlag('checkout_v2', false)      return Response.json({ enabled: checkout.isEnabled() })    },  }),)
TypeScript17 lines

That is the whole wiring. There is no readiness check to write and no client to construct: the factory awaits init() before your handler runs, and awaits client.flushEvents() afterwards.

Deno Deploy has no waitUntil

On Cloudflare the flush is deferred with ctx.waitUntil. Deno Deploy offers no equivalent, so the adapter awaits the flush before returning your response. That is the only way the event batch, the deferred KV write, and the heartbeat actually leave the isolate: an isolate is frozen the moment the response is delivered.

Freshness and retention are two different clocks

cacheTtlMs (default 5 minutes) decides how long a cached datafile counts as fresh. Past it the client revalidates over the network, and if that fails it serves the stale copy with degraded: true rather than nothing. Deno KV keeps an entry until it is overwritten, which is retention, not freshness.

Reading flags

TypeScript
import type { AvsbEdgeClient, Flag } from '@avsbhq/edge'interface CheckoutDecision {  enabled: boolean  theme: string  variationKey: string | null}function decide(client: AvsbEdgeClient): CheckoutDecision {  const checkout: Flag<boolean> = client.getBoolFlag('checkout_v2', false)  const theme: Flag<string> = client.getStringFlag('ui_theme', 'default')  return { enabled: checkout.isEnabled(), theme: theme.value, variationKey: checkout.variationKey }}
TypeScript13 lines

Every typed getter takes the same four arguments, and only the first two are required:

TypeScript
import type { AvsbEdgeClient, EvalContext, Flag } from '@avsbhq/edge'function readMore(client: AvsbEdgeClient, other: EvalContext): void {  const size: Flag<number> = client.getNumberFlag('page_size', 25)  const pricing: Flag<{ tier: string }> = client.getJsonFlag('pricing_config', { tier: 'standard' })  // A context argument evaluates one call against a different identity without  // changing the one the adapter bound to this request.  const forSomeoneElse: Flag<boolean> = client.getBoolFlag('checkout_v2', false, other)  // Bulk read. Fires no exposures by default.  const all: Record<string, Flag> = client.getAllFlags()}
TypeScript13 lines

flag.source is one of rule, holdout, bandit, datafileOverride, runtimeOverride, sticky, default, disabled, not_found, or not_ready. A read before init() finishes reports not_ready, never not_found, and warns once per key, so "the SDK was not ready" is never mistaken for "you typed the flag key wrong". isEnabled() is false for default, disabled, not_found, and not_ready.

A typed getter checks the value against the type the dashboard declares for the flag. On a mismatch it returns your default with source: 'not_found' and logs one line naming both types. It never coerces.

Tracking events

TypeScript
import type { AvsbEdgeClient } from '@avsbhq/edge'function recordPurchase(client: AvsbEdgeClient, orderTotal: number, items: number): void {  client.track('signup_completed')  client.track('purchase', { revenue: orderTotal, value: items })}
TypeScript6 lines

revenue is money in decimal major units of the project currency. value is the numeric metric value an average-value metric averages. They are two separate columns end to end, so one conversion can carry money, a quantity, or both. payload.properties is accepted so the call reads the same across runtimes, but it is not stored on tracked events. The SDK warns once rather than shipping something that looks delivered and is gone. Exposure events (the moment a visitor is counted in an experiment) do carry properties, and they fire automatically: reading a flag whose decision came from an experiment queues one.

Identity

The Deno adapter resolves the visitor from the x-avsb-visitor-id header, then from the A vs B snippet's _avsb_visitor cookie. That way a site running both Web Experiments and this function reports one visitor, and both surfaces join in results. It also sets url, path, host, userAgent, language, referrer, and region from DENO_REGION when the environment is readable.

Deno Deploy exposes no geolocation, and the adapter does not invent one. There are no country or city attributes on the default context. So an exists audience condition can still tell "no geo on this platform" apart from "geo says empty". There are two honest ways to get geo here. Put Deno Deploy behind a CDN that adds geo headers, and read them in your own contextFrom. Or look up the IP yourself.

TypeScript
import { denoContextFrom } from '@avsbhq/edge/deno'import type { EvalContext } from '@avsbhq/edge'function contextFrom(req: Request): EvalContext {  return {    ...denoContextFrom(req),    key: req.headers.get('x-user-id') ?? 'anon',    plan: req.headers.get('x-user-plan') ?? 'free',    // An attribute the platform does not provide is left off rather than    // written as an empty string, so an `exists` audience condition stays honest.    country: req.headers.get('x-country') ?? undefined,  }}
TypeScript13 lines

Pass that as contextFrom on createDenoHandler. Return { kind: 'multi', ... } from it to target on more than one identity at once. There is no identify() at the edge: the context is fixed for the life of one request.

Keeping the datafile fresh

A published change reaches your isolates within the 5 minute freshness window on its own. To make it immediate, register a flag.published webhook under Organization Settings → Integrations → Webhooks and have the receiver refresh the KV entry the adapter reads.

The webhook body carries the project, environment, and who published, not the datafile itself, so the receiver fetches the datafile and writes it under the key the KV adapter looks for:

TypeScript
import { fetchDatafileFromCdn } from '@avsbhq/edge'import type { DenoKV } from '@avsbhq/edge/deno'export async function refreshCachedDatafile(kv: DenoKV, sdkKey: string): Promise<Response> {  const outcome = await fetchDatafileFromCdn(sdkKey)  if (outcome.kind !== 'datafile') {    // 'httpError' carries the status, 'transportError' carries the error, and    // both carry the URL that was tried.    return new Response('datafile unavailable', { status: 502 })  }  await kv.set(['avsb', 'df', sdkKey], { datafile: outcome.datafile, cachedAt: Date.now() })  return new Response('ok')}
TypeScript14 lines

Serve that from a Deno.serve route of its own, and verify the delivery signature before acting on it. See the Webhooks guide.

Graceful shutdown

There is nothing to shut down. Deno Deploy isolates are frozen rather than closed, and an edge client lives for one request with no timers, no sockets, and no global state. createDenoHandler awaits client.flushEvents() before returning your response, which is the only point at which the isolate is still guaranteed to be running.

An invocation ends with its request, so a batch that cannot be delivered is lost rather than retried later. The failure log says exactly that, naming the endpoint, the status, and how many events went with it. One retry inside the request is on by default, and every attempt sends byte-identical event ids so ingestion deduplicates instead of double-counting.

Testing

@avsbhq/test gives you a server-shaped mock whose bound client has the same read surface as the client your handler receives, so the decision logic can be tested with no network and no isolate:

TypeScript
// docs-example: not typechecked here, because Deno's test runner is a global// from the deno CLI rather than from npm, so this repo has nothing to check// Deno.test against.import { createMockServer, flagsFromTestData, TestData } from 'npm:@avsbhq/test'import { assertEquals } from 'jsr:@std/assert'const server = createMockServer(  flagsFromTestData([    TestData.flag('checkout_v2')      .booleanFlag()      .variationForUser('u_1', true)      .fallthroughVariation(false)      .build(),  ]),)Deno.test('serves checkout v2 to u_1', () => {  const visitor = server.forUser({ kind: 'user', key: 'u_1' })  assertEquals(visitor.getBoolFlag('checkout_v2', false).value, true)})Deno.test('serves the fallthrough to everyone else', () => {  const visitor = server.forUser({ kind: 'user', key: 'u_other' })  assertEquals(visitor.getBoolFlag('checkout_v2', false).value, false)})
TypeScript25 lines

To exercise the real evaluator instead of a mock, construct AvsbEdgeClient with a datafile bootstrap and logLevel: 'silent': init() then performs no network call at all.

What's next

Was this helpful?