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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ the implementation rather than preserving the mismatch.
uses `routes` for nesting.
3. Publish API routes only with `src/apis/**/api.*` or `src/apis/**/http.*`,
and keep exactly one supported anchor file in each route directory. `api.*` exports
uppercase HTTP methods; `http.*` exports named `{ method, path?, handler }` domain
operations with a directory URL prefix. Put global middleware in `src/middlewares/middleware.*`,
uppercase HTTP methods; `http.*` exports named `http.get/post/...` bindings
(or existing `{ method, path?, handler }` domain operations) with a directory URL prefix. Put global middleware in `src/middlewares/middleware.*`,
default-exporting one handler or an ordered non-empty array. Compose HTTP
method policies with `withMiddlewares(handler, middlewares)` from
`@evjs/ev/api`; share chains through ordinary imports. `RouteHandlerFn`
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ Release automation replaces them with the release version before publishing.

1. Create the URL directory below `src/apis`.
2. Add one `api.*` or `http.*` anchor with a `ts`, `tsx`, `js`, or `jsx` extension.
3. Export uppercase HTTP method handlers.
3. In `api.*`, export uppercase HTTP method handlers; in `http.*`, export named
business operations bound with `http.get/post/...` from `@evjs/ev/api`.
4. Put helpers and shared middleware chains in ordinary colocated modules.
5. Compose individual methods with `withMiddlewares(handler, middlewares)`
from `@evjs/ev/api`.
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Paths are relative to the project root unless stated otherwise.
| `<page>/index.html` | HTML template for one MPA page | Does not create a page or client entry. |
| `index.html` or `routing.html` | Shared application HTML template | `index.html` is the default. |
| Imported module starting with `"use server";` | Server-function module | Named callable exports only; no required directory. |
| `src/apis/**/{api,http}.{ts,tsx,js,jsx}` | Public HTTP route | Exactly one anchor per directory. `api.*` exports uppercase handlers; `http.*` exports named `{ method, path?, handler }` operations. Compose policies with `withMiddlewares(handler, middlewares)`. |
| `src/apis/**/{api,http}.{ts,tsx,js,jsx}` | Public HTTP route | Exactly one anchor per directory. `api.*` exports uppercase handlers; `http.*` exports named `http.get/post/...` bindings or `{ method, path?, handler }` operations. Compose policies with `withMiddlewares(handler, middlewares)`. |
| Other files inside an API route directory | Route-owned source | Helpers and `index.*` do not create endpoints. |
| `src/middlewares/middleware.*` | Global middleware composition | Default-export one middleware or an explicitly ordered non-empty list. Computed global chains may resolve to `[]` when disabled. |
| Other files in `src/middlewares` | Middleware implementation modules | Imported explicitly; filenames do not define order. |
Expand Down Expand Up @@ -256,4 +256,4 @@ export const GET = {
};
```

`src/apis/**/http.*` groups named `{ method, path?, handler }` operation descriptors. Export names express business operations, such as `listOrders` or `createOrder`; uppercase HTTP verbs such as `GET` and `POST` are rejected as export names. Its directory supplies a URL prefix, and each operation supplies a path suffix (default `/`). Multiple operations may share one file and one URL when their HTTP methods differ. Middleware and runtime execution use the same chain as `api.*`. Keep exactly one `api.*` or `http.*` anchor per route directory. Both feed the same development server, route types, HTTP client metadata, and production build graph. Framework plugins may use the filename to distinguish deployment policies.
`src/apis/**/http.*` groups named `http.get/post/...` bindings from `@evjs/ev/api`; existing `{ method, path?, handler }` operation descriptors are also supported. Export names express business operations, such as `listOrders` or `createOrder`; uppercase HTTP verbs such as `GET` and `POST` are rejected as export names. Its directory supplies a URL prefix, and each operation supplies a path suffix (default `/`). Multiple operations may share one file and one URL when their HTTP methods differ. Middleware and runtime execution use the same chain as `api.*`. Keep exactly one `api.*` or `http.*` anchor per route directory. Both feed the same development server, route types, HTTP client metadata, and production build graph. Framework plugins may use the filename to distinguish deployment policies.
33 changes: 14 additions & 19 deletions docs/docs/server-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,27 +312,22 @@ short-circuit derived `HEAD`, whose final response is always bodyless.

## Domain HTTP operations

`http.*` groups named operation descriptors in one domain module. Export names express business operations, such as `listOrders` or `createOrder`; uppercase HTTP verbs such as `GET` and `POST` are rejected as export names. Each export declares an uppercase `method` and an optional `path` suffix; the directory supplies the URL prefix. A missing suffix maps to the domain root. Handler functions, middleware, streaming, HEAD and OPTIONS use the same HTTP runtime as `api.*`.
`http.*` groups named operations in one domain module. Use business export names such as `listOrders` or `createOrder`, and select the HTTP method with `http.get`, `http.post`, `http.put`, `http.patch`, `http.delete`, `http.head`, or `http.options` from `@evjs/ev/api`. The directory supplies the URL prefix; an optional static `path` adds a suffix.

```ts
// src/apis/orders/http.ts
import type { HttpOperation } from "@evjs/ev/api";

