6 Commits
Author SHA1 Message Date
bwilliams 9905ffb4cb Served the frontend's build output from server.frontend.dir
internal/frontend serves a Vite build from an fs.FS: hashed files under
/assets/ are cached for a year, HTML and other files get no-cache, ETags are
computed (and cached for a build that can't change), precompressed variants
are served when the client accepts them, and a path without an extension
falls back to index.html for client-side routes. Only GET and HEAD are
allowed.

server.WithFrontend serves it for every path outside /api and /ws, behind
compression and the frontend group's hooks. job-search serve passes the
directory in server.frontend.dir, checked for an index.html at start; empty
means no frontend. Its option building moved into serverOptions, with the
access log.
2026-10-16 16:00:34 +01:00
bwilliams 848edc73e2 Published an OpportunityMarksUpdated event when an opportunity's marks change over the API 2026-10-16 13:19:58 +01:00
bwilliams 70d62f280e Added the opportunities REST API, read-only apart from their marks, described in OpenAPI 3.1 with swag
GET /api/opportunities lists the stored opportunities, newest release first,
a page at a time (offset, and limit up to 100, default 50); has_more says
whether another page follows. GET /api/opportunities/{uuid} returns one with
its releases. PUT and DELETE /api/opportunities/{uuid}/marks/{mark} set and
clear one mark (hidden, skipped, interested, bid_submitted, won, lost) and
return them all; won and lost exclude each other (409). The API uses the
types in internal/types, and changing a mark is made atomic, as the store
replaces all of an opportunity's marks at once. The marks are found by
reflection: every bool field of types.OpportunityMarks, by its JSON name, so
a new mark needs no API change.

The API has one entry point, api.New, with its dependencies as options
(api.WithStore), and the server only mounts it under /api, keeping the
middleware. New resources are added inside internal/api. job-search serve
passes server.WithStore when database.file is set, opening the store before
the server; without one there are no opportunity routes.

The API is described by swag v2 annotations on the handlers. go generate
writes the OpenAPI 3.1 description (git-ignored, like the OCDS types), which
the server embeds and serves at /api/openapi.json, with Swagger UI at
/api/docs. A test checks the description and the routes match. swag is in
the README's dev tools, pinned to v2.0.0-rc6, and the build now needs it.
2026-10-16 13:13:44 +01:00
bwilliams 6e101d2b4d Added an events bus, and pushed OpportunitiesUpdated to WebSocket clients when the fetcher stores new opportunities
internal/events decouples the parts of JobSearch that cause events from
the parts that react to them. bus.go has Bus: Subscribe, and a synchronous
Publish that calls each handler in turn on the publisher's goroutine, so
handlers mustn't block (the hub doesn't: it queues per client). events.go
has the event types and their payloads, starting with OpportunitiesUpdated.

With fetcher.WithEvents, a save that stores new opportunities, or new
releases of stored ones, publishes OpportunitiesUpdated with their UUIDs and
counts. An opportunity whose row is only rewritten, as when it is fetched
again from the newest stored release, isn't counted as updated, and nothing
is published when nothing changed. store.SaveResult now reports
NewOpportunity for each opportunity saved.

job-search serve subscribes its WebSocket hub to the bus, so every client
gets {"type":"event","eventType":"OpportunitiesUpdated","payload":{"new":
[...],"updated":[...],"summary":{"new":n,"updated":n}}}. The Envelope gains
an eventType field, and Hub.PushEvent and Server.PushEvent send events.
2026-10-16 11:59:26 +01:00
bwilliams 02e3d910a2 Added access log to server and modified mock-fts to use it 2026-10-16 08:38:41 +01:00
bwilliams a279405cae Added the HTTP server and serve command, with proxy, CSRF, TLS and security header config; wsserve is now ws serve, alongside a new ws dial
The serve command runs internal/server: the API under /api (health, and
the CSRF token endpoint) and the WebSocket hub under /ws, over HTTP or TLS,
with an optional HTTP to HTTPS redirect, the PROXY protocol from trusted
sources and a graceful shutdown that closes WebSockets first. Its middleware
covers the client IP behind trusted proxies, request IDs, request logging,
panic recovery, global and API rate limits, Host and body size checks,
security response headers, compression, CSRF protection (cross-origin
checks and signed double-submit tokens) and JSON-only API bodies. Middleware
that logs takes a WithLogger option.

New server config sections: proxy, csrf, tls, headers and frontend (not
used yet), plus max_header_bytes, max_body_bytes, global and API rate limits
and HTTP read, write and idle timeouts. Every setting documents what 0 or
empty means, and internal/config/README.md is reorganised by section.
server.timeouts.read_header must be greater than 0, and a header or idle
timeout of 0 means no limit rather than the read timeout.

WebSocket upgrades are protected by the Origin check: the server's own
origin, or one of csrf.cross_origin_protection.trusted_origins, which the
hub now takes with WithTrustedOrigins.

wsserve becomes "ws serve". "ws dial" opens a WebSocket on /ws to test it:
--ping sends a ping and waits for the pong, --message sends messages and
prints the replies, --origin tests the Origin check, and with neither
--ping nor --message each line of stdin is sent until Ctrl+C.
2026-10-15 17:47:40 +01:00