Skip to content

feat: Add Fable JavaScript/Node target (closes #13) - #24

Merged
dbrattli merged 2 commits into
mainfrom
feat/js-node-target
Jul 4, 2026
Merged

dbrattli merged 2 commits into
mainfrom
feat/js-node-target

Conversation

@dbrattli

@dbrattli dbrattli commented Jul 4, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds a third backend — src/js — alongside Python (ASGI) and BEAM (Cowboy), answering issue #13 ("Does this run on Fable JS as well?"). Enabled by Fable 5.5.0's task { } → Promise<T> mapping (PR #4694), which lets the Task-based shared core compile to JavaScript.

Design

Each backend owns its HttpContext, wrapping its platform's HTTP primitives natively — the shared contract between backends is the HttpContext member surface that Core/Routing/Negotiation call, not any server protocol:

  • Python wraps ASGI scope/receive/send; BEAM wraps a Cowboy Req; JS wraps Node http IncomingMessage/ServerResponse.
  • src/js is self-contained — zero dependency on the other backends. Deleting the JS target = delete src/js / app/js / test/js, nothing else. (An earlier revision reused the Python ASGI context via a link; that was reverted per review — ASGI is Python's server substrate, not a cross-platform abstraction, and it coupled JS to Python.)
  • New files mirror the per-target layout: PlatformHelpers.fs, Json.fs (typed Fable.Core.JS.JSON, no emits), HttpContext.fs (Node-native), Server.fs, WebHost.fs.

Host model & Express

Sits on Node's built-in http — the peer of uvicorn / Cowboy — not Express. The Giraffe pipeline is exposed as a Connect (req, res, next) middleware, so the Express/Connect ecosystem (cors, helmet, morgan, passport, OpenTelemetry…) is reusable without an Express dependency:

app.use(cors())
app.use(helmet())
app.use(builder.ToMiddleware())   // Giraffe as terminal handler; unmatched → next()

This is the JS equivalent of how real Giraffe reuses ASP.NET middleware and the Python target wraps ASGI middleware.

Verification

  • End-to-end on Node over real HTTP: GET /ping→pong, GET /json→{"Name":"Dag","Age":53}, POST /echo→body, GET /log→DI+console logging, GET /missing→404, HEAD /ping→200 + Content-Length, no body.
  • just test-js — Node smoke test (test/js/smoke.mjs), 4 cases, exercises the Connect middleware including next() fall-through.
  • Existing suite unaffected: native F# + 50 pytest cases still green.

Notes / gotchas

  • HttpHandler = HttpFunc -> HttpFunc is Fable's "function returning a function" uncurrying ambiguity; the handler is applied fully per request (handler earlyReturn ctx) — the shape Fable uncurries consistently. Corollary: build the middleware inside F# and export that, never round-trip a handler through hand-written JS.
  • Bumps fable tool and fable-library (PyPI) to 5.5.0 (Python & BEAM unaffected).
  • New Justfile targets: build-js, app-js, test-js. Output is ESM; recipes inject {"type":"module"}.

Follow-up

A tech-debt list was compiled while widening the architecture (shared cross-target tests, JSON union-serialization parity, host-abstraction convergence, extracting UseStructlog so the ASGI WebHostBuilder core is shareable, StaticFiles/Remoting on JS) — to be tackled in a follow-up PR.

🤖 Generated with Claude Code

dbrattli and others added 2 commits July 4, 2026 14:58
Widen Fable.Giraffe sideways with a third backend, src/js, alongside
Python (ASGI) and BEAM (Cowboy). Enabled by Fable 5.5.0's task -> Promise
mapping, which lets the Task-based shared core compile to JavaScript.

Design keeps entropy low:
- src/js reuses the shared core and links the ASGI-shaped
  ../python/HttpContext.fs (single source of truth, no duplication).
- New JS-only files: PlatformHelpers.fs, Json.fs (typed Fable.Core.JS.JSON,
  no emits), Server.fs (Node http <-> ASGI adapter), WebHost.fs.
- Host is Node's built-in http (the peer of uvicorn/Cowboy), not Express.
  The pipeline is exposed as a Connect (req,res,next) middleware so the
  Express/Connect ecosystem is reusable via app.use(builder.ToMiddleware())
  with no Express dependency; unmatched routes fall through via next().

Also bumps the fable tool and fable-library (PyPI) to 5.5.0; the Python
and BEAM targets are unaffected (native + 50 pytest cases still green).

Verified end-to-end on Node (ping/json/echo/log/404 over real HTTP) and via
`just test-js` (Node smoke test). New Justfile targets: build-js, app-js,
test-js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…GI one

Reviewer feedback: linking ../python/HttpContext.fs coupled the JS target to
the Python backend (changing Python could break JS) and forced the Node
adapter to fabricate ASGI scope/receive/send just to feed a context that
translated them back into res writes.

ASGI is Python's server substrate, not a cross-platform abstraction. The real
shared contract is the HttpContext member surface that Core/Routing call. So
give src/js its own HttpContext.fs wrapping http IncomingMessage/ServerResponse
directly — exactly as the BEAM backend wraps a Cowboy Req. src/js now has zero
dependency on src/python and is independently deletable/evolvable.

Server.fs loses the ASGI ceremony (buildScope/makeReceive/makeSend) and just
creates HttpContext(req, res, services). Bonus: request headers are read
consistently, and HEAD is handled correctly (IsHead "HEAD") rather than via the
dead lowercase "head" literal the other backends carry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli
dbrattli merged commit 102c27e into main Jul 4, 2026
3 checks passed
@dbrattli
dbrattli deleted the feat/js-node-target branch July 4, 2026 13:18
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