Skip to content

Use the SDK's serve() so the function is one function - #23

Merged
stevendborrelli merged 6 commits into
upbound:mainfrom
stevendborrelli:use-sdk-serve-function
Aug 29, 2026
Merged

stevendborrelli merged 6 commits into
upbound:mainfrom
stevendborrelli:use-sdk-serve-function

Conversation

@stevendborrelli

@stevendborrelli stevendborrelli commented Aug 29, 2026 •

Copy link
Copy Markdown
Member

Description of your changes

The TypeScript function SDK grew a serve module in v0.7.0, so a composition function no longer has to hand-assemble its own gRPC server. This adopts it, and picks up two other SDK/codegen affordances the function was not using.

Use serve() for the entrypoint

src/main.ts drops from 85 lines to 6. The commander flag parsing, pino logger construction, server startup and signal handling were scaffolded boilerplate that nobody had edited since:

#!/usr/bin/env node

import { serve } from '@crossplane-org/function-sdk-typescript';
import { compose } from './function.js';

serve(compose, { name: 'configuration-aws-network' });

src/function.ts becomes a ComposeFunction instead of a class implementing FunctionHandler. serve() hands it a response already built from the request, so to(req) goes away, and ComposeResponse narrows desired to non-optional so resources are written straight to rsp.desired.resources with no !. The getDesiredComposedResources() / setDesiredComposedResources() round trip is redundant once you hold that map directly, since to() aliases it rather than copying.

commander and pino are no longer direct dependencies.

Take the XR status type from the generated model

XRStatus was hand-written as { [key: string]: string | string[] }, restating the status block in apis/network/definition.yaml and accepting any field name. It now comes from the model the CLI already generates:

type XRStatus = NonNullable<INetwork['status']>;

The status is built by assignment rather than by spreading cond && { field }, because object spreads are not excess-property checked — with the spread form a misspelt field still compiled, making the precise type worthless at the one place it mattered. Now xrStatus.vpcID = vpcId fails with Property 'vpcID' does not exist ... Did you mean 'vpcId'?.

Use fromModel() for composed resources

Ten call sites of

desiredComposed['igw'] = Resource.fromJSON({ resource: igw.toJSON() });

become desiredComposed['igw'] = fromModel(igw). validate() stays a separate explicit call — no SDK helper validates, and since every value read off the XR is any at runtime, it is the only thing standing between a missing field and a malformed resource reaching the API server.

Docs

The README never said how to get a crossplane CLI with crossplane/cli#170, which is a prerequisite for everything in the Development section — added the clone and build steps. Also refreshed the "Updating the Function" example, which showed the superseded pattern.

Testing

Verified behaviour-preserving, not merely green:

  • The previous Function class and the new compose produce identical responses across every fixture in test/test-cases/, checked by running both against the same inputs and asserting deep equality. This also confirms the SDK change is additive — the old class still compiles and runs unmodified on 0.7.0.
  • crossplane render output is byte-identical before and after every commit here.
  • Resource.fromJSON and fromModel were compared directly: identical protobuf encodings, so the refactor is a true drop-in.
  • Existing suite passes unchanged: 18/18. build, lint and format:check clean.
  • Manually confirmed --help renders and the server shuts down cleanly on SIGTERM with exit 0.
  • The documented CLI build was run rather than transcribed from CI — it succeeds on Go 1.26.2. Note a source-built CLI reports an empty client version, which the README calls out so it does not read as a broken install.

test/test-helpers.ts and tsconfig.eslint.json carry formatting-only changes from npm run format; they had pre-existing drift.

I have:

🤖 Generated with Claude Code

stevendborrelli and others added 6 commits August 29, 2026 10:22
The SDK grew a serve module in v0.7.0, so a function no longer has to
hand-assemble its own gRPC server. This adopts it.

src/main.ts drops from 85 lines to 6: the commander flag parsing, pino
logger construction, server startup and signal handling were boilerplate
nobody had edited since it was scaffolded. serve() does all of it.

