Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

RFC 0012: Runtime macros

Aperta
#58 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
5/5
Tempo stimato
Più di una settimana
Idoneità per principianti
25/100
Tipo di issue
Funzionalità
Chiarezza
Abbastanza chiara
Stato di attività
Attiva
Stack tecnologico
aws, node.js, typescript

Direzione di ricerca

This is an RFC proposing compiler macros, including $output, $grant, and a provider resource macro; the payload does not identify implementation files or tests. Start by reading RFC 0007 on runtime modules and RFC 0004 on contributions, then inspect the remaining issue text for the proposed compiler and provider changes. Done means implementing and validating the agreed design, which requires settling substantial design and cross-cutting work first.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

enhancement

Abstract

Runtime code depends on infra: it needs values that only exist once infra is deployed, such as a queue's URL, and access that only infra can grant. However, a runtime module is compiled before anything is deployed, and 0007 forbids it from containing infra code, so today these needs are wired by hand, in two places that can drift apart.

We will solve this with macros, which the compiler reads twice: once to compile the function, and once to configure the resource it deploys as. A runtime module then declares everything it needs from infra in its own code, including its resource, the values it reads and the access it requires, while its compiled code stays independent of the environment it's deployed to.

https://github.com/user-attachments/assets/03c273b3-41ea-42c5-b2d2-1133139ccb3e

Video transcript

A Notation app has two kinds of code. Infra code declares the resources to deploy, such as a queue for orders. Runtime code runs inside those resources, such as a Lambda. Since RFC 0007, each runtime module deploys as one resource, and its code never runs at deploy time. Take a checkout module. It deploys as one Lambda, and its handler, create order, sends each new order to the order queue.

To send to the order queue, the checkout handler needs two things from infra. First, a value: the URL of the queue. Second, access: permission to send messages to the queue. Two facts make both hard to get. The compiler builds the checkout code before anything is deployed, so the queue URL does not exist yet. And the checkout module may not contain infra code, so it has no sanctioned way to reach the queue at all.

Problem 1: values. Today you have two options, and both are bad. You can hard-code the URL. Then the code works in one stage only, and breaks when the queue is renamed or replaced. Or you can wire the URL by hand. In infra code, you add an environment variable to the Lambda. In the handler, you read the variable by name. That name is a string, and nothing checks it. Say you rename the variable in infra code, but not in the handler. The build passes, and the deploy passes. Then the handler reads undefined, and every checkout fails.

Problem 2: access. The permission also lives in infra code, as an IAM statement, far from the handler. Nothing connects the statement to the code that needs it. Now say you change the checkout handler, so that it no longer sends to the queue. The IAM statement stays behind. Over time, the Lambda collects permissions for code that no longer exists, and nobody can tell which permissions are still in use.

The fix is macros. A macro is a function that the compiler replaces at each place it is called. The compiler will read each macro call twice. Once when it compiles the function code, and once when it configures the resource that the module deploys as. This is the key point, because one call in the checkout module will produce both the runtime code and the infra config. The two can no longer drift apart.

The first macro is dollar output. Dollar output reads a deployed value. The checkout module will call dollar output with the URL of the order queue. In the function code, the compiler will turn the call into a read of that value at runtime. In the infra phase, the call will pass the URL to the checkout Lambda. The result is typed as a string, so the handler uses the queue URL like any other constant.

The second macro is dollar grant. Dollar grant gives the function access to a resource. The checkout module will call dollar grant with the order queue and the send message action. In the function code, the compiler will remove the call. In the infra phase, the call will add the permission to the role of the checkout Lambda. The actions are typed from the queue, so a typo fails while you edit. And if you delete the grant, the next deploy removes the permission.

The third macro declares the resource itself. In RFC 0007, the resource export is a plain object, and may contain only literals. With this RFC, the checkout module will export a call to dollar lambda, with an ID, a timeout and a memory size. Dollar lambda accepts the same settings as the lambda factory in infra code, and the factory checks them. So every use of infra in a runtime module will go through one mechanism: macros.

Now, the infra phase. As in RFC 0007, the compiler will replace the checkout module with a generated module. The generated module declares the checkout Lambda, with the settings from dollar lambda. Then the generated module hands the Lambda a list of the other macro calls: the grant, and the output. Each entry in the list records the line of the call in the checkout file, so an error points at that line.

Notice that the Lambda receives a function that returns the list, not the list itself. That is because infra modules and runtime modules usually import each other. Say the orders module wires a second Lambda, process orders, to consume the queue. So the orders module imports process orders. And process orders reads the queue URL, so it imports the orders module. In that cycle, the generated module runs before the orders module has declared the queue. The Lambda can be declared, but the queue URL cannot be read yet. So the Lambda will evaluate the list only once infra evaluation finishes.

