Skip to content

Add a serve module so a function is one function - #32

Merged
stevendborrelli merged 1 commit into
mainfrom
serve-module
Aug 28, 2026
Merged

Add a serve module so a function is one function#32
stevendborrelli merged 1 commit into
mainfrom
serve-module

Conversation

@stevendborrelli

@stevendborrelli stevendborrelli commented Aug 28, 2026

Copy link
Copy Markdown
Member

Description of your changes

Writing a composition function currently means implementing an interface on a class and
hand-assembling a gRPC server. The scaffold the Crossplane CLI generates is 123 lines, 77 of
which are a main.ts of flag parsing, logger construction, server startup and signal handling
that the author never edits. A Python function, by comparison, is a single def compose(req, rsp).

This adds a serve module: serve() to run a function, and a ComposeFunction shape for
authors who do not need the full FunctionHandler interface.

Before — 123 lines across two files:

// src/main.ts — 77 lines of commander, pino, gRPC and SIGINT handling
// src/function.ts
export class Function implements FunctionHandler {
  async RunFunction(req: RunFunctionRequest, logger?: Logger): Promise<RunFunctionResponse> {
    let rsp = to(req);
    const desiredComposed = getDesiredComposedResources(req);
    // ...
    rsp = setDesiredComposedResources(rsp, desiredComposed);
    return rsp;
  }
}

After — 16 lines:

// src/main.ts
#!/usr/bin/env node
import { serve } from '@crossplane-org/function-sdk-typescript';
import { compose } from './function.js';

serve(compose, { name: 'fn1' });
// src/function.ts
import { normal, type ComposeFunction } from '@crossplane-org/function-sdk-typescript';

export const compose: ComposeFunction = (req, rsp, logger) => {
  rsp.desired.resources['vpc'] = Resource.fromJSON({ resource: vpc.toJSON() });
  normal(rsp, 'Function completed successfully');
  return rsp;
};

Design notes

  • Additive. serve() accepts a FunctionHandler just as happily as a ComposeFunction, so
    existing functions are unaffected. Nothing is deprecated here.
  • ComposeFunction returns its response. It receives one already built from the request, so
    there is no to(req) at the top, and returns the response to send. The response is a
    convenience rather than an out parameter — fill it in and return it, or ignore it and return one
    you built yourself. Returning is required, so forgetting is a compile error rather than an empty
    response at runtime. (The Python SDK mutates an out parameter; that reads as un-idiomatic in
    TypeScript, so this deliberately diverges.)
  • ComposeResponse narrows desired to non-optional. to() always populates it, but the
    generated protobuf type is State | undefined, so without this every author writes
    rsp.desired!.resources[...].
  • No new dependency. Flags are parsed with node:util's built-in parseArgs rather than
    commander, so nothing is added to every function image. It also lets the CLI's template drop
    commander, which it currently pulls in only to parse these same four flags.
  • One flag table. The parser and the help text are both derived from it, and the descriptions
    are keyed by it (Record<keyof typeof flags, string>), so adding a flag without describing it is
    a compile error and the help cannot drift from what parses.
  • parseArgs and helpText are exported so a function needing extra flags of its own can
    still reuse the standard ones.

Testing

26 new unit tests covering flag parsing (both --flag value and --flag=value, short forms,
unrecognised flags, missing values), help text, and the compose adapter — including that desired
state accumulated by earlier pipeline functions is preserved, and that errors propagate so
FunctionRunner can turn them into a fatal result.

Verified end to end against a real generated function: rewrote a crossplane function generate
scaffold to use serve, confirmed 123 → 16 lines, then ran it — the server starts, listens,
serves --help, and shuts down cleanly on SIGTERM with exit 0.

Follow-up, not in this PR: updating the CLI's TypeScript template to generate this shape
(crossplane/cli#170).

I have:

@stevendborrelli

Copy link
Copy Markdown
Member Author

Follow-up review addressed:

Fixed here — the name default now comes from the basename of the running script, so an unnamed serve(compose) reports Usage: main.js [flags] rather than Usage: function [flags].

Documented and pinned herersp.desired aliasing req.desired. ComposeFunction's docs now spell out that writing through rsp.desired.resources also changes req.desired.resources, and a test pins it so changing the behaviour has to be deliberate. I did not change it: copying is a behavioural change that belongs with the wider decision below.

Split out to #33 — the mutate-versus-return inconsistency. Worth noting it is messier than it first looks; I measured every helper and none of them returns a new object, they all return the one passed in:

helper declared actual
fatal RunFunctionResponse mutates, returns the same object
normal (void) mutates, returns void
warning (void) mutates, returns void
setDesiredCompositeStatus RunFunctionResponse mutates, returns the same object
setDesiredComposedResources RunFunctionResponse mutates, returns the same object
setContextKey RunFunctionResponse mutates, returns the same object

So fatal disagrees with normal and warning — it is not a clean split along results-helpers versus setters. Fixing it is breaking either way, which is why it is a separate issue rather than more scope here.

Writing a composition function currently means implementing an interface on a
class and hand-assembling a gRPC server. The scaffold the Crossplane CLI
generates is 123 lines, 77 of which are a main.ts of flag parsing, logger
construction, server startup and signal handling that the author never edits.
By comparison a Python function is a single `def compose(req, rsp)`.

Add `serve()`, which does all of that, and a `ComposeFunction` shape for
authors who do not need the full interface:

    #!/usr/bin/env node
    import { serve } from '@crossplane-org/function-sdk-typescript';
    import { compose } from './function.js';

    serve(compose, { name: 'fn1' });

That takes the same scaffold to 16 lines, and the author writes one function
instead of a class.

`ComposeFunction` receives a response already built from the request, so there
is no `to(req)` at the top, and returns the response to send. The response is a
convenience rather than an out parameter: fill it in and return it, or ignore
it and return one you built yourself. Returning is required, so forgetting is a
compile error rather than an empty response at runtime.

It is handed a `ComposeResponse`, which narrows `desired` to non-optional —
`to()` always populates it, but the protobuf type cannot say so, and without
the narrowing every author writes `rsp.desired!`.

This is additive. `serve()` accepts a FunctionHandler just as happily as a
ComposeFunction, so existing functions are unaffected.

Flags are parsed with node:util's parseArgs rather than commander, so nothing
is added to every function image. It also means the CLI's template can drop
commander, which it currently pulls in only to parse these same four flags.

The flag table is the single source of truth: the parser and the help text are
both derived from it, and the descriptions are keyed by it, so a flag cannot be
added to one and forgotten in the other.

The --help program name defaults to the basename of the running script rather
than the literal "function", so a function started as `node dist/main.js`
reports `Usage: main.js`.

`ComposeFunction` documents, and a test pins, that `rsp.desired` aliases
`req.desired` when the request already carries desired state. That is
inherited from to() and is left as it is here; changing it is discussed in #33
along with the response helpers' inconsistent mutate-versus-return contract,
which ComposeFunction makes harder to live with.

A bad flag prints a usage error rather than a stack trace. node:util throws a
TypeError whose message is exactly what the user needs, but left uncaught it
reaches the top level and Node prints it under fifteen lines of frames through
its own internals. serve catches it, writes `<name>: <message>` and a pointer
to --help on stderr, and exits 2.

Signed-off-by: Steven Borrelli <steve@borrelli.org>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stevendborrelli

Copy link
Copy Markdown
Member Author

Stack trace on a bad flag is gone. serve now catches the parse error, writes the message plus a hint to stderr, and exits 2:

$ node dist/main.js --nope
fn1: Unknown option '--nope'
Try 'fn1 --help' for the available flags.
$ echo $?
2

The other two failure modes come out equally cleanly, and each keeps node's own diagnosis rather than a generic one:

$ node dist/main.js --address
fn1: Option '--address <value>' argument missing

$ node dist/main.js --debug=false
fn1: Option '-d, --debug' does not take an argument

parseArgs itself still throws — it is a library function and a caller may want the error. Only serve, which owns the entrypoint, catches.

The message building is split into an exported usageErrorText(name, error) so it is unit testable without spawning a process; three tests cover it, including one asserting no at frames leak in. Verified against a built function that errors go to stderr with stdout empty, that --help still exits 0, and that normal startup and SIGTERM shutdown are unaffected.

@stevendborrelli
stevendborrelli merged commit 4f7d325 into main Aug 28, 2026
7 checks passed
@stevendborrelli
stevendborrelli deleted the serve-module branch August 28, 2026 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant