Skip to content

Commit c0f1190

Browse files
committed
Add Netlify functions sample project
1 parent 942eed4 commit c0f1190

22 files changed

Lines changed: 631 additions & 0 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Generated files
2+
node_modules
3+
dist
4+
jest.config.js
5+
jest.setup.js
6+
.lintstagedrc.js
7+
**.d.ts

samples/sample-netlify/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.netlify
2+
.env
3+
.env.local
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const baseConfig = require("../../.lintstagedrc.js");
2+
3+
module.exports = {
4+
...baseConfig,
5+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Generated files
2+
pnpm-lock.yaml
3+
node_modules
4+
dist
5+
lambda.zip

samples/sample-netlify/README.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Node-Boot Netlify Sample
2+
3+
A sample project that shows how to build, run locally and deploy a
4+
[Node-Boot](https://github.com/nodejs-boot/node-boot) application as a **Netlify Function**, using
5+
the [`@nodeboot/netlify-server`](../../serverless/netlify-server) package.
6+
7+
It demonstrates:
8+
9+
- Dependency Injection (`@EnableDI`) using explicit injection tokens
10+
- Request validation with `class-validator` (`@EnableValidations`)
11+
- Authorization (`@EnableAuthorization`, `@Authorized()`)
12+
- Controllers, services, middleware and a custom error handler
13+
- Runtime configuration without relying on filesystem discovery (`appConfig` object instead of
14+
`app-config.yaml`)
15+
- Local development with the Netlify CLI (`netlify dev`) or a plain Node.js smoke test
16+
(`pnpm run invoke:local`)
17+
- Deployment with `netlify deploy`
18+
19+
## Project layout
20+
21+
```
22+
netlify/
23+
└── functions/
24+
└── api.ts # Netlify Function entry point (catch-all handling everything under /api/*)
25+
netlify.toml # Redirect rule routing /api/* to the function above
26+
src/
27+
├── app.ts # NodeBootApplication bootstrapped on NetlifyServer
28+
├── app-config.ts # Runtime application config, as a plain object
29+
├── local-invoke.ts # Smoke-test script (calls the handler directly, no Netlify CLI needed)
30+
├── controllers/ # HTTP controllers
31+
├── services/ # Business logic (in-memory user store)
32+
├── models/ # DTOs / validation models
33+
├── middlewares/ # Logging middleware + custom error handler
34+
└── auth/ # Authorization/CurrentUser resolvers
35+
```
36+
37+
## How it works
38+
39+
Unlike the Express/Koa/Fastify samples, this application never "listens" on a port. Netlify
40+
invokes `netlify/functions/api.ts` as a Function for every request routed to it.
41+
`NodeBoot.run(NetlifyServer, appConfig)` bootstraps the DI container, controllers, middleware and
42+
routes exactly once, and `NetlifyServer#getHandler()` returns a function of shape
43+
`(event: HandlerEvent, context: HandlerContext) => Promise<HandlerResponse>`:
44+
45+
```typescript
46+
// netlify/functions/api.ts
47+
let netlifyHandler: NetlifyHandler | null = null;
48+
49+
export const handler: NetlifyHandler = async (event, context) => {
50+
if (!netlifyHandler) {
51+
const app = await new NetlifySampleApp().start();
52+
netlifyHandler = (app.server as NetlifyServer).getHandler();
53+
}
54+
return netlifyHandler(event, context);
55+
};
56+
```
57+
58+
`netlifyHandler` is cached at module scope so the DI container, controllers and routes are only
59+
rebuilt on a cold start; warm invocations of the same Function instance reuse the same instance.
60+
61+
Because NodeBoot's `routePrefix` is configured as `/api` (see `src/app-config.ts`), the redirect
62+
rule in `netlify.toml` rewrites every request under `/api/*` to `/.netlify/functions/api`, lining
63+
up Netlify's routing with NodeBoot's internal router:
64+
65+
```toml
66+
[[redirects]]
67+
from = "/api/*"
68+
to = "/.netlify/functions/api"
69+
status = 200
70+
```
71+
72+
## Running locally
73+
74+
### Option 1: Plain Node.js smoke test (no Netlify CLI/account needed)
75+
76+
```bash
77+
pnpm install
78+
pnpm run build
79+
pnpm run invoke:local
80+
```
81+
82+
This calls the exact same handler function deployed to Netlify directly, building
83+
`HandlerEvent`/`HandlerContext` objects by hand, and fires a few sample requests against it:
84+
85+
```
86+
GET /api/hello -> 200 Hello, from Node-Boot running on Netlify!
87+
POST /api/users -> 201 {"id":"...","email":"ada@example.com","name":"Ada Lovelace"}
88+
GET /api/users -> 200 [{"id":"...","email":"ada@example.com","name":"Ada Lovelace"}]
89+
```
90+
91+
### Option 2: `netlify dev` (requires the Netlify CLI)
92+
93+
```bash
94+
pnpm run dev
95+
```
96+
97+
This runs the actual `netlify dev` local server, which mimics Netlify's production routing/runtime
98+
more closely than the plain smoke test above (redirects, headers, etc.).
99+
100+
```bash
101+
curl http://localhost:8888/api/hello
102+
curl http://localhost:8888/api/users
103+
curl -X POST http://localhost:8888/api/users \
104+
-H "Content-Type: application/json" -H "Authorization: Bearer token" \
105+
-d '{"name":"Ada Lovelace","email":"ada@example.com"}'
106+
```
107+
108+
## Deploying
109+
110+
```bash
111+
npx netlify login
112+
pnpm run deploy
113+
```
114+
115+
`pnpm run deploy` runs `netlify deploy --prod`, which uploads the project and lets Netlify's
116+
`esbuild`-based function bundler compile and bundle `netlify/functions/api.ts` (and everything it
117+
statically imports) automatically.
118+
119+
The first deploy will prompt you to link the directory to a new or existing Netlify site.
120+
Subsequent deploys reuse that link (stored in the git-ignored `.netlify/` directory).
121+
122+
## Netlify vs. traditional Node.js servers: what's different, and why
123+
124+
Netlify Functions run in a real Node.js runtime (AWS Lambda under the hood), so most things "just
125+
work". A few points are still worth calling out:
126+
127+
1. **No long-lived process / no component-scanning.** `@EnableComponentScan()` reads compiled
128+
files from `dist/` at runtime (`fs.readdirSync`). Netlify Functions only ship the subset of
129+
files statically traced from the function's entry point (via esbuild), so relying on directory
130+
scanning for beans not reachable through static imports is unreliable. This sample explicitly
131+
imports every controller/service/middleware in `src/app.ts` for their decorator side effects
132+
instead, guaranteeing they're always included in the bundled function.
133+
134+
2. **No filesystem-based `app-config.yaml` discovery.** `@nodeboot/config` normally walks up the
135+
directory tree from `process.cwd()` looking for `app-config.yaml`. A Function's working
136+
directory is not guaranteed to match the project root, so this sample passes configuration as a
137+
plain object (`src/app-config.ts`) straight into `NodeBoot.run(NetlifyServer, appConfig)`
138+
instead, guaranteeing identical configuration locally and once deployed.
139+
140+
3. **Routing is event-based, not request/response based.** Like AWS Lambda's
141+
`APIGatewayProxyEvent`/`APIGatewayProxyResult`, Netlify's Node.js runtime hands the handler an
142+
immutable `HandlerEvent` and expects an immutable `HandlerResponse` value back.
143+
`NetlifyDriver` builds and returns the final response value instead of writing directly to a
144+
mutable response object.
145+
146+
4. **`find-my-way`'s trailing-slash routes.** Controller index routes build routes with a
147+
trailing slash (e.g. `@Controller("/hello")` + `@Get("/")``/hello/`), but real request URLs
148+
typically omit it (e.g. `/api/hello`). `@nodeboot/netlify-server` configures its `find-my-way`
149+
router with `ignoreTrailingSlash: true` so both forms match.
150+
151+
None of the above are specific to this sample — they're general considerations for running any
152+
Node-Boot application on Netlify Functions.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[build]
2+
functions = "netlify/functions"
3+
command = "pnpm run build"
4+
publish = "dist"
5+
6+
[functions]
7+
node_bundler = "esbuild"
8+
9+
[[redirects]]
10+
from = "/api/*"
11+
to = "/.netlify/functions/api"
12+
status = 200
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {HandlerContext, HandlerEvent, HandlerResponse, NetlifyHandler, NetlifyServer} from "@nodeboot/netlify-server";
2+
import {NetlifySampleApp} from "../../src/app";
3+
4+
/**
5+
* Netlify Function entry point.
6+
*
7+
* This is a single catch-all function, so it handles every path processed by NodeBoot's internal
8+
* router. Since NodeBoot's `routePrefix` is configured as `/api` (see `src/app-config.ts`),
9+
* requests to `/api/*` are rewritten to this function via the redirect rule in `netlify.toml`.
10+
*/
11+
12+
// Reused across warm invocations of the same Function instance.
13+
// Only re-initialized when Netlify spins up a brand-new instance (cold start).
14+
let netlifyHandler: NetlifyHandler | null = null;
15+
16+
export const handler: NetlifyHandler = async (
17+
event: HandlerEvent,
18+
context: HandlerContext,
19+
): Promise<HandlerResponse> => {
20+
if (!netlifyHandler) {
21+
const app = await new NetlifySampleApp().start();
22+
const netlifyServer = app.server as NetlifyServer;
23+
netlifyHandler = netlifyServer.getHandler();
24+
}
25+
26+
return netlifyHandler(event, context) as Promise<HandlerResponse>;
27+
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"name": "@nodeboot/netlify-sample",
3+
"version": "1.0.0",
4+
"description": "Node-Boot sample project deployed as a Netlify Function",
5+
"author": "Manuel Santos <ney.br.santos@gmail.com>",
6+
"license": "MIT",
7+
"private": "true",
8+
"keywords": [
9+
"node-boot",
10+
"netlify",
11+
"serverless"
12+
],
13+
"repository": {
14+
"type": "git",
15+
"url": "https://github.com/nodejs-boot/node-boot.git"
16+
},
17+
"publishConfig": {
18+
"access": "public"
19+
},
20+
"main": "dist/netlify/functions/api.js",
21+
"types": "src/index.ts",
22+
"scripts": {
23+
"dev": "npx netlify-cli dev",
24+
"invoke:local": "ts-node src/local-invoke.ts",
25+
"nodeboot:update": "pnpm update @nodeboot/*@latest",
26+
"build": "tsc -p tsconfig.build.json",
27+
"clean:build": "rimraf ./dist ./.netlify",
28+
"deploy": "npx netlify-cli deploy --prod",
29+
"lint": "eslint . --ext .js,.ts",
30+
"lint:fix": "pnpm lint --fix",
31+
"format": "prettier --check .",
32+
"format:fix": "prettier --write .",
33+
"tsc": "tsc"
34+
},
35+
"dependencies": {
36+
"@nodeboot/config": "workspace:*",
37+
"@nodeboot/context": "workspace:*",
38+
"@nodeboot/core": "workspace:*",
39+
"@nodeboot/netlify-server": "workspace:*",
40+
"@nodeboot/authorization": "workspace:*",
41+
"@nodeboot/di": "workspace:*",
42+
"@nodeboot/error": "workspace:*",
43+
"@nodeboot/starter-validation": "workspace:*",
44+
"reflect-metadata": "^0.2.1",
45+
"class-transformer": "^0.5.1",
46+
"class-validator": "^0.14.0",
47+
"typedi": "^0.10.0",
48+
"winston": "^3.17.0"
49+
},
50+
"devDependencies": {
51+
"@types/node": "^22.13.4",
52+
"ts-node": "^10.9.2"
53+
}
54+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Node-Boot application configuration, expressed as a plain TypeScript object instead of an
3+
* `app-config.yaml` file.
4+
*
5+
* @remarks
6+
*
7+
* `@nodeboot/config` normally discovers and loads `app-config.yaml` from disk (via
8+
* `@backstage/cli-common`'s `findPaths`), which walks up the directory tree from `process.cwd()`
9+
* looking for the file. Netlify Functions execute from a bundled, traced subset of the repository
10+
* with an unpredictable working directory, so relying on filesystem discovery of
11+
* `app-config.yaml` is fragile in that environment. Passing this plain object straight into
12+
* `NodeBoot.run(NetlifyServer, appConfig)` instead guarantees the app is configured identically
13+
* locally (`netlify dev`, `pnpm run invoke:local`) and once deployed. There is no
14+
* `app-config.yaml` file in this sample - this is the single source of truth for configuration.
15+
*/
16+
export const appConfig = {
17+
app: {
18+
name: "netlify-sample",
19+
platform: "node-boot",
20+
environment: process.env["CONTEXT"] ?? "development",
21+
defaultErrorHandler: false,
22+
},
23+
api: {
24+
routePrefix: "/api",
25+
nullResultCode: 200,
26+
undefinedResultCode: 204,
27+
paramOptions: {
28+
required: false,
29+
},
30+
validations: {
31+
enableDebugMessages: false,
32+
skipUndefinedProperties: false,
33+
skipNullProperties: false,
34+
skipMissingProperties: false,
35+
whitelist: true,
36+
forbidNonWhitelisted: true,
37+
forbidUnknownValues: true,
38+
stopAtFirstError: false,
39+
},
40+
},
41+
logger: {
42+
level: "info",
43+
},
44+
};

samples/sample-netlify/src/app.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import "reflect-metadata";
2+
import {Container} from "typedi";
3+
import {NodeBoot, NodeBootApp, NodeBootApplication, NodeBootAppView} from "@nodeboot/core";
4+
import {EnableAuthorization} from "@nodeboot/authorization";
5+
import {EnableValidations} from "@nodeboot/starter-validation";
6+
import {EnableDI} from "@nodeboot/di";
7+
import {NetlifyServer} from "@nodeboot/netlify-server";
8+
import {LoggedInUserResolver} from "./auth/LoggedInUserResolver";
9+
import {DefaultAuthorizationResolver} from "./auth/DefaultAuthorizationResolver";
10+
11+
// Beans are imported here for their side effects (decorator registration) instead of relying on
12+
// `@EnableComponentScan`. Component scanning reads compiled files from `dist/` at runtime
13+
// (`fs.readdirSync`), but Netlify Functions only ship the subset of files statically traced from
14+
// the function's entry point (via esbuild) - untraced directories scanned dynamically at runtime
15+
// are not guaranteed to be present in the deployed bundle. Explicit imports here make the whole
16+
// dependency graph statically discoverable, so it works reliably both locally and once deployed.
17+
import "./controllers/hello.controller";
18+
import "./controllers/users.controller";
19+
import "./services/users.service";
20+
import "./middlewares/LoggingMiddleware";
21+
import "./middlewares/ErrorMiddleware";
22+
import {appConfig} from "./app-config";
23+
24+
/**
25+
* NodeBoot application entry point.
26+
*
27+
* Notice that, unlike the Express/Koa/Fastify samples, this application does not "listen" on a
28+
* port. Instead, it is bootstrapped once per Netlify Function instance (cold start) and its
29+
* `NetlifyServer` exposes a handler function that Netlify invokes for every incoming request.
30+
* See `netlify/functions/api.ts`.
31+
*/
32+
@EnableDI(Container)
33+
@EnableAuthorization(LoggedInUserResolver, DefaultAuthorizationResolver)
34+
@EnableValidations()
35+
@NodeBootApplication()
36+
export class NetlifySampleApp implements NodeBootApp {
37+
start(): Promise<NodeBootAppView> {
38+
return NodeBoot.run(NetlifyServer, appConfig);
39+
}
40+
}

0 commit comments

Comments
 (0)