export const listOrders = {
method: "GET",
handler: () => Response.json([]),
} satisfies HttpOperation;
export const createOrder = {
method: "POST",
async handler(request: Request) {
return Response.json(await request.json());
},
} satisfies HttpOperation;
export const getOrder = {
method: "GET",
path: "/:id",
handler: (_request, ctx) => Response.json({ id: ctx.req.param("id") }),
} satisfies HttpOperation<"/:id">;
import { http } from "@evjs/ev/api";

export const listOrders = http.get(() => Response.json([]));
export const createOrder = http.post(async (request) => {
return Response.json(await request.json());
});
export const getOrder = http.get(
(_request, ctx) => Response.json({ id: ctx.req.param("id") }),
{ path: "/:id" },
);
```

These exports publish GET /orders, POST /orders, and GET /orders/:id. Method and path metadata must be static string literals; duplicate methods at the same URL are rejected. Keep one api.* or http.* anchor per directory. Framework plugins own deployment metadata; source-file grouping does not prescribe a deployment resource boundary.
These exports publish GET /orders, POST /orders, and GET /orders/:id. Helpers return the existing handler descriptor and preserve the standard Request/Response and middleware runtime, including streaming. Method metadata comes from the helper and must not be repeated in options. Named and namespace import aliases are supported; routing options must be a static object with a literal path.

Existing `{ method, path?, handler }` declarations remain supported. Duplicate methods at the same URL are rejected. Keep one api.* or http.* anchor per directory. Framework plugins own deployment metadata; source-file grouping does not prescribe a deployment resource boundary.
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ src/pages/orders/$orderId/
| `<页面>/index.html` | 单个 MPA 页面的 HTML 模板 | 不创建页面或客户端入口。 |
| `index.html` 或 `routing.html` | 共享应用 HTML 模板 | 默认使用 `index.html`。 |
| 以 `"use server";` 开头且被应用导入的模块 | 服务端函数模块 | 只能命名导出可调用值,不要求固定目录。 |
| `src/apis/**/{api,http}.{ts,tsx,js,jsx}` | 公共 HTTP 路由 | 每个目录一个锚点;`api.*` 导出大写方法,`http.*` 导出具名 `{ method, path?, handler }` 操作;策略使用 `withMiddlewares(handler, middlewares)`。 |
| `src/apis/**/{api,http}.{ts,tsx,js,jsx}` | 公共 HTTP 路由 | 每个目录一个锚点;`api.*` 导出大写方法,`http.*` 导出具名 `http.get/post/...` 绑定或 `{ method, path?, handler }` 操作;策略使用 `withMiddlewares(handler, middlewares)`。 |
| API 路由目录中的其他文件 | 路由拥有的源码 | 辅助文件和 `index.*` 不创建端点。 |
| `src/middlewares/middleware.*` | 全局中间件组合 | 默认导出一个中间件或显式排序的非空列表;动态计算的全局链可以在禁用时返回 `[]`。 |
| `src/middlewares` 中的其他文件 | 中间件实现模块 | 显式导入,文件名不决定顺序。 |
Expand Down Expand Up @@ -230,4 +230,4 @@ export const GET = {
};
```

