RFC 0007: Runtime modules

Đang mở
#54 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

@djgrant đang làm issue này rồi.

Từ ngày 2/11/2026.

  • #64 của @djgrant — đang mở

Đánh giá

Độ khó
5/5
Thời gian dự kiến
Hơn một tuần
Mức phù hợp với người mới
25/100
Loại issue
Tính năng
Độ rõ ràng
Khá rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
aws, node.js, typescript
Lĩnh vực
backend, infrastructure

Hướng nghiên cứu

This issue is an RFC for changing runtime module discovery, compilation, resource creation, and dispatch; it does not name implementation files or tests. Start by reading the compiler and infra plugin entry points, then trace how runtime modules are discovered and compiled. Done means the proposed behavior is implemented and covered for resource exports, stable IDs, generated resources, and handler dispatch.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

enhancement

Abstract

Notation deploys code from .fn.ts modules, but it turns each handler export into its own Lambda. A module with several handlers therefore deploys several Lambdas that share one config, ship identical bundles, and would each receive the same grants. The .fn convention is also hard-coded, and runtime modules can contain infra statements that run during the infra phase.

We will solve this by making the module the unit of deployment. A runtime module is one that matches a configured glob and exports resource, which defines the resource it deploys as. Each runtime module deploys as exactly one resource. In the infra phase, the compiler replaces the module with a generated resource module, so none of its code is evaluated.

https://github.com/user-attachments/assets/b44a1d79-3f6e-42a0-b47b-b37a1ea2ae1a

Video transcript

A Notation app has two kinds of code. Infra code declares the resources to deploy. Runtime code runs inside those resources, for example in an AWS Lambda. Runtime code lives in runtime modules: files whose names end in dot fn dot ts. Take a todos module. When the module starts, it fetches the list of todos. It exports two handlers: get todos, and create todo. And it exports a config object, with the service, an ID, a timeout and a memory size. The infra file imports both handlers, and routes GET and POST requests for slash todos to them.

When you deploy, the compiler turns each handler export into its own Lambda. So the todos module deploys as two Lambdas: one for get todos, and one for create todo. Each Lambda has its own role and its own log group. But the config belongs to the module, and so does the bundle. Both Lambdas run the same bundle: the whole todos module.

Problem 1. Each Lambda gets the whole module. So when either Lambda starts cold, it runs the start-up code of the module, and fetches the todos. And each Lambda ships the dependencies of every handler. Now take this further. A module with ten handlers deploys ten Lambdas, ten roles and ten log groups, all with the same code. And it gets worse once runtime modules can bind resources, such as a database table. Say only create todo writes to the table. The grant would belong to the module, so every one of the ten Lambdas would get write access to the table.

Problem 2. The dot fn suffix is hard-coded, in two places. The CLI looks for dot fn dot ts files inside the runtime folder. The infra plugin of the compiler matches any path that contains dot fn. The two rules can disagree, and you cannot configure either of them. So you cannot choose another convention, such as every file in a runtime folder.

Problem 3. Runtime modules can also contain infra code. When the infra file imports the todos module, the compiler keeps each statement that uses only infra imports, and runs it in the infra phase: the step at deploy time that evaluates infra code. The compiler drops every other statement. Say the todos module logs the name of the todo API, which it imports from the infra folder. That line uses only an infra import, so it runs at deploy time, not in the Lambda. So the compiler picks, statement by statement, which lines run at deploy time, and nothing in the file shows you which lines those are.

All three problems have one cause. The module is already the unit of config, and the unit of bundling, but the compiler deploys each export. This RFC will make the module the unit of deployment too: each runtime module will deploy as exactly one resource. This is the key point of the RFC, because the config, the bundle, and the grants will then all belong to one resource.

First, you will choose where runtime modules live. The runtime modules setting in notation config dot ts will take a glob, such as every file in a runtime folder. The default will be every file that ends in dot fn dot ts, so existing projects keep working. But a match alone will not be enough. A module will be a runtime module only if it matches the glob and exports resource. The resource export will replace the config export, and its service field will select the type, such as AWS Lambda. Now take a utils module in the same folder. The utils module matches the glob, but has no resource export, so it will stay an ordinary module that runtime modules import as shared code. This matters, because runtime code and its shared code can then live side by side.

