Skip to content
Draft
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/feat-http-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tailor-platform/sdk": minor
---

Add `defineHttpAdapter()` for declaring HTTP adapters that translate HTTP requests into GraphQL queries and reshape the responses. Adapter files are discovered via the new `httpAdapter.files` glob in `defineConfig()`. At deploy time the `input`/`output` functions are bundled and embedded as gateway filters on the application. Requires the per-workspace feature flag `20260413_platform_filter_router` to be enabled before adapter routes serve traffic.
62 changes: 62 additions & 0 deletions example/adapters/get-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { defineHttpAdapter } from "@tailor-platform/sdk";

function escapeXml(value: unknown): string {
if (value === null || value === undefined) return "";
const str =
typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? String(value)
: JSON.stringify(value);
return str
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

export default defineHttpAdapter({
name: "get-user",
pathPattern: "/users/*",
methods: ["GET"],
input: (req) => {
const segments = req.path.split("/").filter(Boolean);
const id = segments[segments.length - 1] ?? "";
return {
query: `query GetUser($id: ID!) {
user(id: $id) {
id
name
email
role
status
}
}`,
variables: { id },
};
},
output: (resp) => {
const data = resp.data as { user: Record<string, unknown> | null } | null | undefined;
const user = data?.user;
if (!user) {
return {
statusCode: 404,
headers: { "content-type": "application/xml; charset=utf-8" },
body: `<?xml version="1.0" encoding="UTF-8"?>\n<error>user not found</error>`,
};
}
const xml =
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<user>` +
`<id>${escapeXml(user.id)}</id>` +
`<name>${escapeXml(user.name)}</name>` +
`<email>${escapeXml(user.email)}</email>` +
`<role>${escapeXml(user.role)}</role>` +
`<status>${escapeXml(user.status)}</status>` +
`</user>`;
return {
statusCode: 200,
headers: { "content-type": "application/xml; charset=utf-8" },
body: xml,
};
},
});
71 changes: 71 additions & 0 deletions example/adapters/whoami.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { defineHttpAdapter } from "@tailor-platform/sdk";

function escapeXml(value: unknown): string {
if (value === null || value === undefined) return "";
const str =
typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? String(value)
: JSON.stringify(value);
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

function actorXml(tag: string, actor: Record<string, unknown> | undefined): string {
if (!actor) return `<${tag} />`;
return (
`<${tag}>` +
`<id>${escapeXml(actor.id)}</id>` +
`<type>${escapeXml(actor.type)}</type>` +
`<role>${escapeXml(actor.role)}</role>` +
`</${tag}>`
);
}

export default defineHttpAdapter({
name: "whoami",
pathPattern: "/whoami",
methods: ["GET"],
input: () => ({
query: `query Whoami {
showUserInfo {
user {
id
type
role
}
invoker {
id
type
role
}
}
}`,
}),
output: (resp) => {
const data = resp.data as
| {
showUserInfo?: {
user?: Record<string, unknown>;
invoker?: Record<string, unknown>;
};
}
| null
| undefined;
const info = data?.showUserInfo;
const xml =
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<whoami>` +
actorXml("user", info?.user) +
actorXml("invoker", info?.invoker) +
`</whoami>`;
return {
statusCode: 200,
headers: { "content-type": "application/xml; charset=utf-8" },
body: xml,
};
},
});
3 changes: 3 additions & 0 deletions example/tailor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ export default defineConfig({
workflow: {
files: ["./workflows/**/*.ts"],
},
httpAdapter: {
files: ["./adapters/**/*.ts"],
},
staticWebsites: [website, erdSite],
});

Expand Down
115 changes: 115 additions & 0 deletions packages/sdk/docs/services/http-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# HTTP Adapter
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sobek runtime

Does this mean it is running in a runtime that is different from both the tailordb hook/validate runtime and the function runtime?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — HTTP adapter scripts run in a separate runtime from both the TailorDB hook/validate runtime and the function runtime. It is a Go-based JavaScript runtime (Sobek), and there are restrictions on imports and host APIs as a result.


HTTP adapters expose REST-style HTTP endpoints on your application's gateway by translating each request into a GraphQL query and (optionally) reshaping the GraphQL response back into an HTTP response.

## Overview

Each HTTP adapter is a single file that declares:

- The HTTP `pathPattern` and `methods` it handles
- An `input` function that converts an incoming HTTP request into a GraphQL request (`query`, `variables`, `operationName`)
- An optional `output` function that converts the GraphQL response into an HTTP response (`statusCode`, `headers`, `body`)

At deploy time the SDK bundles each `input` and `output` function into a standalone JS script that runs in the gateway's sandboxed runtime when a matching request hits `/f/<pathPattern>`.

## Requirements

- Each adapter file must call `defineHttpAdapter` exactly once and `export default` the result
- `name` must be a string literal that matches `^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$` and be unique across all adapters
- `input` and `output` must be inline arrow or `function` expressions (not references to functions defined elsewhere)
- `input` and `output` **must be synchronous** — `async`/`await` and top-level `await` are not supported by the gateway runtime

## Runtime Constraints

Adapter scripts are bundled to an ES2017 IIFE and executed in the gateway's sandboxed Sobek runtime. The following are **not** available:

- Node built-in modules (`fs`, `path`, `crypto`, `http`, etc.) — rejected at build time
- `async`/`await` and top-level `await` — rejected at build time
- `fetch`, `setTimeout`, `setInterval`, and other browser/host globals
- Any third-party libraries that depend on the above
Comment thread
Mistat marked this conversation as resolved.

Each bundled script is capped at 256 KB (with a warning at 64 KB).

## Activation

HTTP adapters are gated by a per-workspace feature flag (`20260413_platform_filter_router`). Until the flag is enabled for a workspace, requests to `/f/<path>` return `404`. Contact your platform admin to enable adapters in your environment.

## Configuration

Add an `httpAdapter` entry to `defineConfig`:

```typescript
// tailor.config.ts
import { defineConfig } from "@tailor-platform/sdk";

export default defineConfig({
name: "my-app",
httpAdapter: {
files: ["adapters/**/*.ts"],
},
});
```

## Defining an Adapter

```typescript
// adapters/get-user.ts
import { defineHttpAdapter } from "@tailor-platform/sdk";

export default defineHttpAdapter({
name: "get-user",
pathPattern: "/users/*",
methods: ["GET"],
input: (req) => ({
query: `query GetUser($id: ID!) { user(id: $id) { id name email } }`,
variables: { id: req.path.split("/")[2] },
}),
output: (resp) => ({
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify(resp.data?.user ?? null),
}),
});
```

A request to `GET /f/users/abc-123` will invoke `input(req)`, execute the resulting GraphQL query against your application's GraphQL endpoint (with the caller's auth context preserved), then invoke `output(resp)` to produce the HTTP response.

