Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-workers-validate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@offering-protocol/core": patch
---

Precompile bundled ODP validators so document validation works in runtimes that prohibit dynamic JavaScript code generation, including Cloudflare Workers.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ dist/
coverage/
.conformance/
.turbo/
.generated/
.wrangler/
docs/
*.tsbuildinfo

Expand Down
11 changes: 11 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ surface of every package. Scope an individual task with a Turbo filter when iter
pnpm turbo run typecheck --filter=@offering-protocol/core
```

## Core validators

Core's `generate` command compiles the JSON Schemas in `packages/core/src/schemas` into
`packages/core/.generated`. Build, test, lint, typecheck, and TypeDoc commands run generation when
needed. Generated files are untracked and are bundled into the published JavaScript; consumers do
not run the generator. Edit the schemas or generation script, not the generated output.

Core tests compare generated validators with Ajv and check that they preserve results and errors.
Publication checks load both ESM and CommonJS with string-based code generation disabled. The
Cloudflare example tests run the built Service in `workerd`, with startup code generation disabled.

## Dependency updates

Refresh dependencies throughout the workspace to the newest versions allowed by their declared
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ pnpm add @offering-protocol/directory

Use `npm install` or `yarn add` if those are the package managers in your application.

## Cloudflare Workers

To host an ODP catalog on Workers, start with the
[Cloudflare Service example](./examples/odp-service-cloudflare/README.md). It includes a runnable
Worker, the tested compatibility settings, and local tests. You do not need a Cloudflare account
to run it locally.

`core` ships precompiled validators: importing the package and validating ODP documents does not
generate JavaScript or download schemas. The Service package uses those validators, and the
Directory client uses the runtime's `fetch` API.

The Agent package has additional requirements and does not have full Workers support. A
compatibility date alone does not solve its transport and runtime-schema limitations; see
[Agent on Workers](./packages/agent/README.md#cloudflare-workers). These limitations concern code
executing inside Workers, not a Node.js application behind Cloudflare's proxy or firewall.

## Agent Workflow

For general Directory discovery, use [`directory.search()`](./packages/directory/README.md#search-the-directory)
Expand Down
6 changes: 4 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
The examples exercise the published package APIs rather than importing package internals.

- `odp-service-small` demonstrates configuration-first integration for a small catalog.
- [`odp-service-cloudflare`](./odp-service-cloudflare/README.md) hosts a public catalog in a
Cloudflare Worker, with local tests and deployment instructions.
- `odp-service-marketplace` demonstrates storage-style handlers and bounded virtual scale.
- `odp-agent-discovery` builds an explicit mock directory from reachable configured Services and
walks through their discovery responses.
- `odp-service-aep-mpp` exposes an ODP Action that requires AEP authentication followed by an MPP
payment.
- `odp-service-x402` exposes an ODP Action protected by x402.

Each runnable package includes `.env.example`. Copy it to `.env` to make local configuration
explicit; `.env` remains untracked.
The Node server examples include `.env.example`. Copy it to `.env` to make local configuration
explicit; `.env` remains untracked. The Cloudflare example uses `wrangler.json` and needs no secrets.

Build and exercise the complete flow with:

Expand Down
91 changes: 91 additions & 0 deletions examples/odp-service-cloudflare/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# ODP Service on Cloudflare Workers

Publish an ODP catalog from a Cloudflare Worker using `@offering-protocol/service`. This example
offers one free incident-response template. It serves the Service Document, Offering list, Offering
details, and the advertised download Action.

## Run locally

You need Node.js 22 or newer and pnpm to run the development tools. No Cloudflare account is needed
for local testing. From the repository root:

```sh
pnpm install
pnpm --filter @offering-protocol/service... build
pnpm --filter @offering-protocol/example-service-cloudflare dev
```

Wrangler prints the local address, normally `http://localhost:8787`. Try:

```sh
curl http://localhost:8787/.well-known/odp
curl http://localhost:8787/odp/offerings
curl http://localhost:8787/odp/offerings/incident-plan
curl http://localhost:8787/downloads/incident-plan.txt
```

Edit `src/index.ts` to change the catalog. For a database-backed Service, replace the two catalog
handlers with your application's queries. The ODP handler validates requests and responses and
returns the appropriate HTTP status and headers.

## Use it in your Worker

Install `@offering-protocol/service` in your application, copy the integration from `src/index.ts`,
and configure Wrangler:

```json
{
"main": "src/index.ts",
"compatibility_date": "2025-06-01",
"compatibility_flags": ["nodejs_compat", "disallow_eval_during_startup"]
}
```