Once infra evaluation finishes, the Lambda applies each call through contributions. A contribution, from RFC 0004, lets one resource add to the params of another. The output goes into the runtime map of the Lambda. The grant goes into the policy of its role. And the Lambda depends on the policy, so the access is in place before the function runs. On AWS, the runtime map is stored in one environment variable (NOTATION_RUNTIME), and the compiled dollar output reads the queue URL from there.

This matters, because the queue URL is never in the compiled code. The compiled checkout function reads the URL through a key (infra/orders#orderQueue.url): the module that declares the queue, and its export. The key cannot be the ID of the queue, because the function needs the key before infra runs. So the compiled function is the same in every stage. Each provider decides how values reach its functions, such as an environment variable, a platform binding or a secrets store. And a changed output redeploys the configuration of the function, not its code.

How will the compiler know that a call is a macro? A module that imports macro from the Notation resource package declares macros, and its exports whose names start with a dollar sign are the macros. A dollar sign alone never makes a macro, so the dollar from Bun or zx does not clash. Macros must be called directly, never stored or passed around, so the compiler can find every call. And after the compiler rewrites the calls, it checks each finished bundle. Any infra module left in the bundle is an error.

The design has limits. Values arrive at deploy time, so the function sees a changed output only after a redeploy. On AWS, all values share the 4 kilobyte environment limit of a Lambda. Values pass as plain text, so dollar output is not for secrets. Notation does not check that the code performs only granted actions, so a missing grant fails at runtime. And code that calls a macro cannot run outside the compiler, for example in unit tests, without a stand-in.

Before, the checkout handler got the queue URL from a hand-wired environment variable, and its permission from an IAM statement far away in infra code. With this RFC, the checkout module will declare its resource, the values it reads and the access it needs, in its own code. The compiler will read those calls twice, so the function and its config cannot drift apart, and the compiled code will be the same in every stage.

Problem

A runtime module usually depends on infra. A handler that sends to a queue needs the queue's URL, and permission to send to it. Today a developer has two ways to get a value, and both are bad:

  • Hard-code it. The code then depends on one stage, and breaks when the resource is renamed or replaced.
  • Wire it by hand. Add an env var to the function's config, then read process.env.SOMETHING in the code. The name is a string that nothing checks, the config and the code can drift apart, and the developer has to know how the provider passes values in.

Access is worse. The developer has to write an IAM statement in infra code, far from the code that needs it, and nothing connects the two. Removing the send from the handler leaves the permission behind.

Two things make this hard to fix:

  1. Order. A function's code is compiled before anything is deployed. The values it needs don't exist yet.
  2. Separation. 0007 makes sure runtime-module code never runs in the infra phase. Without macros, a runtime module has no sanctioned way to reach infra at all.

0007 also gives runtime modules a second, unrelated way to describe infra: a resource export that must contain only literals.

Proposed solution

  1. Macros. A macro is a function the compiler replaces at each call site. It has two sides: what the call becomes in the function code, and what it adds to the runtime module's resource in the infra phase.
  2. $output(ref) reads a deployed value, e.g. orderQueue.url. In the function code, the call becomes a read of that value at runtime. In the infra phase, the value is passed to the resource.
  3. $grant(resource, actions) gives the function access to an infra resource, e.g. $grant(orderQueue, ["SendMessage"]). In the function code, the call is removed. In the infra phase, the access is attached to the resource.
  4. resource is a macro. The resource export from 0007 is defined by a provider macro, e.g. $lambda({ id: "checkout", timeout: 5 }), instead of a literal object. Every use of infra in a runtime module then goes through one mechanism.
  5. Values come from outside the code. The compiled function is the same in every stage. Outputs reach it through the runtime environment, at deploy time.
  6. The provider decides how. Each provider decides how values reach its functions, such as an env var, a platform binding or a secrets store, and how access is attached. The macros don't expose the mechanism.
// runtime/checkout.ts
import { $lambda, $output, $grant, handle } from "@notation/aws/lambda.fn";
import { orderQueue } from "infra/orders";

$grant(orderQueue, ["SendMessage"]);
const queueUrl = $output(orderQueue.url);

export const createOrder = handle.apiRequest(async (req) => {
  await sqs.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: req.body }));
  return json({ ok: true });
});

export const resource = $lambda({ id: "checkout", timeout: 5, memory: 64 });

queueUrl is typed as the output's type and holds the deployed value when the function runs. The actions accepted by $grant are typed from the resource.

In the infra phase, the generated resource module from 0007 declares the Lambda with the module's settings when it's imported, and hands it the other macro calls:

// generated in place of runtime/checkout.ts during the infra phase
import { lambda } from "@notation/aws/lambda";
import * as orders from "infra/orders";

const fn = lambda({ id: "checkout", timeout: 5, memory: 64, code, handler: "handler", at: "runtime/checkout.ts:13" });

fn.applyMacros(() => [
  { kind: "grant", resource: orders.orderQueue, actions: ["SendMessage"], at: "runtime/checkout.ts:4" },
  { kind: "output", key: "infra/orders#orderQueue.url", value: orders.orderQueue.url, at: "runtime/checkout.ts:5" },
]);

export const createOrder = { resource: fn, export: "createOrder" };

Once infra evaluation finishes, the Lambda evaluates the list and applies each call through contributions. The output goes into the function's Runtime map, the grant into its role's policy, and the function depends on the policy, so the access is in place before the function:

// inside the Lambda's applyMacros, once infra evaluation finishes
this.fn.contribute({ Environment: { Runtime: { outputs: { "infra/orders#orderQueue.url": orderQueue.url } } } });
this.policy.contribute({ PolicyDocument: { Statement: [orderQueue.grant(["SendMessage"])] } });
this.fn.dependOn(this.policy);

On AWS, 0007 encodes the Runtime map into NOTATION_RUNTIME, and the compiled $output call reads its key from there.

The calls wait for the end of evaluation because infra and runtime modules normally import each other. Infra imports a runtime module to wire it, as in orderQueue.consume(processOrders), and that runtime module reads the queue it's wired to with $output(orderQueue.url). In that module cycle, the generated module runs before the infra module has declared orderQueue, so it can declare the Lambda but can't read infra values yet.

Rules
  • Macros are called directly. A macro can't be aliased, stored or passed as a value, so the compiler can find every call site. It can be called in any module a runtime module bundles, at any depth.
  • Arguments are literals or references to infra imports. They are copied into the generated resource module, which contains no runtime code, so they can't reference anything local to the runtime module.
  • Infra imports are only for macros. An infra import is one that resolves, directly or transitively, to a Notation infra package. A local module that imports nothing from Notation is shared code.
  • Plan checks what types can't. A value passed to $output that isn't an output fails at plan, pointing at the call, before anything deploys.
  • Access is in place before the function, and removing a $grant removes the access on the next deploy.
Output keys

An output's key names the module that declares the value and its export, following re-exports, e.g. infra/orders#orderQueue.url. Importing the same queue through a re-export gives the same key. The key can't use the resource ID, because the compiled function needs it before infra runs.

$grant
  • It only allows. A deny would be a separate macro.
  • Its actions are whatever the resource's grant accepts, and may be none.
  • What grant returns is the provider's own form, e.g. an IAM statement on AWS.
  • Granting across providers is out of scope. If the function's resource doesn't recognise a grant, plan fails, naming the call.
$lambda settings

A resource macro's settings are its factory's config type, so $lambda accepts exactly what lambda accepts, minus what the compiler sets. They are validated by the factory's value types when the resource is declared, and errors carry the $lambda(...) call site. The compiler keeps no list of settings of its own.

Compilation

There is one build. Runtime bundles are built first, and the macro calls are collected from them. Then infra is compiled, with each runtime module replaced by its generated resource module.

The infra-import check runs on each finished function bundle, after macro calls are rewritten, their imports removed and tree-shaking done. Any infra module left in the bundle is an error. A syntax check of the imports only supplies the location for the error.

Finding macros
  • A module that imports macro from @notation/resource contains macros, and its $-prefixed exports are the macros.
  • $output and $grant are fixed. Any other $<name> is a resource macro. Its infra half is the same specifier without .fn, and its factory is <name>: $lambda from @notation/aws/lambda.fn is declared by lambda from @notation/aws/lambda.
  • Only package specifiers are checked, never relative ones. Each unique specifier is resolved and peeked once per build, following re-exports with a depth bound and a cycle guard.
  • A $ name alone never makes a macro, so $ from Bun or zx doesn't clash.

Providers must:

  • ship ESM with @notation/resource left external;
  • import macro by name.

A macro that isn't compiled, such as one from a provider that breaks these rules, throws "only callable in code compiled by Notation" when it's called.

Provider interface

@notation/resource defines the interface both halves of a provider implement. The runtime-safe part is imported by the function bundle:

// @notation/resource — runtime-safe
/** Declares a macro. With `rewritten`, a compiled call becomes `rewritten(key)`;
 *  without it, the compiler removes the call. An uncompiled call throws. */
export function macro<F extends (...args: any[]) => any>(
  rewritten?: (key: string) => ReturnType<F>,
): F;

/** A resource `$grant` can give access to. `grant` only ever allows. */
export interface Grantable<Actions = unknown> {
  grant(actions: Actions): unknown; // the provider's own form, e.g. an IAM statement
}
export type GrantActions<R> = R extends Grantable<infer A> ? A : never;

/** What a resource macro returns, so `export const resource = $lambda(...)` type-checks.
 *  Opaque: the compiler removes the call, so there is no value at runtime. */
export interface RuntimeResourceDeclaration {
  readonly kind: "runtime-resource";
}

The infra side is what the generated resource module calls:

// @notation/resource — infra side
export type RuntimeMacro =
  | { kind: "output"; key: string; value: OutputValue<unknown>; at: string }
  | { kind: "grant"; resource: Grantable; actions: unknown; at: string };

export interface RuntimeResource {
  /** `macros` is evaluated once infra evaluation finishes. */
  applyMacros(macros: () => RuntimeMacro[]): void;
}

/** `code` and `handler` are set by the compiler; `at` is the `$<name>(...)` call site. */
export type RuntimeResourceFactory<Settings extends { id: string }> = (
  config: Settings & { code: CodeRef; handler: string; at: string },
) => RuntimeResource;

export type RuntimeSettings<F> = F extends RuntimeResourceFactory<infer S> ? S : never;

A provider's runtime half exports $output, $grant, its resource macro and dispatch. Its infra half exports the factory. On AWS:

// @notation/aws/lambda.fn — runtime half; marked because it imports `macro`
import { macro, type OutputValue, type Grantable, type GrantActions, type RuntimeResourceDeclaration } from "@notation/resource";
import type { lambda } from "@notation/aws/lambda"; // type-only, erased from the bundle

export const $output = macro<<T>(ref: OutputValue<T>) => T>((key) => readRuntime().outputs[key]);
export const $grant  = macro<<R extends Grantable>(resource: R, actions: GrantActions<R>) => void>();
export const $lambda = macro<(settings: RuntimeSettings<typeof lambda>) => RuntimeResourceDeclaration>();
export { dispatch } from "./dispatch";

// @notation/aws/lambda — infra half: the specifier without `.fn`, factory named after `$lambda`
export const lambda: RuntimeResourceFactory<LambdaSettings> = (config) => new LambdaGroup(config);

Rationale

The values a function needs and the access it requires are facts about its code, so they belong in its code. Reading the same calls in both compiler passes is what keeps the function and its configuration from drifting apart, and keeping the mechanism inside the provider is what keeps the compiled code independent of any stage.

Marking macro modules by their import of macro needs no config, so nothing can disagree with the code. Checking the finished bundle, rather than the source, catches infra reached through any path.

Consequences

  • Runtime code reads deployed values with a typed call instead of a hand-wired env var.
  • Access is declared next to the code that needs it, and goes away with it.
  • Compiled functions don't depend on any stage. Changing an output redeploys the function's configuration, not its code.
  • The build can tell exactly what a runtime module depends on.
  • 0007's literal resource export is replaced by the resource macro.
  • Any package can provide macros by implementing the interface in @notation/resource.

Limits

  • Values are delivered at deploy time. A function sees a changed output only after it's redeployed.
  • On AWS, all of a function's values share Lambda's 4 KB environment limit.
  • Values pass in plain text, so $output isn't suitable for secrets.
  • Notation doesn't check that the code only performs granted actions. A missing grant fails at runtime with the provider's access error.
  • The resource macro's settings, such as $lambda({ timeout }), are needed when the resource is declared. In a module cycle they can't refer to the infra module that imports the runtime module.
  • Code that calls a macro can't run outside the compiler, such as in unit tests, without a stand-in.
  • Finding macros adds about 55–140 ms to a cold build at 1,000 files. A disk cache is future work.

Out of scope

  • Secrets: delivering sensitive outputs through a secrets store.
  • Lazy values: looking values up at runtime, which would resolve cycles.
  • Cross-provider grants: granting a function on one provider access to a resource on another.
  • Ready-made clients: macros such as $sqsClient(orderQueue, ["SendMessage"]) that combine $output and $grant and return a client limited to the granted actions.
Lingua principale
TypeScript
Stelle
54
Fork
1
Merge medio
5h 28m
PR unite (30g)
5

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di notationlabs/notation

Tutte le issue di notationlabs/notation

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.