If `output` is omitted, the raw GraphQL response is returned as JSON.

## Path Pattern

- Literal segments must match exactly: `/users/list` matches only `/users/list`
- A `*` in the middle matches exactly one segment: `/api/*/users` matches `/api/v1/users`
- A trailing `*` matches all remaining segments: `/api/*` matches `/api/v1/users/123`

## Type Reference

```typescript
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD";

type HttpAdapterRequest = {
method: HttpMethod;
path: string;
headers: Record<string, string>;
query: Record<string, string>;
body: string;
};

type HttpAdapterInputResult = {
query: string;
variables?: Record<string, unknown>;
operationName?: string;
};

type HttpAdapterGraphQLResponse = {
data?: unknown;
errors?: unknown;
extensions?: unknown;
};

type HttpAdapterOutputResult = {
statusCode?: number;
headers?: Record<string, string>;
body: string;
};
```
8 changes: 7 additions & 1 deletion packages/sdk/src/cli/cache/bundle-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import { hashContent, hashFile, hashFiles } from "./hasher";
import type { CacheStore } from "./store";
import type { Plugin } from "rolldown";

type BundleKind = "resolver" | "executor" | "workflow-job" | "auth-hook";
type BundleKind =
| "resolver"
| "executor"
| "workflow-job"
| "auth-hook"
| "http-adapter-input"
| "http-adapter-output";

type BundleCacheRestoreParams = {
kind: BundleKind;
Expand Down
Loading
Loading