`2025-06-01` is the compatibility date tested by this example. Node compatibility provides the URL,
Buffer, and cryptographic APIs used by the SDK. The explicit `disallow_eval_during_startup` flag
demonstrates that ODP's bundled validators do not need permission to generate JavaScript at runtime;
the flag is not required by ODP. See Cloudflare's
[Node compatibility guidance](https://developers.cloudflare.com/workers/runtime-apis/nodejs/) when
choosing a later date for an existing application.

The SDK ships its compiled ODP validators in the npm package. You do not need to generate them or
download schemas when your Worker starts.

This example has no accounts, payments, database, or secrets. Those remain application choices;
adding an ODP catalog does not implement authentication or payments.

If you replace the catalog handlers with `createStaticCatalog`, initialize that helper inside the
Worker request handler and pass a stable `continuationKey` from a Worker secret. Its default random
key cannot be generated during module initialization. Using the same secret across Worker instances
also lets pagination continue when requests reach different instances.

## Test and deploy

From the repository root, build the SDK packages and run the local Worker tests:

```sh
pnpm --filter @offering-protocol/example-service-cloudflare... build
pnpm --filter @offering-protocol/example-service-cloudflare test
```

The tests use Cloudflare's local runtime and do not deploy a Worker. They cover discovery, Offering
retrieval, validation errors, conditional requests, and the download Action. The repository's
`pnpm verify` command also runs them. A Directory compatibility test uses Workers' native `fetch`
with a local response fixture; it does not contact the live Directory.

To deploy, first choose your Worker name in `wrangler.json`. Then authenticate to your Cloudflare
account and publish:

```sh
pnpm --filter @offering-protocol/example-service-cloudflare exec wrangler login
pnpm --filter @offering-protocol/example-service-cloudflare deploy
```

## Calling other Services from Workers

Hosting this catalog does not require the Agent package. If your Worker also needs directory
search, use `@offering-protocol/directory` and start requests inside your request handler.

The full `@offering-protocol/agent` package has additional transport and schema-compilation
requirements. It is not covered by this Service example. Read its
[Workers limitations](../../packages/agent/README.md#cloudflare-workers) before using it in a Worker.
21 changes: 21 additions & 0 deletions examples/odp-service-cloudflare/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@offering-protocol/example-service-cloudflare",
"private": true,
"type": "module",
"scripts": {
"build": "wrangler deploy --dry-run --outdir dist",
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"lint": "eslint src --max-warnings 0",
"test": "pnpm build && node --test test/*.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@offering-protocol/service": "workspace:^"
},
"devDependencies": {
"@offering-protocol/directory": "workspace:^",
"miniflare": "5.20260921.0-alpha",
"wrangler": "4.136.0"
}
}
63 changes: 63 additions & 0 deletions examples/odp-service-cloudflare/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { createOdpService } from "@offering-protocol/service";

const offering = {
odp_version: "1.0" as const,
id: "incident-plan",
name: "Incident Response Plan",
description: "A downloadable incident-response planning template.",
price: { type: "free" as const },
actions: [
{
authentication: "not-required" as const,
id: "download",
rel: "download" as const,
http: {
method: "GET" as const,
href: "/downloads/incident-plan.txt",
response_content_types: ["text/plain"]
}
}
]
};

const service = createOdpService({
document: {
name: "Cloudflare Example Store",
description: "A public ODP catalog served by a Cloudflare Worker.",
language: "en",
localizations: ["en"],
http: { endpoint_base: "/odp" }
},
catalog: {
listOfferings: (request) => ({
odp_version: "1.0",
items: [representOffering(request.representation)]
}),
getOffering: (id, request) =>
id === offering.id ? representOffering(request.representation) : undefined
}
});

function representOffering(representation: "terse" | "full") {
if (representation === "full") return offering;
return {
odp_version: offering.odp_version,
id: offering.id,
name: offering.name,
price: offering.price
};
}

export default {
fetch(request: Request): Promise<Response> | Response {
if (new URL(request.url).pathname === "/downloads/incident-plan.txt") {
if (request.method !== "GET" && request.method !== "HEAD") {
return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });
}
return new Response(request.method === "HEAD" ? null : "Incident Response Plan\n", {
headers: { "content-type": "text/plain" }
});
}
return service.fetch(request);
}
};
12 changes: 12 additions & 0 deletions examples/odp-service-cloudflare/test/directory-worker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createDirectoryClient } from "@offering-protocol/directory";

export default {
async fetch() {
const directory = createDirectoryClient();
const pages = [];
for await (const page of directory.searchServices({ query: "templates" }).pages) {
pages.push(page);
}
return Response.json(pages);
}
};
Loading
Loading