`src/apis/**/http.*` 用多个具名 `{ method, path?, handler }` 描述对象组织领域操作。目录提供 URL 前缀,每个操作声明路径后缀(默认 `/`);同一文件可发布多个路径,同一路径可声明不同 HTTP 动词。中间件和运行时复用 `api.*` 的执行链。每个路由目录只保留一个 `api.*` 或 `http.*` 锚点。两者进入同一开发服务器、路由类型、HTTP 客户端元信息及生产构建图,框架插件可以根据文件名区分部署策略。导出名使用 `listOrders`、`createOrder` 等业务名称,不接受 `GET`、`POST` 等大写 HTTP 动词作为导出名。
`src/apis/**/http.*` 用来自 `@evjs/ev/api` 的具名 `http.get/post/...` 绑定组织领域操作,也支持已有 `{ method, path?, handler }` 描述对象。目录提供 URL 前缀,每个操作声明路径后缀(默认 `/`);同一文件可发布多个路径,同一路径可声明不同 HTTP 动词。中间件和运行时复用 `api.*` 的执行链。每个路由目录只保留一个 `api.*` 或 `http.*` 锚点。两者进入同一开发服务器、路由类型、HTTP 客户端元信息及生产构建图,框架插件可以根据文件名区分部署策略。导出名使用 `listOrders`、`createOrder` 等业务名称,不接受 `GET`、`POST` 等大写 HTTP 动词作为导出名。
Original file line number Diff line number Diff line change
Expand Up @@ -273,12 +273,23 @@ Hono 的错误处理行为:异常转成错误响应,中间件退出时可通

## HTTP 路由锚点别名

可以使用 `http.ts`、`http.tsx`、`http.js` 或 `http.jsx`,它们与 `api.*` 复用同一编译器和运行时,目录提供 URL 前缀,http.* 的具名操作使用 method/path 声明各自的路由。每个路由目录只能保留其中一个锚点,同时声明两者会报重复路由错误。其他同目录源码仍为普通私有模块。
可以使用 `http.ts`、`http.tsx`、`http.js` 或 `http.jsx`,它们与 `api.*` 复用同一编译器和运行时,目录提供 URL 前缀,http.* 的具名操作通过 HTTP helper 和可选 path 声明各自的路由。每个路由目录只能保留其中一个锚点,同时声明两者会报重复路由错误。其他同目录源码仍为普通私有模块。

## 领域 HTTP 操作

`http.*` 在同一领域文件中导出多个具名描述对象,每个导出显式声明大写 `method`,可选 `path` 为目录路径下的后缀。省略 path 时对应领域根路径。例如 orders/http.ts 中 listOrders 的 GET 对应 /orders,createOrder 的 POST 也对应 /orders,getOrder 的 GET 和 path: '/:id' 对应 /orders/:id。导出名使用 `listOrders`、`createOrder` 等业务名称,不接受 `GET`、`POST` 等大写 HTTP 动词作为导出名。
`http.*` 在同一领域文件中导出多个具名操作。导出名使用 `listOrders`、`createOrder` 等业务名称,不接受大写 HTTP 动词。使用 `@evjs/ev/api` 的 `http.get/post/put/patch/delete/head/options` 选择方法,目录提供 URL 前缀,可选 `path` 指定后缀。

`satisfies HttpOperation`(从 `@evjs/ev/api` 导入)提供编写提示;带动态后缀时使用 `HttpOperation<"/:id">`。
```ts
import { http } from "@evjs/ev/api";

export const listOrders = http.get(() => Response.json([]));
export const createOrder = http.post(async (request) => Response.json(await request.json()));
export const getOrder = http.get(
(_request, ctx) => Response.json({ id: ctx.req.param("id") }),
{ path: "/:id" },
);
```