Next, the infra phase. When the infra file imports the todos module, the compiler will not evaluate the todos module at all. Instead, the compiler will substitute a generated module. The generated module creates one Lambda, with the ID todos, and the timeout and memory from the resource export. For each handler, the generated module exports a reference: the Lambda, plus the name of the export to call. The router will receive these references. So the code of a runtime module will never run at deploy time. A runtime module that needs infra will reach it through macros, which RFC 0012 defines.

The ID todos will name the Lambda, and the resources derived from the Lambda: todos role, todos zip, and so on. Every runtime module will need an ID. The ID goes into generated names, so it must be name-safe. A type will check the ID while you edit, and the type accepts only literal strings. A second resource with the same ID will fail. And because the ID does not come from the file path, you can move the todos module without replacing its Lambda.

One problem remains. A Lambda has a single entry point, but the todos Lambda will serve two handlers. Requests for both routes reach the Lambda in the same shape, so the Lambda cannot tell from a request which handler to call. Only infra knows which route goes to which export. So infra will pass that mapping to the Lambda, as a dispatch map. Each route will add one entry: the export to call, and the route to match. The match will use the route key and the ID of the API, not its ARN, because the ARN exists only after deploy. Notation will store the map in one environment variable on the Lambda (NOTATION_RUNTIME). A schedule will need no entry: the schedule rule will put the export name in the event itself.

This change has costs. Lambda IDs will change from one per export to one per module, so existing stacks will replace their Lambdas on the next deploy. A module that still exports config will fail with a migration message, rather than run as ordinary code at deploy time. Handlers in one module will share one timeout and one memory size, so a handler that needs more memory goes in its own module. And every event will pay for the start-up code of the whole module, as Cloudflare Workers already do.

Before, the todos module deployed as one Lambda per handler, each with the whole bundle, and each Lambda would get every grant. The dot fn suffix was hard-coded, and the compiler picked which runtime lines to run at deploy time. With this RFC, each runtime module will deploy as one resource, with one bundle and one set of grants. You will choose where runtime modules live, and runtime code will never run at deploy time.

Problem

.fn.ts modules mix two models. The suffix and the config export apply to the module, but the compiler emits one Lambda per export, each with its own role and log group. Every Lambda points at the same bundle, the whole module. Each one runs every export's init code at cold start, ships every export's dependencies, and gets the module's config. Once runtime modules can bind resources, each one would also get every grant.

The convention lives in two places that can disagree: the CLI and the infra plugin each match .fn files their own way. Neither can be configured.

Runtime modules can also contain infra code. The compiler keeps every statement that references only infra imports and evaluates it in the infra phase. Which runtime-module code runs at infra time is decided implicitly, statement by statement.

Proposed solution

  1. Globs. Users configure where runtime modules live with runtimeModules in notation.config.ts (0005), e.g. **/runtime/** or **/*.fn.ts. The default is **/*.fn.ts, which keeps existing projects working.
  2. A resource export. A module is a runtime module only if it matches a glob and exports resource. That export defines the resource, and resource.service selects its type: "aws/lambda" now, "cloudflare/worker" later. A module that matches a glob without exporting resource is an ordinary module: runtime modules can import it as shared code, and it's never deployed by itself. resource replaces today's config export.
  3. One module, one resource. A runtime module may have several exports, but it deploys as a single resource.
  4. An explicit ID. Every runtime module's resource has an id, which names the resource independently of the module's path.
  5. Replaced during the infra phase. When infra imports a runtime module, the compiler substitutes a generated resource module. The original module is never evaluated at infra time.
  6. No infra code in runtime modules. Runtime modules can reach infra only through macros, defined in 0012.
// runtime/todos.ts: a runtime module, because it exports `resource`
import { fetchTodos } from "./utils";

const todos = await fetchTodos();

export const getTodos = handle.apiRequest(() => json(todos));
export const createTodo = handle.apiRequest(async (req) => json(await save(req)));
export const resource: LambdaResource = { service: "aws/lambda", id: "todos", timeout: 5, memory: 64 };
// runtime/utils.ts: matches the glob but has no `resource` export, so it's an ordinary module
export const fetchTodos = () => fetch("https://example.com/todos").then((r) => r.json());

In the infra phase, runtime/todos.ts is replaced by a module that creates one resource and exports a reference for each runtime export:

const fn = lambda({ id: "todos", code: { type: "file", path: "dist/runtime/todos/index.mjs" }, timeout: 5, memory: 64 });

export const getTodos = { resource: fn, export: "getTodos" };
export const createTodo = { resource: fn, export: "createTodo" };

Infra APIs such as route() and schedule() receive a reference: the module's resource, plus the name of the export to invoke.

TypeScript types an import from the original file, so handle.* returns Handler & RuntimeRef. Infra code sees .resource without a cast, and runtime code never uses it.

IDs

id is required in every runtime module. Once resource is a macro (0012), that is $lambda({ id }). lambda({ id }) is already required in infra code.

  • Name-safe. Generated names embed the ID, so it must be name-safe. The NameSafe<S> type checks it, and accepts literals only: a value typed as plain string is rejected. A runtime check backs this up.
  • Unique. A duplicate ID fails when the second resource is declared.
  • Stable. The resource IDs derived from it, todos, todos-role, todos-zip and so on, never change when the module moves.
Dispatch

A Lambda has a single entry point, so the compiled module needs one that sends each event to the right export. Events of different types can be told apart by their shape. Several exports of the same type, such as two API handlers on different routes, can't: only infra knows which route goes to which export, so infra must pass that mapping to the function.

Cloudflare Workers already work this way: one script, with all its handlers on the default export.

  • The dispatch map sends events to exports. It is a collected map (0004), keyed by the ID of the binding that adds the entry. Each entry is { export, match }, where match is the route key plus API ID for API routes, and the queue ARN for SQS queues. The key can't be the ARN itself: that is an output value, unknown during evaluation.
  • Schedules need no entry. The rule depends on the function, so the function can't hold the rule's ARN without a cycle. Instead, the rule's target input is { export: "<name>" }, and keeps the event time. The function reads the export from the event. The dispatch map is only for sources that can't attach a payload, such as API routes and SQS queues.

On AWS, the function's Environment carries a structured Runtime: { dispatch, outputs } beside Variables, where outputs holds the values from 0012. create and update encode it with the codec from 0004 into one variable, NOTATION_RUNTIME, and read decodes it. The variable name is the constant runtimeVariable = "NOTATION_RUNTIME" in aws.iac/shared, an entry point that doesn't import the AWS SDK. It can't go in @notation/aws/shared: @notation/aws depends on aws.iac, so that would create a dependency cycle.

Rationale

The module is already the unit of bundling and config, so making it the unit of deployment removes the mismatch instead of working around it. Requiring an explicit resource export, rather than relying on the glob alone, lets runtime code and the shared code it imports live side by side.

Consequences

  • One resource definition, one bundle and one set of grants per runtime module.
  • Runtime-module code never runs in the infra phase.
  • Lambda IDs change from per export to per module, so existing stacks replace their Lambdas on their next deploy.
  • Moving a runtime module doesn't replace its resources.
  • Lambda and Cloudflare Workers share one model.
  • config is renamed to resource. A module that still exports config must fail with a migration message, not be silently treated as ordinary code that runs at infra time.

Limits

  • Exports in one module can't have different resource settings, such as different memory or timeout values. Code that needs different settings goes in separate modules.
  • Every event pays for the whole module's cold-start init, as Workers already do.

Out of scope

  • Macros: how runtime modules reach infra. See 0012.
  • Contributions: how several sources, such as routes, add to one resource's configuration. See 0004.
Ngôn ngữ chính
TypeScript
Star
54
Fork
1
Merge trung bình
13 giờ 18 phút
Pull request đã merge (30 ngày)
7

Chuẩn bị môi trường

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của notationlabs/notation

Tất cả issue của notationlabs/notation

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.