That boilerplate had also drifted into a bug. It trapped only SIGINT, so
Kubernetes — which sends SIGTERM — was killing the function ungracefully
on every rollout. serve() handles both.

src/function.ts becomes a ComposeFunction rather than a class
implementing FunctionHandler. serve() hands it a response already built
from the request, so to(req) goes away, and ComposeResponse narrows
desired to non-optional, so resources can be written straight to
rsp.desired.resources. The getDesiredComposedResources() and
setDesiredComposedResources() round trip is redundant once you hold that
map directly, since to() aliases it rather than copying.

commander and pino are no longer direct dependencies; the composition
logic is otherwise untouched.

Verified behaviour-preserving: the previous Function class and the new
compose produce identical responses across every test-cases/ fixture,
and crossplane render against a live server produces the same 17
documents as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README noted that TypeScript embedded functions need a crossplane CLI
including crossplane/cli#170, but never said how to get one — leaving a
contributor to work out that it means building a fork from source before
anything else in the Development section will run.

Add the clone and build steps, matching what CI's `cli` job does, ahead of
Generating Schemas since the CLI is a prerequisite for it.

A CLI built from source reports an empty client version, so the section
says so rather than offering `crossplane version --client` as a check that
would look like a broken install.

Also document the function's -h/--help flag, which it gained along with the
SDK's serve().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XRStatus was hand-written as `{ [key: string]: string | string[] }`, which
restates the status block in apis/network/definition.yaml and accepts any
field name at all. The CLI already generates a typed model for the XR, so
use it: XRStatus is now NonNullable<INetwork['status']>, and the XRD stays
the single source of truth.

Building the status by spreading `cond && { field }` would have made that
pointless — object spreads are not excess-property checked, so a misspelt
field still compiled. Assign the fields instead, which is checked:
`xrStatus.vpcID = vpcId` now fails with "Property 'vpcID' does not exist
... Did you mean 'vpcId'?" where before it compiled and silently wrote a
field no consumer reads.

Runtime behaviour is unchanged — fields are still omitted rather than set
to undefined when empty. crossplane render output is byte-identical, and
the existing status propagation test still passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every composed resource was converted with the same three lines:

    desiredComposed['igw'] = Resource.fromJSON({
      resource: igw.toJSON(),
    });

fromModel() is the SDK helper for exactly this — it calls toJSON() on a
kubernetes-models object and wraps the result — so the ten call sites
collapse to one line each and Resource is no longer imported.

It is a drop-in: Resource.fromJSON already defaults connectionDetails to
{} and ready to READY_UNSPECIFIED, which is what fromModel() passes
explicitly, so the two produce identical protobuf encodings. crossplane
render output is byte-identical.

validate() stays an explicit separate call. No SDK helper validates, and
keeping it where the resource is constructed is what makes a schema
violation name the offending resource.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "Updating the Function" example still showed the pre-fromModel way of
adding a composed resource, which is the one snippet a contributor is most
likely to copy. Update it, and say why validate() remains a separate call
rather than looking like a leftover.

Also correct the project tree: main.ts no longer assembles a gRPC server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The function catches errors and turns them into a fatal result, but nothing
exercised that branch — every fixture was a well-formed Network. The branch
matters more than it looks: the SDK types an observed resource as
{ [key: string]: any }, so every parameter read off the XR is `any` and
TypeScript cannot catch a missing one. validate() is the only thing between
a missing field and a malformed resource reaching the API server.

Add three cases around an XR with no region: a fatal result naming the
field, no composed resources emitted, and the same input composing cleanly
once region is present.

Confirmed the tests are not vacuous by stripping the validate() calls, at
which point the first two fail and the function silently emits a VPC with
no region.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stevendborrelli
stevendborrelli merged commit 480f71f into upbound:main Aug 29, 2026
6 checks passed
@stevendborrelli
stevendborrelli deleted the use-sdk-serve-function branch August 29, 2026 16:21
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