helper 只返回现有 handler 描述对象,不包装 Request/Response,也不改变 middleware、SSE、HEAD 和 OPTIONS 的执行。HTTP 方法来自 helper,options 中不再声明 method。支持具名导入别名和 namespace 访问;options 必须是静态对象,path 必须是字符串字面量。

method/path 必须是静态字符串,不通过业务函数体推断。相同 URL 下可声明不同动词,重复动词会报错。handler、中间件、SSE、HEAD 和 OPTIONS 复用既有 HTTP 执行链。每个目录保留一个 api.* 或 http.* 锚点,部署策略和资源拆分由框架插件管理。
已有 `{ method, path?, handler }` 声明仍可使用。相同 URL 下重复动词会报错,每个目录保留一个 api.* 或 http.* 锚点,部署策略和资源拆分仍由框架插件管理。
106 changes: 97 additions & 9 deletions packages/ev/src/_internal/build/analysis/http-operations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { HTTP_METHODS } from "@evjs/shared";
import type { Module } from "@swc/types";
import type { Expression, Module, ObjectExpression } from "@swc/types";
import { resolveHttpOperationPath } from "../conventions/http-operation-path.js";
import type {
DiscoveredServerRouteNode,
Expand Down Expand Up @@ -29,6 +29,8 @@ export function analyzeHttpOperations(
>();
const exports = collectModuleExportNames(ast.body);
const definitions = new Set<string>();
const helperBindings = collectHttpBindings(ast);
const helperHandlers = new Map<string, Expression>();
const fail = (message: string) =>
diagnostics.push({ level: "error", file, message });
if (hasDefaultExport(ast)) fail("http.* requires named operation exports.");
Expand All @@ -49,18 +51,41 @@ export function analyzeHttpOperations(
);
continue;
}
const object = unwrapExpression(declaration.init);
if (object.type !== "ObjectExpression") {
const value = unwrapExpression(declaration.init);
const helper = readHttpBinding(value, helperBindings);
let object: ObjectExpression | undefined;
let method: string | undefined;
if (helper) {
const args = helper.call.arguments;
const handler = args[0];
if (!handler || args.length > 2 || args.some((arg) => arg.spread)) {
fail(
`HTTP operation "${name}" requires a handler and optional static options.`,
);
continue;
}
helperHandlers.set(name, unwrapExpression(handler.expression));
method = helper.method;
if (args[1]) {
const options = unwrapExpression(args[1].expression);
if (options.type !== "ObjectExpression") {
fail(`HTTP operation "${name}" requires a static options object.`);
continue;
}
object = options;
}
} else if (value.type === "ObjectExpression") {
object = value;
} else {
fail(
`HTTP operation "${name}" requires a static { method, path?, handler } object.`,
`HTTP operation "${name}" requires http.get/post/put/patch/delete/head/options(handler, options?) from @evjs/ev/api or a static { method, path?, handler } object.`,
);
continue;
}
let method: string | undefined;
let suffix = "/";
const fields = new Set<string>();
const errorsBefore = diagnostics.length;
for (const property of object.properties) {
for (const property of object?.properties ?? []) {
if (property.type === "SpreadElement") {
fail(`HTTP operation "${name}" cannot spread routing metadata.`);
continue;
Expand All @@ -71,6 +96,12 @@ export function analyzeHttpOperations(
continue;
}
if (key.value !== "method" && key.value !== "path") continue;
if (helper && key.value === "method") {
fail(
`HTTP operation "${name}" gets its method from http.${helper.method.toLowerCase()}(); omit method from options.`,
);
continue;
}
if (fields.has(key.value))
fail(`HTTP operation "${name}" repeats "${key.value}".`);
fields.add(key.value);
Expand Down Expand Up @@ -133,9 +164,11 @@ export function analyzeHttpOperations(
}
if (!definitions.size)
fail("http.* requires at least one named operation descriptor.");
for (const message of validateServerRouteMethodExports(ast.body, [
...definitions,
]))
for (const message of validateServerRouteMethodExports(
ast.body,
[...definitions],
helperHandlers,
))
fail(message);
for (const route of routes.values()) {
route.methods = HTTP_METHODS.filter((method) =>
Expand All @@ -145,3 +178,58 @@ export function analyzeHttpOperations(
}
return { routes: [...routes.values()], diagnostics };
}

interface HttpBindings {
named: Set<string>;
namespaces: Set<string>;
}

function collectHttpBindings(ast: Module): HttpBindings {
const bindings: HttpBindings = { named: new Set(), namespaces: new Set() };
for (const item of ast.body) {
if (
item.type !== "ImportDeclaration" ||
item.typeOnly ||
!["@evjs/ev/api", "@alipay/evjs/api"].includes(item.source.value)
)
continue;
for (const specifier of item.specifiers) {
if (specifier.type === "ImportNamespaceSpecifier") {
bindings.namespaces.add(specifier.local.value);
} else if (
specifier.type === "ImportSpecifier" &&
!specifier.isTypeOnly &&
(specifier.imported?.value ?? specifier.local.value) === "http"
) {
bindings.named.add(specifier.local.value);
}
}
}
return bindings;
}

function readHttpBinding(value: Expression, bindings: HttpBindings) {
if (
value.type !== "CallExpression" ||
value.callee.type !== "MemberExpression"
)
return;
const callee = value.callee;
if (callee.property.type !== "Identifier") return;
const method = callee.property.value.toUpperCase();
if (
callee.property.value !== method.toLowerCase() ||
!HTTP_METHODS.some((candidate) => candidate === method)
)
return;
const receiver = unwrapExpression(callee.object);
const known =
receiver.type === "Identifier"
? bindings.named.has(receiver.value)
: receiver.type === "MemberExpression" &&
receiver.property.type === "Identifier" &&
receiver.property.value === "http" &&
receiver.object.type === "Identifier" &&
bindings.namespaces.has(receiver.object.value);
if (known) return { method, call: value };
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type MethodExportTarget =
export function validateServerRouteMethodExports(
body: ModuleItem[],
methods: readonly string[],
handlers?: ReadonlyMap<string, Expression>,
): string[] {
const methodSet = new Set<string>(methods);
const targets = collectMethodExportTargets(body, methodSet);
Expand All @@ -44,8 +45,10 @@ export function validateServerRouteMethodExports(
}

const [target] = methodTargets;
const resolution =
target?.kind === "local"
const handler = handlers?.get(method);
const resolution = handler
? resolveExpression(handler, locals, new Set(), false)
: target?.kind === "local"
? resolveLocalBinding(target.localName, locals, new Set())
: "unknown";
const diagnostic = formatCallableDiagnostic(method, resolution);
Expand Down
25 changes: 25 additions & 0 deletions packages/ev/src/api/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { RouteHandlerFn } from "@evjs/server";
import type { HttpMethod } from "@evjs/shared";
import type { Env, Input } from "hono";
import type { BlankInput } from "hono/types";

function bindHttpMethod<Method extends HttpMethod>(method: Method) {
return function bind<
Path extends string = string,
E extends Env = Env,
I extends Input = BlankInput,
>(handler: RouteHandlerFn<Path, E, I>, options?: { path?: Path }) {
return { ...options, method, handler };
};
}

/** Bind a named HTTP operation without wrapping its standard Request/Response handler. */
export const http = {
get: bindHttpMethod("GET"),
post: bindHttpMethod("POST"),
put: bindHttpMethod("PUT"),
patch: bindHttpMethod("PATCH"),
delete: bindHttpMethod("DELETE"),
head: bindHttpMethod("HEAD"),
options: bindHttpMethod("OPTIONS"),
};
Loading
Loading