Add a serve module so a function is one function - #32
Conversation
d0cfc0d to
30d74fc
Compare
|
Follow-up review addressed: Fixed here — the Documented and pinned here — 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:
So |
30d74fc to
b5e9777
Compare
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>
|
Stack trace on a bad flag is gone. The other two failure modes come out equally cleanly, and each keeps node's own diagnosis rather than a generic one:
The message building is split into an exported |
b5e9777 to
c60674c
Compare
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.tsof flag parsing, logger construction, server startup and signal handlingthat the author never edits. A Python function, by comparison, is a single
def compose(req, rsp).This adds a
servemodule:serve()to run a function, and aComposeFunctionshape forauthors who do not need the full
FunctionHandlerinterface.Before — 123 lines across two files:
After — 16 lines:
Design notes
serve()accepts aFunctionHandlerjust as happily as aComposeFunction, soexisting functions are unaffected. Nothing is deprecated here.
ComposeFunctionreturns its response. It receives one already built from the request, sothere is no
to(req)at the top, and returns the response to send. The response is aconvenience 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.)
ComposeResponsenarrowsdesiredto non-optional.to()always populates it, but thegenerated protobuf type is
State | undefined, so without this every author writesrsp.desired!.resources[...].node:util's built-inparseArgsrather thancommander, so nothing is added to every function image. It also lets the CLI's template dropcommander, which it currently pulls in only to parse these same four flags.are keyed by it (
Record<keyof typeof flags, string>), so adding a flag without describing it isa compile error and the help cannot drift from what parses.
parseArgsandhelpTextare exported so a function needing extra flags of its own canstill reuse the standard ones.
Testing
26 new unit tests covering flag parsing (both
--flag valueand--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
FunctionRunnercan turn them into a fatal result.Verified end to end against a real generated function: rewrote a
crossplane function generatescaffold 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: