RFC 0007: Runtime modules
評価
- 難易度
- 5/5
- 見積もり時間
- 1週間以上
- 初心者へのやさしさ
- 25/100
- issue の種類
- 機能追加
- 明瞭さ
- おおむね明確
- 活発さ
- 活発
- 技術スタック
- aws, node.js, typescript
調査の方向性
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.
索引モデルが issue の本文から書いたものです。
説明
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
- Globs. Users configure where runtime modules live with
runtimeModulesinnotation.config.ts(0005), e.g.**/runtime/**or**/*.fn.ts. The default is**/*.fn.ts, which keeps existing projects working. - A
resourceexport. A module is a runtime module only if it matches a glob and exportsresource. That export defines the resource, andresource.serviceselects its type:"aws/lambda"now,"cloudflare/worker"later. A module that matches a glob without exportingresourceis an ordinary module: runtime modules can import it as shared code, and it's never deployed by itself.resourcereplaces today'sconfigexport. - One module, one resource. A runtime module may have several exports, but it deploys as a single resource.
- An explicit ID. Every runtime module's resource has an
id, which names the resource independently of the module's path. - 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.
- 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 plainstringis 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-zipand 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 }, wherematchis 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.
configis renamed toresource. A module that still exportsconfigmust 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
- 主要言語
- TypeScript
- スター
- 54
- フォーク
- 1
- 平均マージ
- 13時間 18分
- マージ済み PR(30日)
- 7
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートなし
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
notationlabs/notation のほかの issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
notationlabs/notation#48 ·
-
enhancement
難易度 5/5 1週間以上 初心者へのやさしさ 25/100
notationlabs/notation#58 ·
-
enhancement
難易度 5/5 1週間以上 初心者へのやさしさ 25/100
notationlabs/notation#57 ·
-
enhancement
難易度 5/5 1週間以上 初心者へのやさしさ 25/100
notationlabs/notation#56 ·
-
enhancement
難易度 5/5 1週間以上 初心者へのやさしさ 25/100
notationlabs/notation#55 ·
notationlabs/notation の issue をすべて見る
似ている issue
-
normalizePath removes the Windows device namespace prefix対応中かも このイシューにリンクされたプルリクエストがオープン中、またはマージ済みです。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
caprover/caprover-frontend#273 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
ai-driven-dev/framework#1147 ·
メンテナーはふだん 1 日以内に返信
-
agent-ui bug p2
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 62/100
cybersemics/em#6232 ·
メンテナーはふだん 1 日以内に返信