A sample project that shows how to build, run locally and deploy a
Node-Boot application as a Cloudflare Worker, using
the @nodeboot/cloudflare-server package.
It demonstrates:
- Dependency Injection (
@EnableDI) using explicit injection tokens (required in Workers, see below) - Request validation with
class-validator(@EnableValidations) - Authorization (
@EnableAuthorization,@Authorized()) - Controllers, services, middleware and a custom error handler
- Runtime configuration without a filesystem (
additionalConfigDatainstead ofapp-config.yaml) - Local development with
wrangler dev(runs the realworkerdruntime, not just Node.js) - Deployment with
wrangler deploy
src/
├── app.ts # NodeBootApplication bootstrapped on CloudflareServer
├── app-config.ts # Runtime application config, as a plain object (no filesystem access)
├── worker.ts # Cloudflare Worker entry point (exported default { fetch })
├── polyfills.ts # __dirname/__filename shims required by some Node-Boot internals
├── local-invoke.ts # Script to smoke-test the worker locally via ts-node (no wrangler needed)
├── controllers/ # HTTP controllers
├── services/ # Business logic (in-memory user store)
├── models/ # DTOs / validation models
├── middlewares/ # Logging middleware + custom error handler
└── auth/ # Authorization/CurrentUser resolvers
stubs/
└── glob-stub.js # No-op stub for the `glob` module (see wrangler.toml)
wrangler.toml # Wrangler configuration (dev + deploy)
Unlike the Express/Koa/Fastify samples, this application never "listens" on a port.
NodeBoot.run(CloudflareServer, appConfig) bootstraps the DI container, controllers, middleware
and routes exactly once, and CloudflareServer#getHandler() returns a function of shape
(request, env, ctx) => Promise<Response> that Cloudflare invokes for every incoming request:
// src/worker.ts
let fetchHandler: CloudflareHandler | null = null;
export default {
async fetch(request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> {
if (!fetchHandler) {
const app = await new CloudflareSampleApp().start();
fetchHandler = (app.server as CloudflareServer).getHandler();
}
return fetchHandler(request, env, ctx);
},
};fetchHandler is cached at module scope so the DI container, controllers and routes are only
rebuilt on a cold start; warm invocations of the same isolate reuse the same instance.
pnpm install
pnpm run devThis runs wrangler dev, which starts the real, open-source workerd runtime locally (not a
Node.js emulation), so anything that works here is representative of the deployed behavior.
curl http://localhost:8787/api/hello
curl http://localhost:8787/api/users
curl -X POST http://localhost:8787/api/users \
-H "Content-Type: application/json" -H "Authorization: Bearer token" \
-d '{"name":"Ada Lovelace","email":"ada@example.com"}'You can also smoke-test the worker's fetch handler directly in plain Node.js (no wrangler
needed) via ts-node:
pnpm run invoke:localnpx wrangler login
pnpm run deploywrangler.toml's main points at src/worker.ts; Wrangler bundles it and everything it imports
with esbuild, so no separate build step is required for deployment (pnpm run build only runs
tsc for type-checking).
Add bindings (KV namespaces, D1, Durable Objects, secrets, service bindings, etc.) to
wrangler.toml as needed; they are reachable in controllers/services via the injected
CloudflareContext (action.response.env).
The Workers runtime is a sandboxed V8 isolate, not Node.js. Getting a Node-Boot app to run there surfaced a few incompatibilities, each with a corresponding fix baked into this sample and/or the framework itself:
-
No filesystem, no dynamic
require, no__dirname/__filename.src/polyfills.tsshimsglobalThis.__dirname/__filename(imported first inworker.ts), which is needed by some third-party dependencies. More fundamentally,@nodeboot/config's file-basedapp-config.yamldiscovery can never succeed in a real deployed Worker (no fs at all), so@nodeboot/confignow falls back gracefully toadditionalConfigDatawhen file discovery fails. This sample has noapp-config.yamlat all - configuration lives entirely insrc/app-config.ts(a plain object) and is passed straight intoNodeBoot.run(CloudflareServer, appConfig), so the app is configured identically locally (wrangler dev,pnpm run invoke:local) and once deployed. -
No component-scanning.
@EnableComponentScan()reads compiled files from disk (fs.readdirSync/require.cache), which cannot work in Workers. Instead,src/app.tsexplicitly imports every controller/service/middleware for their decorator side effects. Since Wrangler bundles the whole dependency graph with esbuild anyway, this is both simpler and correct. -
No
eval/new Function(find-my-waydoesn't work). The router used by the other HTTP/serverless drivers (find-my-way) compiles its matcher withnew Function(...)for performance, which throwsEvalError: Code generation from strings disallowed for this contextinside a Worker isolate.@nodeboot/cloudflare-serverships its own dependency-freeSimpleRouter(segment-by-segment matching, no codegen) instead. -
esbuild doesn't emit TypeScript decorator metadata. Wrangler bundles source with esbuild, which does not emit
design:paramtypes/design:typemetadata even withemitDecoratorMetadata: trueintsconfig.json. This breaks two things:-
Implicit, type-based
@Inject()resolution — always use explicit tokens instead:@Inject("logger")or@Inject(() => SomeService). -
Constructor parameter injection specifically: without
design:paramtypesmetadata, TypeDI can't determine how many constructor parameters to build and falls back to passing only its internal container instance as the sole argument, silently ignoring any@Inject()decorators on constructor parameters. Property injection is required instead of constructor injection for any class that needs its dependencies resolved in a Workers/esbuild-bundled environment:// ❌ Breaks silently under esbuild bundling (no decorator metadata emitted) constructor(@Inject(() => UserService) private readonly userService: UserService) {} // ✅ Works regardless of decorator metadata availability @Inject(() => UserService) private readonly userService: UserService;
-
-
Winston's
Consoletransport doesn't work out of the box. By default, winston's Console transport writes viaprocess.stdout/console._stdout(real Node streams). Thenodejs_compatpolyfill exposes a stream-shapedconsole._stdoutwhose_writeisn't implemented, causingError: The _write() method is not implemented. Node-Boot's logger now always constructs the Console transport withforceConsole: true, which makes it call the globalconsole.log/warn/errorfunctions directly (natively supported by Workers, and a no-op behavior change in plain Node.js). -
globgets bundled even though it's never called.@nodeboot/engine's (unused, since we don't scan directories)ClassFiles.loadFromDirectoriesstatically importsglob, a Node fs-based package. esbuild's static analysis bundles it regardless of whether the code path actually runs, and it fails at import time in the Workers sandbox.wrangler.tomlaliasesglobtostubs/glob-stub.js, a no-op stub, since this code path is never invoked.
None of the above are specific to this sample — they're general requirements for running any Node-Boot application on Cloudflare Workers (or, likely, other similarly sandboxed edge runtimes).