Compare commits

..
Author SHA1 Message Date
overseer 30e6dec65d Merge branch 'dev' into feature/ea4f47b4-6f1e-4735-a33e-6e91bf092564-pwa-shell
CI / ci (pull_request) Successful in 1m41s
CI / ci (push) Successful in 1m40s
2026-10-05 15:08:55 -04:00
dev_agentandClaude Opus 5.5 8f5ab773fc Clear offline data on logout, test offline plan and recipe reads, drop Pillow
CI / ci (push) Successful in 1m35s
CI / ci (pull_request) Successful in 1m38s
- POST /auth/logout answers with Clear-Site-Data: "storage", "cache", which
  removes the verified-session marker, the service worker and its caches, so
  an offline visit after logout cannot reopen household data. The 401/403
  clearing path is unchanged.
- The service worker no longer answers network-only requests; it leaves them
  to the browser. Chrome stalls a Clear-Site-Data navigation that the worker
  answered, so the logout POST must bypass it.
- New tests send plan and recipe reads through the real API client and the
  real network-first strategy online, then serve them from the cache offline.
- The icon generator is now Go on golang.org/x/image (approved) instead of
  Python on Pillow; the icons are regenerated with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:36:45 -04:00
dev_agentandClaude Opus 5.5 7f8ae90978 Open saved pages offline, cache recipe reads, flag cache fallbacks
CI / ci (push) Successful in 1m34s
CI / ci (pull_request) Successful in 1m34s
Address the Feature 06 integration review:

- Offline bootstrap: the API client keeps a device-local verified-session
  marker (localStorage "1" only, never the /me payload or CSRF token),
  set when /me answers with a member and cleared on a 401 or a
  not-in-household 403. When /me fails with a network error and the
  marker is set, AuthBootstrap opens the saved pages read-only (writes
  are refused for want of a CSRF token) and loads /me again when the
  browser comes back online. /me runs with networkMode 'always' so an
  offline load fails fast instead of pausing on the skeleton.
- Recipe reads (/api/v1/recipes) join the network-first API cache;
  /auth, settings and jobs stay network-only, including encoded and
  dot-segment forms.
- The network-first strategy marks a response answered from its cache
  with X-MealQuest-Cache-Fallback; the client counts it as offline, so
  the indicator shows while navigator.onLine is still true.

Tests: offline reload of /plan and /dinner/:id after an online visit,
no saved pages without a verified session or after a 401, reconnect
reloads /me; the real Workbox NetworkFirst strategy against an
in-memory cache opens a previously viewed recipe offline, marked; the
pill shows for a cache fallback while online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:10:53 -04:00
dev_agent 1fa295b131 Merge dev into Feature 06 branch before integration review
CI / ci (push) Successful in 1m35s
CI / ci (pull_request) Successful in 1m34s
2026-10-05 08:56:59 -04:00
dev_agent 581ff217a9 iOS install hint component (#56)
CI / ci (push) Successful in 1m33s
CI / ci (pull_request) Successful in 1m34s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-05 08:35:35 -04:00
dev_agent 78c33370f9 Update prompt and offline indicator (#52)
CI / ci (push) Successful in 1m33s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-05 08:13:50 -04:00
dev_agent 21b2672d40 Service worker caching strategy (#51)
CI / ci (push) Successful in 1m31s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 16:36:43 -04:00
dev_agent dd58865323 Web app manifest and icons (#50)
CI / ci (push) Successful in 1m35s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 16:16:41 -04:00
56 changed files with 7361 additions and 2508 deletions
+5 -31
View File
@@ -208,37 +208,8 @@ automatic** (D-021).
- Dinners are grounded in real recipe pages found through recipe search
(`searxng` or `brave`, per `recipe_search_settings`).
- Fetching is **SSRF-safe** (`recipes.Fetcher`): https:// only, at every
redirect hop. Its dialer resolves the host once and refuses unless every
answer is public — every non-globally-reachable IPv4 special-purpose
range (loopback, RFC 1918, link-local, carrier-grade NAT, documentation
TEST-NETs, benchmarking 198.18/15, multicast, reserved and more) is
refused, IPv4-mapped forms included, and IPv6 must be global unicast
(2000::/3) outside documentation, 6to4, Teredo and other special ranges —
then dials the address it checked, so a
rebinding DNS answer cannot swap in a private one. Keep-alives and
environment proxies are off, so every request dials and checks afresh. At
most 3 redirects; a body over 2 MB is an error, never truncated; 10 s for
the whole fetch; a descriptive `User-Agent`; requests to one host at least
1 s apart, counted from requests actually sent (an abandoned wait holds no
slot); 2xx pages cached by URL for an hour (64 pages, oldest evicted), and
callers get their own copy of a cached body.
- Recipe data is parsed from schema.org `Recipe` JSON-LD
(`recipes.Extract`): a small targeted scan of the page's
`application/ld+json` scripts and `og:site_name` (no HTML parser
dependency), finding the Recipe node at a script's top level, in a
top-level array or in an `@graph`, with `@type` a string or an array of
terms or absolute schema.org IRIs (`https://schema.org/Recipe`). An image
or publisher given as an `{"@id": ...}` reference resolves to the node
with that `@id` anywhere in the page's JSON-LD. The largest image (by ImageObject width × height; the first of equals) is
resolved to an absolute URL; instructions — one string of lines, or a
list of strings, HowToSteps and HowToSections — are flattened in order;
ISO-8601 prep/cook/total times become minutes, total derived as prep +
cook when only those are given; the site name is the publisher's, else
`og:site_name`, else the host. A page with no Recipe is `ErrNoRecipe`,
with two is `ErrManyRecipes`; a shape schema.org does not allow — such as
a non-ISO-8601 duration like "20 minutes" or an empty one, or an image
`@id` no node has — is `ErrMalformedRecipe`, never guessed at.
- Fetching is **SSRF-safe**.
- Recipe data is parsed from schema.org `Recipe` JSON-LD.
- Photos are cached locally (`photos`) and processed with
`golang.org/x/image`.
@@ -289,6 +260,9 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
hard-coded hex. Self-hosted Bagel Fat One and Figtree.
- Tests: Vitest, Testing Library, jsdom.
- Offline cache for the current plan, shopping list and recipes.
- Logout (`POST /auth/logout`) answers with `Clear-Site-Data: "storage",
"cache"`, wiping the offline marker, the service worker and its caches; the
worker leaves that request to the browser, as it does every network-only one.
| Route | Screen |
| --- | --- |
+1
View File
@@ -6,6 +6,7 @@ require (
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-chi/chi/v5 v5.3.1
github.com/oklog/ulid/v2 v2.1.2
golang.org/x/image v0.45.0
golang.org/x/oauth2 v0.36.0
modernc.org/sqlite v1.59.0
)
+2
View File
@@ -21,6 +21,8 @@ github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+5
View File
@@ -327,8 +327,13 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
// logout ends the session, clears the cookie and sends the browser to the
// provider's end_session_endpoint to end the provider session as well.
// Clear-Site-Data wipes this origin's storage and caches — the PWA's
// verified-session marker in localStorage and the service worker's cached API
// and media responses — so an offline visit after logout cannot reopen
// household data.
func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Clear-Site-Data", `"storage", "cache"`)
if err := a.sessions.EndSession(w, r); err != nil {
a.logger.ErrorContext(r.Context(), "end session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
+6
View File
@@ -440,6 +440,9 @@ func TestLogout(t *testing.T) {
if !strings.HasPrefix(raw, SessionCookieName+"=;") || !strings.Contains(raw, "Max-Age=0") {
t.Errorf("logout Set-Cookie = %q, want the cookie cleared", raw)
}
if got := resp.Header.Get("Clear-Site-Data"); got != `"storage", "cache"` {
t.Errorf("logout Clear-Site-Data = %q, want the PWA's storage and caches cleared", got)
}
rows := h.rows(t)
if len(rows) != 1 || rows[0].idHash != sha256Hex(other.Value) {
@@ -461,6 +464,9 @@ func TestLogoutWithoutSession(t *testing.T) {
t.Errorf("logout = %d to %q, want 303 to the end session endpoint", resp.StatusCode, resp.Header.Get("Location"))
}
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
if got := resp.Header.Get("Clear-Site-Data"); got != `"storage", "cache"` {
t.Errorf("logout Clear-Site-Data = %q, want the PWA's storage and caches cleared", got)
}
}
func TestLogoutIsPostOnly(t *testing.T) {
-631
View File
@@ -1,631 +0,0 @@
package recipes
import (
"encoding/json"
"errors"
"fmt"
"html"
"io"
"math"
"net/url"
"strconv"
"strings"
)
var (
// ErrNoRecipe is returned for a page with no schema.org Recipe node in
// its JSON-LD.
ErrNoRecipe = errors.New("recipes: page has no schema.org Recipe")
// ErrManyRecipes is returned for a page with more than one Recipe node:
// which one the page is about is not knowable.
ErrManyRecipes = errors.New("recipes: page has more than one schema.org Recipe")
// ErrMalformedRecipe is returned when the page's JSON-LD, or a field of
// its Recipe node, is not in a shape schema.org allows.
ErrMalformedRecipe = errors.New("recipes: malformed schema.org Recipe")
)
// Recipe is the schema.org Recipe extracted from a recipe page.
type Recipe struct {
// Title is the recipe's name.
Title string
// SiteName is the publisher's name, else the page's og:site_name, else
// its host without a leading "www.".
SiteName string
// ImageURL is the largest image, absolute; empty when the recipe has
// none.
ImageURL string
Ingredients []string
// Steps are the instructions in order, HowToSection groups flattened.
Steps []string
// PrepMinutes, CookMinutes and TotalMinutes are nil when not given.
// TotalMinutes is prep plus cook when the page gives both but no total.
PrepMinutes *int
CookMinutes *int
TotalMinutes *int
// Yield is recipeYield as text; empty when not given.
Yield string
// JSONLD is the Recipe node as found.
JSONLD json.RawMessage
}
// Extract finds the schema.org Recipe in a page's application/ld+json
// scripts. Recipe nodes are found at the top level of a script, in a
// top-level array, or in an @graph. An image or publisher given as an
// {"@id": ...} reference resolves to the node with that @id found in the
// same places, in any of the page's scripts. pageURL is the page's own
// absolute URL; relative image URLs resolve against it.
func Extract(page []byte, pageURL string) (Recipe, error) {
base, err := url.Parse(pageURL)
if err != nil || !base.IsAbs() || base.Host == "" {
return Recipe{}, fmt.Errorf("recipes: page URL %q is not absolute", pageURL)
}
doc := scanHTML(string(page))
g := graph{ids: map[string][]map[string]any{}}
for i, script := range doc.jsonLD {
v, err := decodeJSON(script)
if err != nil {
return Recipe{}, fmt.Errorf("%w: JSON-LD script %d: %v", ErrMalformedRecipe, i+1, err)
}
g.collect(v)
}
nodes := g.recipes
switch len(nodes) {
case 0:
return Recipe{}, ErrNoRecipe
case 1:
default:
return Recipe{}, fmt.Errorf("%w: found %d", ErrManyRecipes, len(nodes))
}
r, err := recipeFrom(nodes[0], g, base, doc.ogSiteName)
if err != nil {
return Recipe{}, fmt.Errorf("%w: %v", ErrMalformedRecipe, err)
}
return r, nil
}
func decodeJSON(s string) (any, error) {
dec := json.NewDecoder(strings.NewReader(s))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return nil, err
}
if _, err := dec.Token(); err != io.EOF {
return nil, errors.New("trailing data after JSON value")
}
return v, nil
}
// graph holds the page's JSON-LD nodes: the Recipe nodes, and every node
// with an @id by that @id.
type graph struct {
recipes []map[string]any
ids map[string][]map[string]any
}
// collect gathers the nodes in v: v itself, the elements of an array, and
// the members of an @graph. A bare {"@id": ...} reference is not a node.
func (g *graph) collect(v any) {
switch n := v.(type) {
case []any:
for _, e := range n {
g.collect(e)
}
case map[string]any:
if hasType(n, "Recipe") {
g.recipes = append(g.recipes, n)
}
if id, ok := n["@id"].(string); ok && len(n) > 1 {
g.ids[id] = append(g.ids[id], n)
}
if members, ok := n["@graph"]; ok {
g.collect(members)
}
}
}
// resolve returns the node a bare {"@id": ...} reference names, and v
// itself when v is not a reference. found is false for a reference to no
// node; a reference to more than one node is an error.
func (g graph) resolve(v any) (node any, found bool, err error) {
m, ok := v.(map[string]any)
if !ok || len(m) != 1 {
return v, true, nil
}
id, ok := m["@id"].(string)
if !ok {
return v, true, nil
}
switch nodes := g.ids[id]; len(nodes) {
case 0:
return v, false, nil
case 1:
return nodes[0], true, nil
default:
return nil, false, fmt.Errorf("@id %q names %d nodes", id, len(nodes))
}
}
// hasType reports whether node's @type, a string or an array of strings,
// names the schema.org type typ, as a term or an absolute schema.org IRI.
func hasType(node map[string]any, typ string) bool {
switch t := node["@type"].(type) {
case string:
return isSchemaType(t, typ)
case []any:
for _, e := range t {
if s, ok := e.(string); ok && isSchemaType(s, typ) {
return true
}
}
}
return false
}
func isSchemaType(s, typ string) bool {
return s == typ || s == "https://schema.org/"+typ || s == "http://schema.org/"+typ
}
func recipeFrom(n map[string]any, g graph, base *url.URL, ogSiteName string) (Recipe, error) {
var r Recipe
var err error
name, ok := n["name"].(string)
if r.Title = cleanText(name); !ok || r.Title == "" {
return Recipe{}, errors.New("name is missing")
}
if r.SiteName, err = siteName(n["publisher"], g, ogSiteName, base); err != nil {
return Recipe{}, err
}
if r.ImageURL, err = imageURL(n["image"], g, base); err != nil {
return Recipe{}, err
}
if r.Ingredients, err = ingredients(n["recipeIngredient"]); err != nil {
return Recipe{}, err
}
if r.Steps, err = steps(n["recipeInstructions"]); err != nil {
return Recipe{}, err
}
if r.PrepMinutes, err = minutes(n, "prepTime"); err != nil {
return Recipe{}, err
}
if r.CookMinutes, err = minutes(n, "cookTime"); err != nil {
return Recipe{}, err
}
if r.TotalMinutes, err = minutes(n, "totalTime"); err != nil {
return Recipe{}, err
}
if r.TotalMinutes == nil && r.PrepMinutes != nil && r.CookMinutes != nil {
total := *r.PrepMinutes + *r.CookMinutes
r.TotalMinutes = &total
}
if r.Yield, err = yield(n["recipeYield"]); err != nil {
return Recipe{}, err
}
if r.JSONLD, err = json.Marshal(n); err != nil {
return Recipe{}, err
}
return r, nil
}
// cleanText decodes HTML entities, which recipe plugins often leave in
// JSON-LD text, and trims surrounding space.
func cleanText(s string) string {
return strings.TrimSpace(html.UnescapeString(s))
}
func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string, error) {
pubs, ok := publisher.([]any)
if !ok {
pubs = []any{publisher}
}
for _, p := range pubs {
p, _, err := g.resolve(p)
if err != nil {
return "", fmt.Errorf("publisher: %w", err)
}
if m, ok := p.(map[string]any); ok {
if name, ok := m["name"].(string); ok && cleanText(name) != "" {
return cleanText(name), nil
}
}
}
if ogSiteName != "" {
return ogSiteName, nil
}
return strings.TrimPrefix(base.Hostname(), "www."), nil
}
// imageURL picks the largest of the recipe's images by width × height;
// images without both dimensions count as size zero, and the first wins a
// tie, so a list of bare URLs gives its first.
func imageURL(v any, g graph, base *url.URL) (string, error) {
items, ok := v.([]any)
if !ok {
if v == nil {
return "", nil
}
items = []any{v}
}
best, bestArea := "", int64(-1)
for _, item := range items {
item, found, err := g.resolve(item)
if err != nil {
return "", fmt.Errorf("image: %w", err)
}
if !found {
return "", fmt.Errorf("image references @id %v, which no node on the page has", item.(map[string]any)["@id"])
}
var u string
var area int64
switch img := item.(type) {
case string:
u = img
case map[string]any:
if s, ok := img["url"].(string); ok {
u = s
} else if s, ok := img["contentUrl"].(string); ok {
u = s
}
w, err := dimension(img["width"])
if err != nil {
return "", fmt.Errorf("image width: %w", err)
}
h, err := dimension(img["height"])
if err != nil {
return "", fmt.Errorf("image height: %w", err)
}
area = w * h
default:
return "", fmt.Errorf("image is a %T, not a URL or ImageObject", item)
}
u = strings.TrimSpace(u)
if u == "" {
return "", errors.New("image has no URL")
}
if area > bestArea {
best, bestArea = u, area
}
}
if best == "" {
return "", nil
}
ref, err := url.Parse(best)
if err != nil {
return "", fmt.Errorf("image URL %q: %w", best, err)
}
return base.ResolveReference(ref).String(), nil
}
// dimension reads an ImageObject width or height: a number or a string of
// digits; zero when absent.
func dimension(v any) (int64, error) {
switch d := v.(type) {
case nil:
return 0, nil
case json.Number:
f, err := d.Float64()
if err != nil || f < 0 {
return 0, fmt.Errorf("%q is not a size", d)
}
return int64(f), nil
case string:
n, err := strconv.ParseInt(strings.TrimSpace(d), 10, 64)
if err != nil || n < 0 {
return 0, fmt.Errorf("%q is not a size", d)
}
return n, nil
}
return 0, fmt.Errorf("a %T is not a size", v)
}
func ingredients(v any) ([]string, error) {
items, ok := v.([]any)
if !ok {
if v == nil {
return nil, nil
}
items = []any{v}
}
var out []string
for _, item := range items {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("recipeIngredient holds a %T, not text", item)
}
if s = cleanText(s); s != "" {
out = append(out, s)
}
}
return out, nil
}
// steps flattens recipeInstructions: one string of lines, or a list of
// strings, HowToSteps and HowToSections of those, in order.
func steps(v any) ([]string, error) {
switch in := v.(type) {
case nil:
return nil, nil
case string:
var out []string
for _, line := range strings.Split(in, "\n") {
if line = cleanText(line); line != "" {
out = append(out, line)
}
}
return out, nil
case map[string]any:
return stepList([]any{in})
case []any:
return stepList(in)
}
return nil, fmt.Errorf("recipeInstructions is a %T", v)
}
func stepList(items []any) ([]string, error) {
var out []string
for _, item := range items {
switch s := item.(type) {
case string:
if text := cleanText(s); text != "" {
out = append(out, text)
}
case map[string]any:
switch {
case hasType(s, "HowToStep"):
text, _ := s["text"].(string)
if text = cleanText(text); text == "" {
return nil, errors.New("HowToStep has no text")
}
out = append(out, text)
case hasType(s, "HowToSection"):
list, ok := s["itemListElement"].([]any)
if !ok {
return nil, errors.New("HowToSection has no itemListElement list")
}
section, err := stepList(list)
if err != nil {
return nil, err
}
out = append(out, section...)
default:
return nil, fmt.Errorf("recipeInstructions holds a %v, not a HowToStep or HowToSection", s["@type"])
}
default:
return nil, fmt.Errorf("recipeInstructions holds a %T", item)
}
}
return out, nil
}
// minutes reads a duration field: nil when absent or null; any string
// present, empty included, must be an ISO-8601 duration.
func minutes(n map[string]any, field string) (*int, error) {
v, ok := n[field]
if !ok || v == nil {
return nil, nil
}
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("%s is a %T, not an ISO-8601 duration", field, v)
}
m, err := isoMinutes(s)
if err != nil {
return nil, fmt.Errorf("%s: %w", field, err)
}
return &m, nil
}
// isoMinutes converts an ISO-8601 duration of weeks, days, hours, minutes
// and seconds to whole minutes, rounded. Years and months are refused:
// their length in minutes is not fixed.
func isoMinutes(s string) (int, error) {
bad := fmt.Errorf("%q is not an ISO-8601 duration of days, hours, minutes or seconds", s)
rest, ok := strings.CutPrefix(strings.TrimSpace(s), "P")
if !ok || rest == "" {
return 0, bad
}
const units = "WDHMS"
seconds := map[byte]float64{'W': 7 * 86400, 'D': 86400, 'H': 3600, 'M': 60, 'S': 1}
inTime, last, total := false, -1, 0.0
for rest != "" {
if rest[0] == 'T' {
if inTime || len(rest) == 1 {
return 0, bad
}
inTime, rest = true, rest[1:]
continue
}
i := 0
for i < len(rest) && (rest[i] >= '0' && rest[i] <= '9' || rest[i] == '.' || rest[i] == ',') {
i++
}
if i == 0 || i == len(rest) {
return 0, bad
}
num, err := strconv.ParseFloat(strings.Replace(rest[:i], ",", ".", 1), 64)
if err != nil {
return 0, bad
}
unit := rest[i]
pos := strings.IndexByte(units, unit)
if pos < 0 || pos <= last || inTime != (pos >= 2) {
return 0, bad
}
last, total, rest = pos, total+num*seconds[unit], rest[i+1:]
}
if last < 0 {
return 0, bad
}
return int(math.Round(total / 60)), nil
}
func yield(v any) (string, error) {
if list, ok := v.([]any); ok {
if len(list) == 0 {
return "", nil
}
v = list[0]
}
switch y := v.(type) {
case nil:
return "", nil
case string:
return cleanText(y), nil
case json.Number:
return y.String(), nil
}
return "", fmt.Errorf("recipeYield is a %T", v)
}
// htmlDoc is what Extract needs from a page's HTML.
type htmlDoc struct {
jsonLD []string
ogSiteName string
}
// scanHTML is a small targeted scan, not a full HTML parser: it walks the
// tags, skipping comments and the bodies of scripts and styles, and keeps
// the bodies of application/ld+json scripts and the first og:site_name.
func scanHTML(doc string) htmlDoc {
var out htmlDoc
lower := asciiLower(doc)
for pos := 0; ; {
lt := strings.IndexByte(doc[pos:], '<')
if lt < 0 {
return out
}
pos += lt
if strings.HasPrefix(doc[pos:], "<!--") {
end := strings.Index(doc[pos+4:], "-->")
if end < 0 {
return out
}
pos += 4 + end + 3
continue
}
name := tagName(lower[pos+1:])
if name == "" {
pos++
continue
}
end := tagEnd(doc, pos+1+len(name))
if end < 0 {
return out
}
attrs := parseAttrs(doc[pos+1+len(name) : end])
pos = end + 1
switch name {
case "meta":
if out.ogSiteName == "" && attrs["property"] == "og:site_name" {
out.ogSiteName = strings.TrimSpace(attrs["content"])
}
case "script", "style":
closeAt := strings.Index(lower[pos:], "</"+name)
if closeAt < 0 {
return out
}
typ, _, _ := strings.Cut(attrs["type"], ";")
if name == "script" && strings.EqualFold(strings.TrimSpace(typ), "application/ld+json") {
out.jsonLD = append(out.jsonLD, doc[pos:pos+closeAt])
}
pos += closeAt
}
}
}
// asciiLower lower-cases ASCII letters only, so byte offsets match doc.
func asciiLower(s string) string {
b := []byte(s)
for i, c := range b {
if 'A' <= c && c <= 'Z' {
b[i] = c + 'a' - 'A'
}
}
return string(b)
}
// tagName reads the lower-cased name of an opening tag at the start of s,
// or "" when s does not start one.
func tagName(s string) string {
i := 0
for i < len(s) && ('a' <= s[i] && s[i] <= 'z' || i > 0 && '0' <= s[i] && s[i] <= '9') {
i++
}
if i == 0 || i < len(s) && !isSpace(s[i]) && s[i] != '>' && s[i] != '/' {
return ""
}
return s[:i]
}
// tagEnd finds the '>' closing a tag whose attributes start at i, skipping
// quoted attribute values.
func tagEnd(doc string, i int) int {
var quote byte
for ; i < len(doc); i++ {
switch c := doc[i]; {
case quote != 0:
if c == quote {
quote = 0
}
case c == '"' || c == '\'':
quote = c
case c == '>':
return i
}
}
return -1
}
// parseAttrs reads a tag's attributes; names are lower-cased, values are
// entity-decoded, and the first of a repeated name wins.
func parseAttrs(s string) map[string]string {
attrs := map[string]string{}
i := 0
for i < len(s) {
for i < len(s) && (isSpace(s[i]) || s[i] == '/') {
i++
}
start := i
for i < len(s) && !isSpace(s[i]) && s[i] != '=' && s[i] != '/' {
i++
}
if i == start {
i++
continue
}
name := asciiLower(s[start:i])
for i < len(s) && isSpace(s[i]) {
i++
}
value := ""
if i < len(s) && s[i] == '=' {
i++
for i < len(s) && isSpace(s[i]) {
i++
}
if i < len(s) && (s[i] == '"' || s[i] == '\'') {
q := s[i]
end := strings.IndexByte(s[i+1:], q)
if end < 0 {
end = len(s) - i - 1
}
value = s[i+1 : i+1+end]
i += end + 2
} else {
start := i
for i < len(s) && !isSpace(s[i]) {
i++
}
value = s[start:i]
}
}
if _, seen := attrs[name]; !seen {
attrs[name] = html.UnescapeString(value)
}
}
return attrs
}
func isSpace(c byte) bool {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'
}
-408
View File
@@ -1,408 +0,0 @@
package recipes
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func readPage(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatal(err)
}
return b
}
func intp(n int) *int { return &n }
func fmtMinutes(m *int) string {
if m == nil {
return "nil"
}
b, _ := json.Marshal(*m)
return string(b)
}
func TestExtractRealPages(t *testing.T) {
tests := []struct {
file, url string
title, site, image, yld string
ingredients, steps int
prep, cook, total *int
firstStep string
}{
{
// A Recipe script beside VideoObject and BreadcrumbList scripts;
// image is an array of one sized ImageObject.
file: "bbcgoodfood.html", url: "https://www.bbcgoodfood.com/recipes/easy-chicken-curry",
title: "Easy chicken curry", site: "Good Food",
image: "https://images.immediate.co.uk/production/volatile/sites/30/2020/08/easy-chicken-curry-aa22a0b.jpg?resize=440,400",
ingredients: 11, steps: 4, prep: intp(5), cook: intp(45), total: intp(50), yld: "4",
firstStep: "Heat the oil in a flameproof casserole dish",
},
{
// Yoast @graph; image is a list of bare URLs, the first the
// full-size one; no publisher, so og:site_name names the site.
file: "budgetbytes.html", url: "https://www.budgetbytes.com/one-pot-creamy-cajun-chicken-pasta/",
title: "One Pot Creamy Cajun Chicken Pasta", site: "Budget Bytes",
image: "https://www.budgetbytes.com/wp-content/uploads/2018/10/One-Pot-Creamy-Cajun-Chicken-Pasta-pan.jpg",
ingredients: 17, steps: 6, prep: intp(10), cook: intp(20), total: intp(30), yld: "4",
firstStep: "Combine the ingredients for the Cajun seasoning",
},
{
// HowToSection groups (ragu, cheese sauce, assembly), flattened.
file: "recipetineats.html", url: "https://www.recipetineats.com/lasagna/",
title: "Lasagna", site: "RecipeTin Eats",
image: "https://www.recipetineats.com/tachyon/2017/05/Lasagne-recipe-3-main-square.jpg",
ingredients: 24, steps: 18, prep: intp(20), cook: intp(240), total: intp(260), yld: "10",
firstStep: "Heat oil in a large heavy based pot",
},
{
// @graph of one node; a single ImageObject; instructions as a
// list of plain strings; hours and minutes.
file: "kingarthur.html", url: "https://www.kingarthurbaking.com/recipes/classic-sandwich-bread-recipe",
title: "Classic Sandwich Bread", site: "King Arthur Baking",
image: "https://www.kingarthurbaking.com/sites/default/files/2026-02/Classic-Sandwich-Bread-12.jpg",
ingredients: 7, steps: 7, prep: intp(12), cook: intp(40), total: intp(162), yld: "16",
firstStep: "Weigh your flour",
},
{
// A top-level array; ImageObjects of different sizes.
file: "delish.html", url: "https://www.delish.com/cooking/recipe-ideas/a19636089/creamy-tuscan-chicken-recipe/",
title: "Creamy Tuscan Chicken", site: "Delish",
image: "https://hips.hearstapps.com/hmg-prod/images/1d09a24f-5b76-49f4-9ad6-496acdc03933.jpg?crop=0.502xw:1.00xh;0.250xw,0&resize=1200:*",
ingredients: 12, steps: 4, prep: intp(10), cook: intp(0), total: intp(45), yld: "4 serving(s)",
firstStep: "In a large skillet over medium heat, heat oil.",
},
{
// A bare Recipe object; image is one URL; HTML entities in steps.
file: "foodcom.html", url: "https://www.food.com/recipe/best-banana-bread-2886",
title: "Best Banana Bread", site: "Food.com",
image: "https://img.sndimg.com/food/image/upload/q_92,fl_progressive,w_1200,c_scale/v1/img/recipes/28/86/wBZxNua1T8yfDwbfo9Fz_0S9A9315.jpg",
ingredients: 8, steps: 10, prep: intp(10), cook: intp(60), total: intp(70), yld: "1 loaf, 10 serving(s)",
firstStep: "Remove odd pots and pans from oven.",
},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
r, err := Extract(readPage(t, tt.file), tt.url)
if err != nil {
t.Fatalf("Extract: %v", err)
}
if r.Title != tt.title {
t.Errorf("Title = %q, want %q", r.Title, tt.title)
}
if r.SiteName != tt.site {
t.Errorf("SiteName = %q, want %q", r.SiteName, tt.site)
}
if r.ImageURL != tt.image {
t.Errorf("ImageURL = %q, want %q", r.ImageURL, tt.image)
}
if len(r.Ingredients) != tt.ingredients {
t.Errorf("%d ingredients, want %d", len(r.Ingredients), tt.ingredients)
}
if len(r.Steps) != tt.steps {
t.Errorf("%d steps, want %d", len(r.Steps), tt.steps)
}
if len(r.Steps) > 0 && !strings.HasPrefix(r.Steps[0], tt.firstStep) {
t.Errorf("first step = %q, want prefix %q", r.Steps[0], tt.firstStep)
}
for name, got := range map[string][2]*int{
"prep": {r.PrepMinutes, tt.prep}, "cook": {r.CookMinutes, tt.cook}, "total": {r.TotalMinutes, tt.total},
} {
if fmtMinutes(got[0]) != fmtMinutes(got[1]) {
t.Errorf("%s minutes = %s, want %s", name, fmtMinutes(got[0]), fmtMinutes(got[1]))
}
}
if r.Yield != tt.yld {
t.Errorf("Yield = %q, want %q", r.Yield, tt.yld)
}
var node map[string]any
if err := json.Unmarshal(r.JSONLD, &node); err != nil || node["name"] == nil {
t.Errorf("JSONLD is not the Recipe node: %v", err)
}
})
}
}
func TestExtractHowToSectionOrder(t *testing.T) {
r, err := Extract(readPage(t, "recipetineats.html"), "https://www.recipetineats.com/lasagna/")
if err != nil {
t.Fatal(err)
}
// Ragu (5 steps), Cheese Sauce (5), Assemble (8): each section's first
// step lands right after the previous section's last.
for i, prefix := range map[int]string{
0: "Heat oil in a large heavy based pot",
4: "The ragu is ready when the meat",
5: "Warm milk up in a saucepan",
10: "Preheat oven to 180°C/350°F.",
17: "Stand for 5 to 10 minutes",
} {
if !strings.HasPrefix(r.Steps[i], prefix) {
t.Errorf("step %d = %q, want prefix %q", i+1, r.Steps[i], prefix)
}
}
}
func TestExtractNoRecipe(t *testing.T) {
_, err := Extract(readPage(t, "budgetbytes-about.html"), "https://www.budgetbytes.com/about/")
if !errors.Is(err, ErrNoRecipe) {
t.Fatalf("err = %v, want ErrNoRecipe", err)
}
_, err = Extract([]byte("<html><head><title>x</title></head></html>"), "https://example.com/")
if !errors.Is(err, ErrNoRecipe) {
t.Fatalf("page without JSON-LD: err = %v, want ErrNoRecipe", err)
}
}
func TestExtractRefusesNonISODuration(t *testing.T) {
// Bon Appétit writes cookTime as "20 minutes".
_, err := Extract(readPage(t, "bonappetit.html"), "https://www.bonappetit.com/recipe/bas-best-chocolate-chip-cookies")
if !errors.Is(err, ErrMalformedRecipe) || !strings.Contains(err.Error(), "cookTime") {
t.Fatalf("err = %v, want ErrMalformedRecipe naming cookTime", err)
}
}
// page wraps JSON-LD scripts in a minimal HTML page.
func page(head string, jsonLD ...string) []byte {
var b strings.Builder
b.WriteString("<!DOCTYPE html><html><head>" + head)
for _, s := range jsonLD {
b.WriteString(`<script type="application/ld+json">` + s + "</script>")
}
b.WriteString("</head><body></body></html>")
return []byte(b.String())
}
func TestExtractShapes(t *testing.T) {
const pageURL = "https://www.example.com/recipes/stew/"
tests := []struct {
name string
html []byte
check func(t *testing.T, r Recipe)
}{
{
name: "type array and relative image",
html: page("", `{"@type":["Recipe","NewsArticle"],"name":"Stew","image":"/img/stew.jpg"}`),
check: func(t *testing.T, r Recipe) {
if r.ImageURL != "https://www.example.com/img/stew.jpg" {
t.Errorf("ImageURL = %q", r.ImageURL)
}
},
},
{
name: "largest image wins regardless of order",
html: page("", `{"@type":"Recipe","name":"Stew","image":[
"https://cdn.example.com/plain.jpg",
{"@type":"ImageObject","url":"small.jpg","width":300,"height":200},
{"@type":"ImageObject","contentUrl":"../big.jpg","width":"1600","height":"900"},
{"@type":"ImageObject","url":"mid.jpg","width":1200,"height":800}]}`),
check: func(t *testing.T, r Recipe) {
if r.ImageURL != "https://www.example.com/recipes/big.jpg" {
t.Errorf("ImageURL = %q", r.ImageURL)
}
},
},
{
name: "instructions as one string",
html: page("", `{"@type":"Recipe","name":"Stew","recipeInstructions":"Brown the beef.\r\n\r\nAdd the stock &amp; simmer.\nServe."}`),
check: func(t *testing.T, r Recipe) {
want := []string{"Brown the beef.", "Add the stock & simmer.", "Serve."}
if strings.Join(r.Steps, "|") != strings.Join(want, "|") {
t.Errorf("Steps = %q, want %q", r.Steps, want)
}
},
},
{
name: "sections and steps flattened in order",
html: page("", `{"@type":"Recipe","name":"Stew","recipeInstructions":[
{"@type":"HowToSection","name":"Beef","itemListElement":[
{"@type":"HowToStep","text":"one"},{"@type":"HowToStep","text":"two"}]},
{"@type":"HowToStep","text":"three"},
{"@type":"HowToSection","name":"Finish","itemListElement":["four"]}]}`),
check: func(t *testing.T, r Recipe) {
if got := strings.Join(r.Steps, " "); got != "one two three four" {
t.Errorf("Steps = %q", got)
}
},
},
{
name: "total derived from prep and cook",
html: page("", `{"@type":"Recipe","name":"Stew","prepTime":"PT15M","cookTime":"PT1H30M"}`),
check: func(t *testing.T, r Recipe) {
if fmtMinutes(r.TotalMinutes) != "105" {
t.Errorf("TotalMinutes = %s, want 105", fmtMinutes(r.TotalMinutes))
}
},
},
{
name: "total not derived from prep alone",
html: page("", `{"@type":"Recipe","name":"Stew","prepTime":"PT15M"}`),
check: func(t *testing.T, r Recipe) {
if r.TotalMinutes != nil || r.CookMinutes != nil {
t.Errorf("total %s, cook %s; want nil", fmtMinutes(r.TotalMinutes), fmtMinutes(r.CookMinutes))
}
},
},
{
name: "site name from host without publisher or og:site_name",
html: page("", `{"@type":"Recipe","name":"Stew"}`),
check: func(t *testing.T, r Recipe) {
if r.SiteName != "example.com" {
t.Errorf("SiteName = %q", r.SiteName)
}
},
},
{
name: "og:site_name with attributes in any order",
html: page(`<meta content="Our &amp; Kitchen" property="og:site_name">`, `{"@type":"Recipe","name":"Stew","publisher":{"@id":"#org"}}`),
check: func(t *testing.T, r Recipe) {
if r.SiteName != "Our & Kitchen" {
t.Errorf("SiteName = %q", r.SiteName)
}
},
},
{
name: "numeric yield and recipe in @graph beside others",
html: page("", `{"@context":"https://schema.org","@graph":[{"@type":"WebPage"},{"@type":"Recipe","name":"Stew","recipeYield":6}]}`),
check: func(t *testing.T, r Recipe) {
if r.Yield != "6" {
t.Errorf("Yield = %q", r.Yield)
}
},
},
{
name: "image and publisher resolved by @id within @graph",
html: page(`<meta property="og:site_name" content="Not this">`, `{"@context":"https://schema.org","@graph":[
{"@type":"Recipe","name":"Stew",
"image":{"@id":"https://www.example.com/#primaryimage"},
"publisher":{"@id":"https://www.example.com/#organization"}},
{"@type":"ImageObject","@id":"https://www.example.com/#primaryimage","url":"/img/stew.jpg","width":1200,"height":800},
{"@type":"Organization","@id":"https://www.example.com/#organization","name":"Our Kitchen"}]}`),
check: func(t *testing.T, r Recipe) {
if r.ImageURL != "https://www.example.com/img/stew.jpg" {
t.Errorf("ImageURL = %q", r.ImageURL)
}
if r.SiteName != "Our Kitchen" {
t.Errorf("SiteName = %q", r.SiteName)
}
},
},
{
name: "image @id resolved from another script",
html: page("", `{"@type":"Recipe","name":"Stew","image":[{"@id":"#img"}]}`,
`{"@type":"ImageObject","@id":"#img","contentUrl":"stew.jpg"}`),
check: func(t *testing.T, r Recipe) {
if r.ImageURL != "https://www.example.com/recipes/stew/stew.jpg" {
t.Errorf("ImageURL = %q", r.ImageURL)
}
},
},
{
name: "absolute schema.org type IRIs",
html: page("", `{"@type":"https://schema.org/Recipe","name":"Stew","recipeInstructions":[
{"@type":"http://schema.org/HowToStep","text":"one"}]}`),
check: func(t *testing.T, r Recipe) {
if r.Title != "Stew" || strings.Join(r.Steps, "|") != "one" {
t.Errorf("Title = %q, Steps = %q", r.Title, r.Steps)
}
},
},
{
name: "absolute type IRI in a type array",
html: page("", `{"@type":["NewsArticle","http://schema.org/Recipe"],"name":"Stew"}`),
check: func(t *testing.T, r Recipe) {
if r.Title != "Stew" {
t.Errorf("Title = %q", r.Title)
}
},
},
{
name: "null duration is absent",
html: page("", `{"@type":"Recipe","name":"Stew","prepTime":null,"cookTime":"PT10M"}`),
check: func(t *testing.T, r Recipe) {
if r.PrepMinutes != nil || r.TotalMinutes != nil || fmtMinutes(r.CookMinutes) != "10" {
t.Errorf("prep %s, cook %s, total %s", fmtMinutes(r.PrepMinutes), fmtMinutes(r.CookMinutes), fmtMinutes(r.TotalMinutes))
}
},
},
{
name: "JSON-LD text inside comments and other scripts is ignored",
html: []byte(`<html><head><!-- <script type="application/ld+json">{"@type":"Recipe","name":"Old"}</script> -->` +
`<script>var s = '<script type="application/ld+json">';</script>` +
`<SCRIPT TYPE='application/ld+json; charset=utf-8'>{"@type":"Recipe","name":"Stew"}</SCRIPT></head></html>`),
check: func(t *testing.T, r Recipe) {
if r.Title != "Stew" {
t.Errorf("Title = %q", r.Title)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, err := Extract(tt.html, pageURL)
if err != nil {
t.Fatalf("Extract: %v", err)
}
tt.check(t, r)
})
}
}
func TestExtractErrors(t *testing.T) {
tests := []struct {
name string
html []byte
url string
want error
}{
{"two recipes", page("", `{"@type":"Recipe","name":"A"}`, `{"@type":"Recipe","name":"B"}`), "https://example.com/", ErrManyRecipes},
{"invalid JSON", page("", `{"@type":"Recipe",`), "https://example.com/", ErrMalformedRecipe},
{"no name", page("", `{"@type":"Recipe"}`), "https://example.com/", ErrMalformedRecipe},
{"bad step type", page("", `{"@type":"Recipe","name":"A","recipeInstructions":[{"@type":"Thing"}]}`), "https://example.com/", ErrMalformedRecipe},
{"step without text", page("", `{"@type":"Recipe","name":"A","recipeInstructions":[{"@type":"HowToStep","name":"x"}]}`), "https://example.com/", ErrMalformedRecipe},
{"bad image width", page("", `{"@type":"Recipe","name":"A","image":{"url":"a.jpg","width":"wide"}}`), "https://example.com/", ErrMalformedRecipe},
{"empty prepTime", page("", `{"@type":"Recipe","name":"A","prepTime":""}`), "https://example.com/", ErrMalformedRecipe},
{"blank cookTime", page("", `{"@type":"Recipe","name":"A","cookTime":" "}`), "https://example.com/", ErrMalformedRecipe},
{"empty totalTime", page("", `{"@type":"Recipe","name":"A","totalTime":""}`), "https://example.com/", ErrMalformedRecipe},
{"image @id not on page", page("", `{"@type":"Recipe","name":"A","image":{"@id":"#missing"}}`), "https://example.com/", ErrMalformedRecipe},
{"image @id on two nodes", page("", `{"@type":"Recipe","name":"A","image":{"@id":"#img"}}`,
`{"@id":"#img","url":"a.jpg"}`, `{"@id":"#img","url":"b.jpg"}`), "https://example.com/", ErrMalformedRecipe},
{"non-text ingredient", page("", `{"@type":"Recipe","name":"A","recipeIngredient":[{"name":"salt"}]}`), "https://example.com/", ErrMalformedRecipe},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Extract(tt.html, tt.url)
if !errors.Is(err, tt.want) {
t.Fatalf("err = %v, want %v", err, tt.want)
}
})
}
if _, err := Extract(page("", `{"@type":"Recipe","name":"A"}`), "/relative"); err == nil {
t.Fatal("relative page URL accepted")
}
}
func TestISOMinutes(t *testing.T) {
good := map[string]int{
"PT0S": 0, "PT45M": 45, "PT1H": 60, "PT2H42M": 162, "PT240M": 240, "P1D": 1440,
"P1DT2H": 1560, "PT90S": 2, "PT1.5H": 90, "PT0,5H": 30, "P1W": 10080, " PT5M ": 5,
}
for in, want := range good {
got, err := isoMinutes(in)
if err != nil || got != want {
t.Errorf("isoMinutes(%q) = %d, %v; want %d", in, got, err, want)
}
}
for _, in := range []string{"", "P", "PT", "20 minutes", "PT5", "P1Y", "P1M", "PT1D", "P5H", "PT5M1H", "PT5M5M", "P1DT", "PTxM", "PT1..5M"} {
if got, err := isoMinutes(in); err == nil {
t.Errorf("isoMinutes(%q) = %d, want error", in, got)
}
}
}
-523
View File
@@ -1,523 +0,0 @@
// Package recipes fetches and holds real recipe pages that ground dinners.
package recipes
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"sync"
"time"
)
const (
// UserAgent identifies MealQuest to the recipe sites it fetches.
UserAgent = "MealQuest/1.0 (household meal planner; recipe fetcher)"
// MaxBodyBytes caps a fetched body. A larger page is an error, never
// silently truncated: a cut-off recipe page would parse as a wrong one.
MaxBodyBytes = 2 << 20
// MaxRedirects is how many redirects a fetch follows; each hop is
// re-checked for scheme and address.
MaxRedirects = 3
// Timeout bounds a whole fetch: rate-limit waits, every redirect hop
// and reading the body.
Timeout = 10 * time.Second
// HostInterval is the least time between two requests to one host.
HostInterval = time.Second
// CacheTTL is how long a fetched page is served from the cache.
CacheTTL = time.Hour
// CacheSize is how many pages the cache holds; the oldest is evicted
// first.
CacheSize = 64
)
var (
// ErrNotHTTPS is returned for a URL, or a redirect, that is not
// https://.
ErrNotHTTPS = errors.New("recipes: only https:// URLs are fetched")
// ErrBlockedAddress is returned when a host resolves to an address
// that is not public, such as loopback or a private network.
ErrBlockedAddress = errors.New("recipes: address is not public")
// ErrTooManyRedirects is returned after MaxRedirects redirects.
ErrTooManyRedirects = errors.New("recipes: too many redirects")
// ErrTooLarge is returned for a body over MaxBodyBytes.
ErrTooLarge = errors.New("recipes: body exceeds size cap")
// ErrTimeout is returned when a fetch runs past Timeout.
ErrTimeout = errors.New("recipes: fetch timed out")
)
// StatusError is returned for a final response whose status is not 2xx.
type StatusError struct {
URL string
Status int
}
func (e *StatusError) Error() string {
return fmt.Sprintf("recipes: GET %s: HTTP %d", e.URL, e.Status)
}
// Page is a fetched page.
type Page struct {
// URL is the final URL, after redirects.
URL string
ContentType string
Body []byte
}
// Resolver looks up a host's addresses; *net.Resolver satisfies it.
type Resolver interface {
LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
}
// dialFunc dials an address that has already passed the public-address
// check.
type dialFunc func(ctx context.Context, network, address string) (net.Conn, error)
// Fetcher retrieves recipe pages without reaching private networks. Its
// dialer resolves each host itself, refuses the connection unless every
// answer is a public address, and dials the checked address, so a second DNS
// answer cannot swap in a private one. Keep-alives are off: every request,
// redirect hops included, dials and checks afresh. Proxies from the
// environment are not used, since a proxy would dial on the Fetcher's
// behalf. Requests are HTTP/1.1, so each connection carries exactly one
// request and its first write is that request. A Fetcher is safe for
// concurrent use.
type Fetcher struct {
client *http.Client
resolver Resolver
dial dialFunc
tlsConfig *tls.Config
// timeout bounds a fetch; tests shorten it.
timeout time.Duration
// now is the clock; tests replace it.
now func() time.Time
// sleep waits d or until ctx is done; tests replace it.
sleep func(ctx context.Context, d time.Duration) error
mu sync.Mutex
// lastSent is when each host's latest request began to be written to
// its connection; a request whose write fails before any byte is sent
// hands its slot back.
lastSent map[string]time.Time
// released is closed, then replaced, whenever a slot is handed back,
// waking requests waiting for their turn.
released chan struct{}
cache map[string]cacheEntry
}
type cacheEntry struct {
page Page
fetched time.Time
}
// NewFetcher returns a Fetcher that resolves through the system resolver.
func NewFetcher() *Fetcher {
return newFetcher(net.DefaultResolver, (&net.Dialer{}).DialContext, nil)
}
// newFetcher returns a Fetcher over resolver and dial; tlsConfig, when not
// nil, replaces the default TLS settings so tests can trust their servers.
func newFetcher(resolver Resolver, dial dialFunc, tlsConfig *tls.Config) *Fetcher {
f := &Fetcher{
resolver: resolver,
dial: dial,
tlsConfig: tlsConfig,
timeout: Timeout,
now: time.Now,
sleep: sleep,
lastSent: map[string]time.Time{},
released: make(chan struct{}),
cache: map[string]cacheEntry{},
}
transport := &http.Transport{
Proxy: nil,
DialTLSContext: f.dialTLS,
DisableKeepAlives: true,
}
f.client = &http.Client{
Transport: &requestTransport{next: transport},
CheckRedirect: checkRedirect,
}
return f
}
// Fetch returns the page at rawURL, from the cache when it was fetched
// within CacheTTL. Only 2xx responses are returned and cached. The
// returned Body is the caller's own copy.
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (Page, error) {
u, err := url.Parse(rawURL)
if err != nil {
return Page{}, fmt.Errorf("recipes: parse URL: %w", err)
}
if err := checkURL(u); err != nil {
return Page{}, err
}
key := u.String()
if page, ok := f.cached(key); ok {
return page, nil
}
fetchCtx, cancel := context.WithTimeout(ctx, f.timeout)
defer cancel()
page, err := f.get(fetchCtx, key)
if err != nil {
if ctx.Err() == nil && errors.Is(fetchCtx.Err(), context.DeadlineExceeded) {
return Page{}, fmt.Errorf("%w after %s: %s", ErrTimeout, f.timeout, key)
}
return Page{}, err
}
f.store(key, page)
return page, nil
}
func (f *Fetcher) get(ctx context.Context, rawURL string) (Page, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return Page{}, fmt.Errorf("recipes: build request: %w", err)
}
req.Header.Set("User-Agent", UserAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := f.client.Do(req)
if err != nil {
return Page{}, err
}
defer resp.Body.Close()
final := resp.Request.URL.String()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return Page{}, &StatusError{URL: final, Status: resp.StatusCode}
}
if resp.ContentLength > MaxBodyBytes {
return Page{}, fmt.Errorf("%w: %s declares %d bytes", ErrTooLarge, final, resp.ContentLength)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, MaxBodyBytes+1))
if err != nil {
return Page{}, fmt.Errorf("recipes: read %s: %w", final, err)
}
if len(body) > MaxBodyBytes {
return Page{}, fmt.Errorf("%w: %s is over %d bytes", ErrTooLarge, final, MaxBodyBytes)
}
return Page{URL: final, ContentType: resp.Header.Get("Content-Type"), Body: body}, nil
}
// cached returns the page cached under key if it is younger than CacheTTL.
func (f *Fetcher) cached(key string) (Page, bool) {
f.mu.Lock()
defer f.mu.Unlock()
e, ok := f.cache[key]
if !ok {
return Page{}, false
}
if f.now().Sub(e.fetched) >= CacheTTL {
delete(f.cache, key)
return Page{}, false
}
page := e.page
page.Body = bytes.Clone(page.Body)
return page, true
}
// store caches a copy of page under key, evicting the oldest entry when
// full.
func (f *Fetcher) store(key string, page Page) {
page.Body = bytes.Clone(page.Body)
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.cache[key]; !ok && len(f.cache) >= CacheSize {
var oldest string
var oldestAt time.Time
for k, e := range f.cache {
if oldest == "" || e.fetched.Before(oldestAt) {
oldest, oldestAt = k, e.fetched
}
}
delete(f.cache, oldest)
}
f.cache[key] = cacheEntry{page: page, fetched: f.now()}
}
// waitTurn blocks until a request to host may be sent, keeping requests to
// one host at least HostInterval apart. A wait holds no reservation, and a
// request ending with ctx before its turn takes no slot. The request let
// through takes the host's slot and must be sent at once, or call release
// if sending fails, so that it delays no other request. Fetch calls it from
// turnConn's first Write, so the slot is taken after DNS, dialing and the
// TLS handshake, as the request is written.
func (f *Fetcher) waitTurn(ctx context.Context, host string) (release func(), err error) {
for {
if err := ctx.Err(); err != nil {
return nil, err
}
f.mu.Lock()
now := f.now()
last, sent := f.lastSent[host]
wait := last.Add(HostInterval).Sub(now)
if !sent || wait <= 0 {
f.lastSent[host] = now
f.mu.Unlock()
return func() { f.release(host, now, last, sent) }, nil
}
released := f.released
f.mu.Unlock()
if err := f.waitOrRelease(ctx, wait, released); err != nil {
return nil, err
}
}
}
// release hands back the slot taken at taken for a request that was not
// sent, restoring the host's previous send time, and wakes waiting requests.
func (f *Fetcher) release(host string, taken, prev time.Time, hadPrev bool) {
f.mu.Lock()
defer f.mu.Unlock()
if !f.lastSent[host].Equal(taken) {
// A later request already holds the slot.
return
}
if hadPrev {
f.lastSent[host] = prev
} else {
delete(f.lastSent, host)
}
close(f.released)
f.released = make(chan struct{})
}
// waitOrRelease sleeps for d, returning early without error when a slot is
// handed back so the caller can look again.
func (f *Fetcher) waitOrRelease(ctx context.Context, d time.Duration, released <-chan struct{}) error {
sleepCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
select {
case <-released:
cancel()
case <-sleepCtx.Done():
}
}()
err := f.sleep(sleepCtx, d)
select {
case <-released:
return nil
default:
return err
}
}
// dialPublic resolves address's host once, refuses unless every answer is
// public, then dials the checked addresses in turn.
func (f *Fetcher) dialPublic(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("recipes: dial address %q: %w", address, err)
}
var addrs []netip.Addr
if ip, err := netip.ParseAddr(host); err == nil {
addrs = []netip.Addr{ip}
} else {
answers, err := f.resolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("recipes: resolve %s: %w", host, err)
}
for _, a := range answers {
ip, ok := netip.AddrFromSlice(a.IP)
if !ok {
return nil, fmt.Errorf("recipes: resolve %s: malformed address %v", host, a.IP)
}
addrs = append(addrs, ip)
}
}
if len(addrs) == 0 {
return nil, fmt.Errorf("recipes: resolve %s: no addresses", host)
}
for _, ip := range addrs {
if !IsPublic(ip) {
return nil, fmt.Errorf("%w: %s resolves to %s", ErrBlockedAddress, host, ip)
}
}
var errs []error
for _, ip := range addrs {
conn, err := f.dial(ctx, network, net.JoinHostPort(ip.Unmap().WithZone("").String(), port))
if err == nil {
return conn, nil
}
errs = append(errs, err)
}
return nil, errors.Join(errs...)
}
// requestCtxKey carries a request's own context into its dial. The
// transport dials with a context that keeps the request's values but not
// its cancellation; the connection and the rate-limit wait must end with
// the request.
type requestCtxKey struct{}
// requestTransport hands each request's context, redirect hops included,
// to dialTLS.
type requestTransport struct {
next http.RoundTripper
}
func (t *requestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
ctx := req.Context()
return t.next.RoundTrip(req.WithContext(context.WithValue(ctx, requestCtxKey{}, ctx)))
}
// dialTLS connects to address through dialPublic and completes the TLS
// handshake, all under the request's context, and returns a turnConn whose
// first write waits for the host's rate-limit slot. ALPN offers only
// HTTP/1.1, so the connection carries this one request.
func (f *Fetcher) dialTLS(dialCtx context.Context, network, address string) (net.Conn, error) {
ctx, ok := dialCtx.Value(requestCtxKey{}).(context.Context)
if !ok {
return nil, fmt.Errorf("recipes: dial %s outside a request", address)
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("recipes: dial address %q: %w", address, err)
}
conn, err := f.dialPublic(ctx, network, address)
if err != nil {
return nil, err
}
cfg := &tls.Config{}
if f.tlsConfig != nil {
cfg = f.tlsConfig.Clone()
}
cfg.ServerName = host
cfg.NextProtos = []string{"http/1.1"}
tc := tls.Client(conn, cfg)
if err := tc.HandshakeContext(ctx); err != nil {
conn.Close()
return nil, fmt.Errorf("recipes: TLS handshake with %s: %w", host, err)
}
return &turnConn{Conn: tc, f: f, ctx: ctx, host: strings.ToLower(host)}, nil
}
// turnConn is a connection ready to carry one request. Its first Write —
// the start of the request — waits for the host's rate-limit slot, so two
// requests to one host are written at least HostInterval apart however
// long each spent resolving, dialing and in the TLS handshake. A first
// Write that sends nothing hands the slot back.
type turnConn struct {
net.Conn
f *Fetcher
ctx context.Context
host string
// started is set by the first Write; the transport writes a request
// from one goroutine, so it needs no lock.
started bool
}
func (c *turnConn) Write(p []byte) (int, error) {
if c.started {
return c.Conn.Write(p)
}
release, err := c.f.waitTurn(c.ctx, c.host)
if err != nil {
return 0, err
}
c.started = true
n, err := c.Conn.Write(p)
if n == 0 && err != nil {
release()
}
return n, err
}
// checkURL refuses anything but an absolute https:// URL with a host.
func checkURL(u *url.URL) error {
if u.Scheme != "https" {
return fmt.Errorf("%w: %q", ErrNotHTTPS, u.Redacted())
}
if u.Hostname() == "" {
return fmt.Errorf("recipes: URL %q has no host", u.Redacted())
}
return nil
}
// checkRedirect applies checkURL to every hop and stops after
// MaxRedirects. The hop's address is checked when it is dialed.
func checkRedirect(req *http.Request, via []*http.Request) error {
if len(via) > MaxRedirects {
return fmt.Errorf("%w: more than %d", ErrTooManyRedirects, MaxRedirects)
}
return checkURL(req.URL)
}
// blockedIPv4 are the IPv4 special-purpose ranges that are not globally
// reachable (IANA IPv4 Special-Purpose Address Registry). IPv4-mapped IPv6
// addresses are unmapped before the check, so these cover them too.
var blockedIPv4 = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"), // "this network"
netip.MustParsePrefix("10.0.0.0/8"), // RFC 1918
netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT
netip.MustParsePrefix("127.0.0.0/8"), // loopback
netip.MustParsePrefix("169.254.0.0/16"), // link-local
netip.MustParsePrefix("172.16.0.0/12"), // RFC 1918
netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments
netip.MustParsePrefix("192.0.2.0/24"), // documentation, TEST-NET-1
netip.MustParsePrefix("192.88.99.0/24"), // 6to4 relay anycast (deprecated)
netip.MustParsePrefix("192.168.0.0/16"), // RFC 1918
netip.MustParsePrefix("198.18.0.0/15"), // benchmarking
netip.MustParsePrefix("198.51.100.0/24"), // documentation, TEST-NET-2
netip.MustParsePrefix("203.0.113.0/24"), // documentation, TEST-NET-3
netip.MustParsePrefix("224.0.0.0/4"), // multicast
netip.MustParsePrefix("240.0.0.0/4"), // reserved, broadcast
}
// globalIPv6 is the only IPv6 range dialed: global unicast. Everything
// outside it is refused, including ::, ::1, IPv4-compatible and
// IPv4-translated forms, NAT64 (64:ff9b::/96), discard-only (100::/64),
// unique local (fc00::/7), link-local (fe80::/10) and multicast (ff00::/8).
var globalIPv6 = netip.MustParsePrefix("2000::/3")
// blockedIPv6 are the special-purpose ranges inside global unicast that are
// not public or embed an arbitrary IPv4 address.
var blockedIPv6 = []netip.Prefix{
netip.MustParsePrefix("2001::/23"), // IETF protocol assignments, Teredo, benchmarking
netip.MustParsePrefix("2001:db8::/32"), // documentation
netip.MustParsePrefix("2002::/16"), // 6to4, embeds any IPv4
netip.MustParsePrefix("3fff::/20"), // documentation
}
// IsPublic reports whether ip is a public unicast address the Fetcher may
// dial. IPv4 addresses are public unless in a special-purpose range; IPv6
// addresses must be global unicast and outside its special-purpose ranges.
func IsPublic(ip netip.Addr) bool {
if !ip.IsValid() {
return false
}
ip = ip.Unmap().WithZone("")
blocked := blockedIPv4
if ip.Is6() {
if !globalIPv6.Contains(ip) {
return false
}
blocked = blockedIPv6
}
for _, p := range blocked {
if p.Contains(ip) {
return false
}
}
return true
}
// sleep waits d or until ctx is done, returning ctx's error if it is.
func sleep(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
-721
View File
@@ -1,721 +0,0 @@
package recipes
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"sync"
"testing"
"time"
)
// stubResolver answers each host from a list of answers, one per lookup;
// the last answer repeats.
type stubResolver struct {
mu sync.Mutex
answers map[string][][]string
calls map[string]int
}
func (r *stubResolver) LookupIPAddr(_ context.Context, host string) ([]net.IPAddr, error) {
r.mu.Lock()
defer r.mu.Unlock()
seq, ok := r.answers[host]
if !ok {
return nil, fmt.Errorf("stub: no such host %s", host)
}
n := r.calls[host]
r.calls[host]++
if n >= len(seq) {
n = len(seq) - 1
}
var out []net.IPAddr
for _, s := range seq[n] {
out = append(out, net.IPAddr{IP: net.ParseIP(s)})
}
return out, nil
}
func (r *stubResolver) lookups(host string) int {
r.mu.Lock()
defer r.mu.Unlock()
return r.calls[host]
}
// testEnv is a Fetcher whose public test addresses all reach one httptest
// TLS server, and which records every address it dials.
type testEnv struct {
f *Fetcher
resolver *stubResolver
server *httptest.Server
mu sync.Mutex
dialed []string
}
// publicIP, publicIP2 and publicIPv6 are public addresses; the test dialer
// routes them to the test server and never reaches the real hosts.
const (
publicIP = "93.184.215.14"
publicIP2 = "93.184.215.20"
publicIPv6 = "2606:2800:21f::10"
)
func newTestEnv(t *testing.T, handler http.Handler, answers map[string][][]string) *testEnv {
t.Helper()
server := httptest.NewTLSServer(handler)
t.Cleanup(server.Close)
pool := x509.NewCertPool()
pool.AddCert(server.Certificate())
env := &testEnv{
resolver: &stubResolver{answers: answers, calls: map[string]int{}},
server: server,
}
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
env.mu.Lock()
env.dialed = append(env.dialed, address)
env.mu.Unlock()
host, _, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
if host == publicIP || host == publicIP2 || host == publicIPv6 {
var d net.Dialer
return d.DialContext(ctx, network, server.Listener.Addr().String())
}
return nil, fmt.Errorf("test dial: no route to %s", address)
}
env.f = newFetcher(env.resolver, dial, &tls.Config{RootCAs: pool})
// Real requests in these tests are not rate-limited unless a test
// installs a fake clock.
env.f.sleep = func(context.Context, time.Duration) error { return nil }
return env
}
func (e *testEnv) dials() []string {
e.mu.Lock()
defer e.mu.Unlock()
return append([]string(nil), e.dialed...)
}
func publicAnswers() map[string][][]string {
return map[string][][]string{"example.com": {{publicIP}}}
}
func TestFetchReturnsPage(t *testing.T) {
t.Parallel()
var gotUA string
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUA = r.Header.Get("User-Agent")
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, "<html>tacos</html>")
}), publicAnswers())
page, err := env.f.Fetch(t.Context(), "https://example.com/tacos")
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if string(page.Body) != "<html>tacos</html>" || page.ContentType != "text/html" || page.URL != "https://example.com/tacos" {
t.Errorf("page = %+v", page)
}
if gotUA != UserAgent {
t.Errorf("User-Agent = %q, want %q", gotUA, UserAgent)
}
if got := env.dials(); len(got) != 1 || got[0] != publicIP+":443" {
t.Errorf("dialed %v, want [%s:443]", got, publicIP)
}
}
func TestFetchDialsPublicIPv6(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
}), map[string][][]string{"example.com": {{publicIPv6}}})
if _, err := env.f.Fetch(t.Context(), "https://example.com/"); err != nil {
t.Fatalf("Fetch: %v", err)
}
if got := env.dials(); len(got) != 1 || got[0] != "["+publicIPv6+"]:443" {
t.Errorf("dialed %v, want [[%s]:443]", got, publicIPv6)
}
}
func TestFetchRejectsNonHTTPS(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.NotFoundHandler(), publicAnswers())
for _, raw := range []string{"http://example.com/tacos", "ftp://example.com/", "example.com/tacos", "https:///tacos"} {
_, err := env.f.Fetch(t.Context(), raw)
if err == nil {
t.Errorf("Fetch(%q) succeeded", raw)
}
if strings.HasPrefix(raw, "http://") && !errors.Is(err, ErrNotHTTPS) {
t.Errorf("Fetch(%q) error = %v, want ErrNotHTTPS", raw, err)
}
}
if got := env.dials(); len(got) != 0 {
t.Errorf("dialed %v, want nothing", got)
}
if n := env.resolver.lookups("example.com"); n != 0 {
t.Errorf("resolved example.com %d times, want 0", n)
}
}
// blockedIPs has an address in every refused range, including IPv4-mapped
// forms of the IPv4 ranges.
var blockedIPs = []string{
"127.0.0.1", "127.255.255.254", "::1", "::ffff:127.0.0.1", // loopback
"10.0.0.1", "172.16.0.1", "172.31.255.255", "192.168.1.1", // RFC 1918
"::ffff:10.1.2.3", "::ffff:172.20.0.1", "::ffff:192.168.0.10",
"169.254.169.254", "::ffff:169.254.169.254", "fe80::1", // link-local
"100.64.0.1", "100.127.255.254", "::ffff:100.100.0.1", // carrier-grade NAT
"224.0.0.1", "239.255.255.250", "ff02::1", "::ffff:224.0.0.251", // multicast
"0.0.0.0", "0.1.2.3", "::", "::ffff:0.0.0.0", // unspecified
"fc00::1", "fd12:3456:789a::1", // unique local
"255.255.255.255", "240.0.0.1", "fec0::1", // reserved, site-local
"192.0.0.1", "192.0.0.255", // IETF protocol assignments
"192.0.2.1", "198.51.100.7", "203.0.113.10", "::ffff:203.0.113.10", // documentation
"192.88.99.1", // 6to4 relay anycast
"198.18.0.1", "198.19.255.255", "::ffff:198.18.0.1", // benchmarking
"2001:db8::1", "3fff::1", // IPv6 documentation
"2001:2::1", "100::1", // IPv6 benchmarking, discard-only
"::ffff:0:a00:1", // IPv4-translated
"64:ff9b::a00:1", "64:ff9b:1::a00:1", "2002:a00:1::1", "2001:0:a00:1::1", "::a00:1",
}
func TestIsPublic(t *testing.T) {
t.Parallel()
for _, s := range blockedIPs {
if IsPublic(netip.MustParseAddr(s)) {
t.Errorf("IsPublic(%s) = true, want false", s)
}
}
for _, s := range []string{
"8.8.8.8", publicIP, "::ffff:8.8.8.8", "100.63.255.255", "100.128.0.1", "172.32.0.1",
"192.0.1.255", "192.0.3.1", "192.88.98.255", "198.17.255.255", "198.20.0.1",
"198.51.99.255", "203.0.112.255", "203.0.114.1", "223.255.255.254",
"2606:4700::1111", publicIPv6, "2001:4860:4860::8888", "2a00:1450::1",
} {
if !IsPublic(netip.MustParseAddr(s)) {
t.Errorf("IsPublic(%s) = false, want true", s)
}
}
if IsPublic(netip.Addr{}) {
t.Error("IsPublic(zero Addr) = true, want false")
}
}
func TestFetchRefusesEachBlockedRange(t *testing.T) {
t.Parallel()
for _, ip := range blockedIPs {
t.Run(ip, func(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.NotFoundHandler(), map[string][][]string{"example.com": {{ip}}})
_, err := env.f.Fetch(t.Context(), "https://example.com/")
if !errors.Is(err, ErrBlockedAddress) {
t.Fatalf("Fetch error = %v, want ErrBlockedAddress", err)
}
if got := env.dials(); len(got) != 0 {
t.Errorf("dialed %v, want nothing", got)
}
})
}
}
func TestFetchRefusesBlockedLiteralHost(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.NotFoundHandler(), nil)
for _, raw := range []string{"https://127.0.0.1/", "https://[::1]/", "https://[::ffff:10.0.0.1]/", "https://169.254.169.254/latest/meta-data/", "https://[fe80::1%25eth0]/"} {
if _, err := env.f.Fetch(t.Context(), raw); !errors.Is(err, ErrBlockedAddress) {
t.Errorf("Fetch(%q) error = %v, want ErrBlockedAddress", raw, err)
}
}
if got := env.dials(); len(got) != 0 {
t.Errorf("dialed %v, want nothing", got)
}
}
func TestFetchRefusesMixedAnswer(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.NotFoundHandler(), map[string][][]string{"example.com": {{publicIP, "10.0.0.1"}}})
if _, err := env.f.Fetch(t.Context(), "https://example.com/"); !errors.Is(err, ErrBlockedAddress) {
t.Fatalf("Fetch error = %v, want ErrBlockedAddress", err)
}
if got := env.dials(); len(got) != 0 {
t.Errorf("dialed %v, want nothing", got)
}
}
func TestFetchRefusesRedirectToPrivateHost(t *testing.T) {
t.Parallel()
answers := map[string][][]string{
"example.com": {{publicIP}},
"evil.example.com": {{"10.0.0.5"}},
}
for _, target := range []string{"https://evil.example.com/admin", "https://127.0.0.1/admin", "https://[::ffff:192.168.0.1]/"} {
t.Run(target, func(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.RedirectHandler(target, http.StatusFound), answers)
_, err := env.f.Fetch(t.Context(), "https://example.com/tacos")
if !errors.Is(err, ErrBlockedAddress) {
t.Fatalf("Fetch error = %v, want ErrBlockedAddress", err)
}
if got := env.dials(); len(got) != 1 || got[0] != publicIP+":443" {
t.Errorf("dialed %v, want only the first hop", got)
}
})
}
}
func TestFetchRefusesRedirectToHTTP(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.RedirectHandler("http://example.com/plain", http.StatusMovedPermanently), publicAnswers())
if _, err := env.f.Fetch(t.Context(), "https://example.com/"); !errors.Is(err, ErrNotHTTPS) {
t.Fatalf("Fetch error = %v, want ErrNotHTTPS", err)
}
}
// chainHandler redirects /hop/N to /hop/N-1 until /hop/0, which serves a
// page.
var chainHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var n int
if _, err := fmt.Sscanf(r.URL.Path, "/hop/%d", &n); err != nil {
http.NotFound(w, r)
return
}
if n == 0 {
fmt.Fprint(w, "arrived")
return
}
http.Redirect(w, r, fmt.Sprintf("https://recipes.example.com/hop/%d", n-1), http.StatusFound)
})
func TestFetchFollowsAtMostThreeRedirects(t *testing.T) {
t.Parallel()
answers := map[string][][]string{
"example.com": {{publicIP}},
"recipes.example.com": {{publicIP2}},
}
env := newTestEnv(t, chainHandler, answers)
page, err := env.f.Fetch(t.Context(), "https://example.com/hop/3")
if err != nil {
t.Fatalf("Fetch with 3 redirects: %v", err)
}
if page.URL != "https://recipes.example.com/hop/0" || string(page.Body) != "arrived" {
t.Errorf("page = %+v", page)
}
// Every hop is dialed, so every hop's address is checked.
if got := env.dials(); len(got) != 4 || got[0] != publicIP+":443" || got[3] != publicIP2+":443" {
t.Errorf("dialed %v, want 4 checked hops", got)
}
if _, err := env.f.Fetch(t.Context(), "https://example.com/hop/4"); !errors.Is(err, ErrTooManyRedirects) {
t.Fatalf("Fetch with 4 redirects error = %v, want ErrTooManyRedirects", err)
}
}
// TestFetchRefusesDNSSwitchToPrivate covers DNS rebinding: the host's first
// answer is public and every later one private. The first fetch dials the
// address it checked; the next lookup's private answer is refused, and the
// private address is never dialed.
func TestFetchRefusesDNSSwitchToPrivate(t *testing.T) {
t.Parallel()
answers := map[string][][]string{"example.com": {{publicIP}, {"127.0.0.1"}}}
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
}), answers)
if _, err := env.f.Fetch(t.Context(), "https://example.com/one"); err != nil {
t.Fatalf("first Fetch: %v", err)
}
if n := env.resolver.lookups("example.com"); n != 1 {
t.Fatalf("first Fetch resolved %d times, want 1 (checked IP must be dialed IP)", n)
}
_, err := env.f.Fetch(t.Context(), "https://example.com/two")
if !errors.Is(err, ErrBlockedAddress) {
t.Fatalf("second Fetch error = %v, want ErrBlockedAddress", err)
}
if got := env.dials(); len(got) != 1 || got[0] != publicIP+":443" {
t.Errorf("dialed %v, want only %s:443", got, publicIP)
}
}
func TestFetchSizeCap(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
size := MaxBodyBytes
if r.URL.Path != "/exact" {
size++
}
if r.URL.Path == "/declared" {
w.Header().Set("Content-Length", fmt.Sprint(size))
}
w.Write([]byte(strings.Repeat("a", size)))
}), publicAnswers())
page, err := env.f.Fetch(t.Context(), "https://example.com/exact")
if err != nil {
t.Fatalf("Fetch at the cap: %v", err)
}
if len(page.Body) != MaxBodyBytes {
t.Errorf("body is %d bytes, want %d", len(page.Body), MaxBodyBytes)
}
for _, path := range []string{"/declared", "/chunked"} {
if _, err := env.f.Fetch(t.Context(), "https://example.com"+path); !errors.Is(err, ErrTooLarge) {
t.Errorf("Fetch %s error = %v, want ErrTooLarge", path, err)
}
}
}
func TestFetchTimeout(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/slow-body" {
w.Write([]byte("start"))
w.(http.Flusher).Flush()
}
<-r.Context().Done()
}), publicAnswers())
env.f.timeout = 100 * time.Millisecond
for _, path := range []string{"/slow-headers", "/slow-body"} {
start := time.Now()
_, err := env.f.Fetch(t.Context(), "https://example.com"+path)
if !errors.Is(err, ErrTimeout) {
t.Errorf("Fetch %s error = %v, want ErrTimeout", path, err)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Errorf("Fetch %s took %s", path, elapsed)
}
}
if Timeout != 10*time.Second {
t.Errorf("Timeout = %s, want 10s", Timeout)
}
}
func TestFetchCallerCancelIsNotTimeout(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.NotFoundHandler(), publicAnswers())
ctx, cancel := context.WithCancel(t.Context())
cancel()
_, err := env.f.Fetch(ctx, "https://example.com/")
if !errors.Is(err, context.Canceled) || errors.Is(err, ErrTimeout) {
t.Fatalf("Fetch error = %v, want context.Canceled", err)
}
}
// fakeClock is a clock whose sleep advances it and records the wait.
type fakeClock struct {
mu sync.Mutex
t time.Time
sleeps []time.Duration
}
func (c *fakeClock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *fakeClock) sleep(_ context.Context, d time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.sleeps = append(c.sleeps, d)
c.t = c.t.Add(d)
return nil
}
func (c *fakeClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.t = c.t.Add(d)
}
func (c *fakeClock) waits() []time.Duration {
c.mu.Lock()
defer c.mu.Unlock()
return append([]time.Duration(nil), c.sleeps...)
}
func installClock(f *Fetcher) *fakeClock {
c := &fakeClock{t: time.Date(2026, 10, 5, 18, 0, 0, 0, time.UTC)}
f.now = c.now
f.sleep = c.sleep
return c
}
func TestFetchCachesByURL(t *testing.T) {
t.Parallel()
var mu sync.Mutex
hits := map[string]int{}
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
hits[r.URL.Path]++
n := hits[r.URL.Path]
mu.Unlock()
if r.URL.Path == "/missing" {
http.NotFound(w, r)
return
}
fmt.Fprintf(w, "%s #%d", r.URL.Path, n)
}), publicAnswers())
clock := installClock(env.f)
fetch := func(path string) string {
t.Helper()
page, err := env.f.Fetch(t.Context(), "https://example.com"+path)
if err != nil {
t.Fatalf("Fetch %s: %v", path, err)
}
return string(page.Body)
}
if got := fetch("/a"); got != "/a #1" {
t.Errorf("first /a = %q", got)
}
if got := fetch("/a"); got != "/a #1" {
t.Errorf("cached /a = %q, want the first response", got)
}
if got := fetch("/b"); got != "/b #1" {
t.Errorf("/b = %q, want its own response", got)
}
clock.advance(CacheTTL)
if got := fetch("/a"); got != "/a #2" {
t.Errorf("/a after CacheTTL = %q, want a refetch", got)
}
for range 2 {
var se *StatusError
if _, err := env.f.Fetch(t.Context(), "https://example.com/missing"); !errors.As(err, &se) || se.Status != http.StatusNotFound {
t.Fatalf("Fetch /missing error = %v, want StatusError 404", err)
}
}
mu.Lock()
defer mu.Unlock()
if hits["/missing"] != 2 {
t.Errorf("/missing was requested %d times, want 2 (errors are not cached)", hits["/missing"])
}
}
func TestCacheEvictsOldest(t *testing.T) {
t.Parallel()
f := newFetcher(&stubResolver{}, nil, nil)
clock := installClock(f)
for i := range CacheSize + 1 {
f.store(fmt.Sprintf("https://example.com/%d", i), Page{})
clock.advance(time.Second)
}
if len(f.cache) != CacheSize {
t.Errorf("cache holds %d pages, want %d", len(f.cache), CacheSize)
}
if _, ok := f.cached("https://example.com/0"); ok {
t.Error("oldest page was not evicted")
}
if _, ok := f.cached(fmt.Sprintf("https://example.com/%d", CacheSize)); !ok {
t.Error("newest page is missing")
}
}
func TestFetchRateLimitsPerHost(t *testing.T) {
t.Parallel()
answers := map[string][][]string{
"example.com": {{publicIP}},
"recipes.example.com": {{publicIP2}},
}
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
}), answers)
clock := installClock(env.f)
for _, raw := range []string{"https://example.com/1", "https://recipes.example.com/1", "https://example.com/2", "https://example.com/3"} {
if _, err := env.f.Fetch(t.Context(), raw); err != nil {
t.Fatalf("Fetch %s: %v", raw, err)
}
}
// example.com/2 waits a full interval; example.com/3 waits another.
// recipes.example.com has its own slot and never waits.
got := clock.waits()
if len(got) != 2 || got[0] != HostInterval || got[1] != HostInterval {
t.Errorf("waits = %v, want [%s %s]", got, HostInterval, HostInterval)
}
}
func TestFetchCacheIsNotSharedWithCallers(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "tacos")
}), publicAnswers())
for range 2 {
page, err := env.f.Fetch(t.Context(), "https://example.com/tacos")
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if string(page.Body) != "tacos" {
t.Fatalf("body = %q, want the cached page unchanged", page.Body)
}
// Overwrite the returned body: neither the fetched nor the cached
// copy may reach the cache.
copy(page.Body, "XXXXX")
}
}
// TestFetchCanceledWaitHoldsNoSlot covers a burst of fetches whose
// rate-limit waits end with their context: they must not push back the
// next request that is allowed through.
func TestFetchCanceledWaitHoldsNoSlot(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
}), publicAnswers())
clock := installClock(env.f)
if _, err := env.f.Fetch(t.Context(), "https://example.com/first"); err != nil {
t.Fatalf("first Fetch: %v", err)
}
env.f.sleep = func(context.Context, time.Duration) error { return context.DeadlineExceeded }
for i := range 5 {
if _, err := env.f.Fetch(t.Context(), fmt.Sprintf("https://example.com/burst/%d", i)); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("burst Fetch %d error = %v, want context.DeadlineExceeded", i, err)
}
}
env.f.sleep = clock.sleep
clock.advance(HostInterval)
if _, err := env.f.Fetch(t.Context(), "https://example.com/after"); err != nil {
t.Fatalf("Fetch after the burst: %v", err)
}
if got := clock.waits(); len(got) != 0 {
t.Errorf("waits = %v, want none: canceled waits held slots", got)
}
}
// TestFetchUnsentRequestHoldsNoSlot covers requests to a host with no
// previous request that are never sent — canceled before Fetch, or failing
// to connect — and checks that the next request is not delayed.
func TestFetchUnsentRequestHoldsNoSlot(t *testing.T) {
t.Parallel()
const unroutableIP = "93.184.215.99"
answers := map[string][][]string{"example.com": {{unroutableIP}, {publicIP}}}
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
}), answers)
clock := installClock(env.f)
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, err := env.f.Fetch(ctx, "https://example.com/canceled"); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled Fetch error = %v, want context.Canceled", err)
}
if _, err := env.f.Fetch(t.Context(), "https://example.com/unroutable"); err == nil || !strings.Contains(err.Error(), "no route") {
t.Fatalf("unroutable Fetch error = %v, want a dial failure", err)
}
if _, err := env.f.Fetch(t.Context(), "https://example.com/next"); err != nil {
t.Fatalf("next Fetch: %v", err)
}
if got := clock.waits(); len(got) != 0 {
t.Errorf("waits = %v, want none: unsent requests held slots", got)
}
if got := env.dials(); len(got) != 2 {
t.Errorf("dials = %v, want the unroutable and the next request only", got)
}
}
// TestReleaseWakesWaiter checks that a request waiting behind a slot that
// is handed back goes ahead at once instead of sleeping out the interval.
func TestReleaseWakesWaiter(t *testing.T) {
t.Parallel()
f := newFetcher(&stubResolver{}, nil, nil)
release, err := f.waitTurn(t.Context(), "example.com")
if err != nil {
t.Fatalf("first waitTurn: %v", err)
}
done := make(chan error, 1)
start := time.Now()
go func() {
_, err := f.waitTurn(t.Context(), "example.com")
done <- err
}()
time.Sleep(50 * time.Millisecond)
release()
select {
case err := <-done:
if err != nil {
t.Fatalf("waiting waitTurn: %v", err)
}
if waited := time.Since(start); waited >= HostInterval {
t.Errorf("waiter took %s, want less than %s", waited, HostInterval)
}
case <-time.After(HostInterval / 2):
t.Fatal("waiter still waiting after the slot was handed back")
}
}
// stallingResolver holds its first lookup until unstall is closed, then
// answers from the stub.
type stallingResolver struct {
*stubResolver
once sync.Once
stalled chan struct{}
unstall chan struct{}
}
func (r *stallingResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
first := false
r.once.Do(func() { first = true })
if first {
close(r.stalled)
<-r.unstall
}
return r.stubResolver.LookupIPAddr(ctx, host)
}
// TestFetchRateLimitsSendsNotSetup covers a request that stalls in DNS
// while a later request to the same host is sent: the stalled request must
// still be sent a full HostInterval after the other one, because the slot
// is taken when the request is written, not when it starts.
func TestFetchRateLimitsSendsNotSetup(t *testing.T) {
t.Parallel()
var clock *fakeClock
var mu sync.Mutex
sentAt := map[string]time.Time{}
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
sentAt[r.URL.Path] = clock.now()
mu.Unlock()
fmt.Fprint(w, "ok")
}), publicAnswers())
resolver := &stallingResolver{
stubResolver: env.resolver,
stalled: make(chan struct{}),
unstall: make(chan struct{}),
}
env.f.resolver = resolver
clock = installClock(env.f)
slow := make(chan error, 1)
go func() {
_, err := env.f.Fetch(t.Context(), "https://example.com/slow")
slow <- err
}()
<-resolver.stalled
if _, err := env.f.Fetch(t.Context(), "https://example.com/fast"); err != nil {
t.Fatalf("fast Fetch: %v", err)
}
close(resolver.unstall)
if err := <-slow; err != nil {
t.Fatalf("slow Fetch: %v", err)
}
mu.Lock()
defer mu.Unlock()
if gap := sentAt["/slow"].Sub(sentAt["/fast"]); gap < HostInterval {
t.Errorf("slow request sent %s after fast, want at least %s", gap, HostInterval)
}
if got := clock.waits(); len(got) != 1 || got[0] != HostInterval {
t.Errorf("waits = %v, want [%s]: only the stalled request waits", got, HostInterval)
}
}
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<!-- Trimmed from https://www.bbcgoodfood.com/recipes/easy-chicken-curry (fetched 2026-10-05): head metadata and JSON-LD only. -->
<html><head>
<title>Easy chicken curry recipe | Good Food</title>
<meta property="og:title" content="Easy chicken curry" data-testid="meta-og-title"/>
<meta property="og:site_name" content="Good Food" data-testid="meta-og-site-name"/>
<meta property="og:url" content="https://www.bbcgoodfood.com/recipes/easy-chicken-curry" data-testid="meta-og-url"/>
<meta property="og:image" content="https://images.immediate.co.uk/production/volatile/sites/30/2020/08/easy-chicken-curry-aa22a0b.jpg?resize=440%2C230" data-testid="meta-og-image"/>
<script data-testid="schema" type="application/ld+json">{"@context":"https://schema.org","@type":"VideoObject","name":"Easy Chicken Curry","contentUrl":"https://media.delight.video/0c18e53b610964fac175fe0e757adc30557e79b7/cb0e0397ffd102279027b8d892036a85e98afa8b/MEDIA/v0/HD/media.mp4","description":"This easy staple chicken curry is a fantastic recipe for family dinners. It's made with just a handful of ingredients and is enriched with creamy yogurt","embedUrl":"https://www.bbcgoodfood.com/recipes/easy-chicken-curry","thumbnailUrl":"https://media.delight.video/0c18e53b610964fac175fe0e757adc30557e79b7/cb0e0397ffd102279027b8d892036a85e98afa8b/MEDIA/v0/THUMBS/media.0000005.jpg","uploadDate":"2022-07-29T10:34:28.279Z","duration":"PT0M57S","customPosterUrl":"https://media.delight.video/0c18e53b610964fac175fe0e757adc30557e79b7/cb0e0397ffd102279027b8d892036a85e98afa8b/POSTER_USER/v0/cb0e0397ffd102279027b8d892036a85e98afa8b.jpeg"}</script>
<script data-testid="page-schema" type="application/ld+json">{"@context":"https://schema.org","@id":"https://www.bbcgoodfood.com/recipes/easy-chicken-curry#Recipe","@type":"Recipe","description":"This easy staple chicken curry is a fantastic recipe for family dinners. It's made with just a handful of ingredients and is enriched with creamy yogurt","image":[{"@type":"ImageObject","url":"https://images.immediate.co.uk/production/volatile/sites/30/2020/08/easy-chicken-curry-aa22a0b.jpg?resize=440,400","width":440,"height":400}],"mainEntityOfPage":{"@type":"WebPage","@id":"https://www.bbcgoodfood.com/recipes/easy-chicken-curry"},"name":"Easy chicken curry","url":"https://www.bbcgoodfood.com/recipes/easy-chicken-curry","author":[{"@type":"Person","name":"Esther Clark","url":"https://www.bbcgoodfood.com/author/estherclark"}],"dateModified":"2026-08-13T14:40:21+01:00","datePublished":"2019-01-25T15:26:05+00:00","headline":"Easy chicken curry","keywords":"1 of 5-a-day, Chicken, Curry, Esther Clark, Family, Family friendly, Gluten free","publisher":{"@type":"Organization","name":"Good Food","url":"https://www.bbcgoodfood.com","logo":{"@type":"ImageObject","height":325,"url":"https://images.immediate.co.uk/production/volatile/sites/30/2026/02/goodfood.logo-f37096c.png?resize=1312,325","width":1312}},"cookTime":"PT45M","nutrition":{"@type":"NutritionInformation","calories":"354 calories","fatContent":"23 grams fat","saturatedFatContent":"4 grams saturated fat","carbohydrateContent":"10 grams carbohydrates","sugarContent":"8 grams sugar","fiberContent":"3 grams fiber","proteinContent":"24 grams protein","sodiumContent":"0.6 milligram of sodium"},"prepTime":"PT5M","recipeCategory":"Dinner, Main course","recipeIngredient":["2 tbsp sunflower oil","1 onion thinly sliced","2 garlic cloves crushed","thumb-sized piece of ginger grated","6 chicken thighs boneless and skinless","3 tbsp medium spice paste (tikka works well)","400g can chopped tomatoes","100g Greek yogurt","1 small bunch of coriander leaves chopped","50g ground almonds","naan breads or cooked basmati rice, to serve"],"recipeInstructions":[{"@type":"HowToStep","text":"Heat the oil in a flameproof casserole dish or large frying pan over a medium heat. Add the onion and a generous pinch of salt and fry for 8-10 mins, or until the onion has turned golden brown and sticky."},{"@type":"HowToStep","text":"Add the garlic and ginger, cooking for a further minute."},{"@type":"HowToStep","text":"Chop the chicken into chunky 3cm pieces, add to the pan and fry for 5 mins before stirring through the spice paste and tomatoes, along with 250ml water. Bring to the boil, lower to a simmer and cook on a gentle heat uncovered for 25-30 mins or until rich and slightly reduced."},{"@type":"HowToStep","text":"Stir though the yogurt, coriander and ground almonds, season and serve with warm naan or fluffy basmati rice."}],"recipeYield":4,"suitableForDiet":"https://schema.org/GlutenFreeDiet","totalTime":"PT50M"}</script>
<script type="application/ld+json">{"@context":"https://schema.org/","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://www.bbcgoodfood.com/"},{"@type":"ListItem","position":2,"name":"Recipes","item":"https://www.bbcgoodfood.com/recipes"},{"@type":"ListItem","position":3,"name":"Easy chicken curry"}]}</script>
<script type="application/ld+json">{"@context":"https://schema.org/","@id":"https://www.bbcgoodfood.com/recipes/easy-chicken-curry#Recipe","aggregateRating":{"@type":"AggregateRating","ratingValue":5,"reviewCount":363,"bestRating":5,"worstRating":1}}</script>
</head><body><main>(page body trimmed)</main></body></html>
File diff suppressed because one or more lines are too long
-10
View File
@@ -1,10 +0,0 @@
<!DOCTYPE html>
<!-- Trimmed from https://www.budgetbytes.com/about/ (fetched 2026-10-05): head metadata and JSON-LD only. -->
<html><head>
<title>The Budget Bytes Story - BudgetBytes.com</title>
<meta property="og:title" content="The Budget Bytes Story - BudgetBytes.com" />
<meta property="og:url" content="https://www.budgetbytes.com/about/" />
<meta property="og:site_name" content="Budget Bytes" />
<meta property="og:image" content="https://www.budgetbytes.com/wp-content/uploads/2025/04/Budget-Bytes-Group-Shot_Web.jpg" />
<script type="application/ld+json" class="yoast-schema-graph">{"@context":"https:\/\/schema.org","@graph":[{"@type":"WebPage","@id":"https:\/\/www.budgetbytes.com\/about\/","url":"https:\/\/www.budgetbytes.com\/about\/","name":"The Budget Bytes Story - BudgetBytes.com","isPartOf":{"@id":"https:\/\/www.budgetbytes.com\/#website"},"primaryImageOfPage":{"@id":"https:\/\/www.budgetbytes.com\/about\/#primaryimage"},"image":{"@id":"https:\/\/www.budgetbytes.com\/about\/#primaryimage"},"thumbnailUrl":"https:\/\/www.budgetbytes.com\/wp-content\/uploads\/2025\/04\/Budget-Bytes-Group-Shot_Web.jpg","datePublished":"2013-05-02T18:19:55+00:00","dateModified":"2026-07-20T15:36:17+00:00","description":"Beth Moncel created Budget Bytes in 2009 to help people learn how to cook and create delicious recipes designed for small budgets.","inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/www.budgetbytes.com\/about\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.budgetbytes.com\/about\/#primaryimage","url":"https:\/\/www.budgetbytes.com\/wp-content\/uploads\/2025\/04\/Budget-Bytes-Group-Shot_Web.jpg","contentUrl":"https:\/\/www.budgetbytes.com\/wp-content\/uploads\/2025\/04\/Budget-Bytes-Group-Shot_Web.jpg","width":1200,"height":800,"caption":"The Budget Bytes team that develops recipes out of our Nashville studio. Left to right: Rebecca Denton (Photographer), Jess Rice (Recipe Development Manager), Jennie Alley (Senior Recipe Developer), and Melissa Nolan (Senior Recipe Developer). We develop, test, and photograph each and every recipe on Budget Bytes from Nashville Tennessee."},{"@type":"WebSite","@id":"https:\/\/www.budgetbytes.com\/#website","url":"https:\/\/www.budgetbytes.com\/","name":"Budget Bytes","description":"Delicious Recipes Designed for Small Budgets","publisher":{"@id":"https:\/\/www.budgetbytes.com\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/www.budgetbytes.com\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/www.budgetbytes.com\/#organization","name":"Budget Bytes","url":"https:\/\/www.budgetbytes.com\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.budgetbytes.com\/#\/schema\/logo\/image\/","url":"https:\/\/www.budgetbytes.com\/wp-content\/uploads\/2024\/11\/budgetbytes_square_icon_transparent-300x300-1.png","contentUrl":"https:\/\/www.budgetbytes.com\/wp-content\/uploads\/2024\/11\/budgetbytes_square_icon_transparent-300x300-1.png","width":300,"height":300,"caption":"Budget Bytes"},"image":{"@id":"https:\/\/www.budgetbytes.com\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/www.facebook.com\/budgetbytes1\/?ref=hl","https:\/\/x.com\/Budget_Bytes","https:\/\/instagram.com\/budgetbytes\/","https:\/\/www.pinterest.com\/budgetbytes\/","https:\/\/www.youtube.com\/channel\/UC17vdVOZBVxTSDcldUBBlRg"]}]}</script>
</head><body><main>(page body trimmed)</main></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-9
View File
@@ -1,9 +0,0 @@
<!DOCTYPE html>
<!-- Trimmed from https://www.food.com/recipe/best-banana-bread-2886 (fetched 2026-10-05): head metadata and JSON-LD only; the 80 KB review array removed from the Recipe node. -->
<html><head>
<title>Best Banana Bread Recipe - Food.com</title>
<meta name="og:title" content="Best Banana Bread Recipe - Food.com"/>
<meta name="og:image" content="https://img.sndimg.com/food/image/upload/w_555,h_416,c_fit,fl_progressive,q_95/v1/img/recipes/28/86/wBZxNua1T8yfDwbfo9Fz_0S9A9315.jpg"/>
<meta name="og:url" content=""/>
<script type="application/ld+json">{"@context":"http://schema.org","@type":"Recipe","mainEntityOfPage":"true","name":"Best Banana Bread","author":"lkadlec","cookTime":"PT1H","prepTime":"PT10M","totalTime":"PT1H10M","datePublished":"1999-09-26T20:49Z","description":"Make and share this Best Banana Bread recipe from Food.com.","image":"https://img.sndimg.com/food/image/upload/q_92,fl_progressive,w_1200,c_scale/v1/img/recipes/28/86/wBZxNua1T8yfDwbfo9Fz_0S9A9315.jpg","recipeCategory":"Quick Breads","keywords":"Breads,Fruit,Oven,< 4 Hours","recipeIngredient":["1/2 cup butter, softened ","1 cup granulated sugar","2 eggs, beaten ","3 bananas, finely crushed (for serious and extreme moist and delicious, try 4 bananas)","1 1/2 cups all-purpose flour","1 teaspoon baking soda","1/2 teaspoon salt","1/2 teaspoon vanilla (optional)"],"aggregateRating":{"@type":"AggregateRating","ratingValue":"5.0","reviewCount":"2355"},"nutrition":{"@type":"NutritionInformation","calories":"272.8","fatContent":"10.5","saturatedFatContent":"6.2","cholesterolContent":"61.6","sodiumContent":"338.3","carbohydrateContent":"42.5","fiberContent":"1.4","sugarContent":"24.4","proteinContent":"3.7"},"recipeInstructions":[{"@type":"HowToStep","text":"Remove odd pots and pans from oven."},{"@type":"HowToStep","text":"Preheat oven to 350&ordm; / 180&ordm;."},{"@type":"HowToStep","text":"Cream together butter and sugar."},{"@type":"HowToStep","text":"Add eggs and crushed bananas."},{"@type":"HowToStep","text":"Combine well."},{"@type":"HowToStep","text":"Sift together flour, soda and salt. Add to creamed mixture. Add vanilla."},{"@type":"HowToStep","text":"Mix just until combined. Do not overmix."},{"@type":"HowToStep","text":"Pour into greased and floured loaf pan."},{"@type":"HowToStep","text":"Bake at 350&ordm; / 180&ordm; for 55 minutes."},{"@type":"HowToStep","text":"Keeps well, refrigerated."}],"recipeYield":"1 loaf, 10 serving(s)","publisher":{"@type":"Organization","name":"Food.com","logo":{"@type":"ImageObject","url":"https://geniuskitchen.sndimg.com/fdc-new/img/FDC-Logo.png"},"url":"https://www.food.com"},"video":[{"@type":"VideoObject","name":"Best Banana Bread","duration":"42S","thumbnailUrl":"https://img.sndimg.com/food/image/upload/w_1024,h_576,c_fit/v1/img/mpx/5458761_16x9.jpg","contentUrl":"https://www.food.com/recipe/best-banana-bread-2886"}]}</script>
</head><body><main>(page body trimmed)</main></body></html>
-79
View File
@@ -1,79 +0,0 @@
<!DOCTYPE html>
<!-- Trimmed from https://www.kingarthurbaking.com/recipes/classic-sandwich-bread-recipe (fetched 2026-10-05): head metadata and JSON-LD only. -->
<html><head>
<title>Classic Sandwich Bread Recipe | King Arthur Baking</title>
<meta property="og:site_name" content="King Arthur Baking" />
<meta property="og:url" content="https://www.kingarthurbaking.com/recipes/classic-sandwich-bread-recipe" />
<meta property="og:title" content="Classic Sandwich Bread" />
<meta property="og:image" content="https://www.kingarthurbaking.com/sites/default/files/2026-02/Classic-Sandwich-Bread-12.jpg" />
<script type="application/ld+json">{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Recipe",
"keywords": "Sandwich bread;;Tangzhong",
"name": "Classic Sandwich Bread",
"description": "Made with all of the ingredients that make homemade white bread superior to storebought — butter, milk, and high-quality flour — this sandwich bread recipe is our version of a classic, perfected. The soft and tender interior makes this loaf ideal for sandwiches, toast, or even French toast. It\u0027s a timeless recipe you\u0027ll come back to time and time again.",
"recipeYield": [
"16",
"1 loaf"
],
"datePublished": "December 31, 2007 at 7:00pm",
"prepTime": "PT12M",
"cookTime": "PT40M",
"totalTime": "PT2H42M",
"recipeInstructions": [
"Weigh your flour; or measure it by gently spooning it into a cup, then sweeping off any excess.",
"In a large bowl, combine all of the ingredients. Mix and knead everything together — by hand, mixer, or bread machine set on the dough cycle — until you\u0027ve made a smooth dough. If you\u0027re kneading in a stand mixer, it should take 5 to 7 minutes at medium-low speed, and the dough should barely clean the sides of the bowl, perhaps sticking a bit at the bottom. In a bread machine (or by hand), knead the dough for about 6 to 8 minutes, until it forms a smooth ball. ",
"Transfer the dough to a lightly greased bowl, cover the bowl, and allow the dough to rise until puffy though not necessarily doubled in bulk, about 1 to 2 hours, depending on the warmth of your kitchen. If you\u0027re using a bread machine, allow the machine to complete its cycle, then leave the dough in the machine until it\u0027s doubled in bulk, perhaps an additional 30 minutes or so.",
"Gently deflate the dough and transfer it to a lightly oiled work surface. Shape the dough into an 8\u0022 log.",
"Place the log in a lightly greased 8 1/2\u0022 x 4 1/2\u0022 loaf pan, cover the pan loosely with lightly greased plastic wrap, and allow the bread to rise for about 60 minutes, until it\u0027s domed about 1\u0022 above the edge of the pan. A finger pressed into the dough should leave a mark that rebounds slowly. Towards the end of the rise, preheat your oven to 350°F.",
"Bake the bread for 30 to 35 minutes, until it\u0027s light golden brown. Test it for doneness by removing it from the pan and thumping it on the bottom (it should sound hollow), or by measuring its interior temperature with a digital thermometer (it should register 190°F at the center of the loaf).",
"Remove the bread from the oven, and cool it on a rack before slicing. Store the bread in a plastic bag at room temperature for several days; freeze for longer storage."
],
"recipeIngredient": [
"3 cups (360g) King Arthur Unbleached All-Purpose Flour",
"1/2 cup (113g) milk, (skim, 1%, 2% or whole, your choice)*",
"1/2 to 2/3 cup (113g to 152g) hot water, enough to make a soft, smooth dough* (see \u0022tips,\u0022 below)",
"4 tablespoons (57g) melted butter or 1/4 cup (50g) vegetable oil",
"2 tablespoons (25g) granulated sugar",
"1 1/4 teaspoons (8g) table salt",
"2 teaspoons instant yeast"
],
"publisher": {
"@type": "Organization",
"name": "King Arthur Baking",
"url": "https://www.kingarthurbaking.com/"
},
"author": {
"@type": "Person",
"name": "PJ Hamel",
"url": "https://www.kingarthurbaking.com/author/pj-hamel"
},
"image": {
"@type": "ImageObject",
"url": "https://www.kingarthurbaking.com/sites/default/files/2026-02/Classic-Sandwich-Bread-12.jpg"
},
"nutrition": {
"@type": "NutritionInformation",
"servingSize": "43g",
"calories": "120 calories",
"carbohydrateContent": "19g",
"cholesterolContent": "10mg",
"fiberContent": "1g",
"proteinContent": "3g",
"sodiumContent": "190mg",
"sugarContent": "2g",
"fatContent": "3g",
"saturatedFatContent": "2g",
"transFatContent": "0g"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.60",
"ratingCount": "681"
}
}
]
}</script>
</head><body><main>(page body trimmed)</main></body></html>
File diff suppressed because one or more lines are too long
+2
View File
@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#5a358c" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>MealQuest</title>
</head>
<body>
+19
View File
@@ -0,0 +1,19 @@
{
"name": "MealQuest",
"short_name": "MealQuest",
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#5a358c",
"background_color": "#f9f6f1",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+5227
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -5,9 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run lint:colors && tsc --noEmit && vite build && npm run check:dist-fonts",
"build": "npm run lint:colors && npm run typecheck && vite build && npm run check:dist-fonts",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.sw.json",
"test": "npm run lint:colors && vitest run",
"lint:colors": "node scripts/lint-colors.mjs",
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
@@ -29,6 +29,7 @@
"jsdom": "^30.1.1",
"typescript": "^7.0.2",
"vite": "^8.3.2",
"vite-plugin-pwa": "^2.0.0",
"vitest": "^5.0.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

+186
View File
@@ -0,0 +1,186 @@
// Command generate-icons writes the PWA icons in web/public from the
// MealQuest logo. Run it from the repository root:
//
// go run ./web/scripts/generate-icons
//
// The logo fills the regular icons edge to edge. The maskable icon shrinks it
// onto the colour of its own parchment, so the banner stays inside the
// circular safe zone (radius 40% of the icon) that launchers may crop to.
package main
import (
"fmt"
"image"
"image/color"
"image/png"
"math"
"os"
"path/filepath"
"slices"
"golang.org/x/image/draw"
)
const (
source = "design/logos/logo_v1_2Oct2026.jpg"
public = "web/public"
maskableScale = 0.66
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "generate-icons:", err)
os.Exit(1)
}
}
func run() error {
logo, err := load(source)
if err != nil {
return err
}
if b := logo.Bounds(); b.Dx() != b.Dy() {
return fmt.Errorf("%s is %dx%d; the logo must be square", source, b.Dx(), b.Dy())
}
outputs := []struct {
path string
image *image.RGBA
}{
{"icons/icon-192.png", resized(logo, 192)},
{"icons/icon-512.png", resized(logo, 512)},
{"icons/icon-maskable-512.png", maskable(logo, 512)},
{"apple-touch-icon.png", resized(logo, 180)},
}
for _, out := range outputs {
path := filepath.Join(public, out.path)
if err := save(path, out.image); err != nil {
return err
}
fmt.Printf("wrote %s (%dx%d)\n", path, out.image.Rect.Dx(), out.image.Rect.Dy())
}
return nil
}
// load decodes the logo. The file is a PNG despite its .jpg name, so the
// format is sniffed rather than taken from the extension.
func load(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
return img, nil
}
func save(path string, img image.Image) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
enc := png.Encoder{CompressionLevel: png.BestCompression}
if err := enc.Encode(f, img); err != nil {
f.Close()
return fmt.Errorf("encode %s: %w", path, err)
}
return f.Close()
}
func resized(logo image.Image, size int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, size, size))
draw.CatmullRom.Scale(dst, dst.Rect, logo, logo.Bounds(), draw.Src, nil)
return dst
}
func maskable(logo image.Image, size int) *image.RGBA {
// The padding is the median colour of the logo's top edge, which is bare
// parchment; a feathered edge hides the seam.
b := logo.Bounds()
canvas := image.NewRGBA(image.Rect(0, 0, size, size))
draw.Draw(canvas, canvas.Rect, image.NewUniform(medianColour(logo, image.Rect(b.Min.X, b.Min.Y, b.Max.X, b.Min.Y+b.Dy()/40))), image.Point{}, draw.Src)
inner := int(math.Round(float64(size) * maskableScale))
offset := (size - inner) / 2
margin := inner / 24
at := image.Rect(offset, offset, offset+inner, offset+inner)
draw.DrawMask(canvas, at, resized(logo, inner), image.Point{}, feather(inner, margin), image.Point{}, draw.Over)
return canvas
}
// medianColour is the per-channel median of the pixels in r.
func medianColour(img image.Image, r image.Rectangle) color.RGBA {
var channels [3][]uint8
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
c := color.RGBAModel.Convert(img.At(x, y)).(color.RGBA)
channels[0] = append(channels[0], c.R)
channels[1] = append(channels[1], c.G)
channels[2] = append(channels[2], c.B)
}
}
var median [3]uint8
for i, values := range channels {
slices.Sort(values)
median[i] = values[len(values)/2]
}
return color.RGBA{R: median[0], G: median[1], B: median[2], A: 0xff}
}
// feather is an opaque square inset by margin on a size×size mask, its edge
// softened by a Gaussian blur with a standard deviation of margin/2.
func feather(size, margin int) *image.Alpha {
values := make([]float64, size*size)
for y := margin; y <= size-margin && y < size; y++ {
for x := margin; x <= size-margin && x < size; x++ {
values[y*size+x] = 255
}
}
kernel := gaussian(float64(margin) / 2)
values = blur(values, size, kernel, 1, size)
values = blur(values, size, kernel, size, 1)
mask := image.NewAlpha(image.Rect(0, 0, size, size))
for i, v := range values {
mask.Pix[i] = uint8(math.Round(min(max(v, 0), 255)))
}
return mask
}
// gaussian is a normalised kernel reaching three standard deviations.
func gaussian(sigma float64) []float64 {
radius := int(math.Ceil(3 * sigma))
kernel := make([]float64, 2*radius+1)
var sum float64
for i := range kernel {
d := float64(i - radius)
kernel[i] = math.Exp(-d * d / (2 * sigma * sigma))
sum += kernel[i]
}
for i := range kernel {
kernel[i] /= sum
}
return kernel
}
// blur convolves each line of a size×size grid with kernel: step is the
// distance between neighbours along a line and stride between lines. Samples
// beyond the edge repeat the edge.
func blur(values []float64, size int, kernel []float64, step, stride int) []float64 {
radius := len(kernel) / 2
out := make([]float64, len(values))
for line := range size {
for i := range size {
var v float64
for k, w := range kernel {
j := min(max(i+k-radius, 0), size-1)
v += w * values[line*stride+j*step]
}
out[line*stride+i*step] = v
}
}
return out
}
+103 -8
View File
@@ -1,23 +1,28 @@
import { render, screen } from '@testing-library/react'
import { act, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApiClient } from './api/index.ts'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApiClient, createVerifiedSession } from './api/index.ts'
import { createNetworkStatus } from './pwa/networkStatus.ts'
import { mockViewport } from './test/matchMedia.ts'
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
import App, { routes } from './App.tsx'
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function renderAt(path: string, location = fakeLocation(path)) {
render(
return { location, ...renderAppAt(path, location) }
}
function renderAppAt(path: string, location = fakeLocation(path)) {
return render(
<MemoryRouter initialEntries={[path]}>
<App api={createApiClient(location)} />
<App api={createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))} serviceWorker={undefined} />
</MemoryRouter>,
)
return location
}
describe('App routes', () => {
@@ -70,7 +75,7 @@ describe('App bootstrap', () => {
it('sends a signed-out visitor to sign-in and never renders the routes', async () => {
const fetchMock = stubFetch(errorResponse(401, 'unauthenticated'))
const location = renderAt('/discover', fakeLocation('/discover', '?swipe=1'))
const { location } = renderAt('/discover', fakeLocation('/discover', '?swipe=1'))
await vi.waitFor(() => expect(location.assign).toHaveBeenCalledWith('/auth/login?return=%2Fdiscover%3Fswipe%3D1'))
expect(fetchMock).toHaveBeenCalledTimes(1)
@@ -80,7 +85,7 @@ describe('App bootstrap', () => {
it('shows the not-in-this-household screen on a 403', async () => {
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
const location = renderAt('/')
const { location } = renderAt('/')
expect(await screen.findByText(/not in this household/)).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument()
@@ -99,3 +104,93 @@ describe('App bootstrap', () => {
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
})
})
describe('App offline bootstrap', () => {
let onLine: boolean
function goOffline() {
onLine = false
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
}
// visitOnline is an earlier online visit on this device: /me answers with a
// member, then the app is closed.
async function visitOnline() {
stubFetch(jsonResponse(200, ME))
const { unmount } = renderAt('/')
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
unmount()
}
beforeEach(() => {
mockViewport(390)
onLine = true
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
})
it.each([
['/plan', 'Plan'],
['/dinner/01J9ZK', 'Dinner'],
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
await visitOnline()
goOffline()
renderAt(path)
expect(await screen.findByRole('heading', { level: 1, name: title })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
await visitOnline()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
renderAt('/plan')
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
})
it('reports the failure offline when this device never verified a session', async () => {
goOffline()
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
it('reports the failure offline after the session was refused', async () => {
await visitOnline()
stubFetch(errorResponse(401, 'unauthenticated'))
const { location, unmount } = renderAt('/plan')
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
unmount()
goOffline()
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
it('loads /me again on reconnect and refuses writes until it has', async () => {
await visitOnline()
goOffline()
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<MemoryRouter initialEntries={['/plan']}>
<App api={api} serviceWorker={undefined} />
</MemoryRouter>,
)
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
const fetchMock = stubFetch(jsonResponse(200, ME))
onLine = true
act(() => {
window.dispatchEvent(new Event('online'))
})
await vi.waitFor(() => expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' }))
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
})
+11 -1
View File
@@ -4,6 +4,8 @@ import { Route, Routes } from 'react-router'
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
import { AuthBootstrap } from './AuthBootstrap.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
import { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { AppShell } from './shell/AppShell.tsx'
// Every route the app serves, with the heading its placeholder page shows.
@@ -40,11 +42,17 @@ export function AppRoutes() {
export type AppProps = {
api: ApiClient
// navigator.serviceWorker, or undefined where there is none (and in tests).
serviceWorker: UpdateContainer | undefined
}
function reloadPage() {
window.location.reload()
}
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
// MemoryRouter in tests).
export default function App({ api }: AppProps) {
export default function App({ api, serviceWorker }: AppProps) {
const [queryClient] = useState(createQueryClient)
return (
<ApiProvider client={api}>
@@ -52,6 +60,8 @@ export default function App({ api }: AppProps) {
<AuthBootstrap>
<AppRoutes />
</AuthBootstrap>
<OfflinePill />
<UpdatePrompt container={serviceWorker} reload={reloadPage} />
</QueryClientProvider>
</ApiProvider>
)
+19 -3
View File
@@ -1,4 +1,4 @@
import { useSyncExternalStore, type ReactNode } from 'react'
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ApiError, ME_PATH, useApi } from './api/index.ts'
import { NotInHouseholdPage } from './pages/NotInHouseholdPage.tsx'
@@ -11,13 +11,29 @@ export const meQueryKey = [ME_PATH] as const
// AuthBootstrap loads GET /api/v1/me before rendering children. A 401 is
// already navigating to sign-in, so the skeleton stays up; an account outside
// the household sees only the "not in this household" screen.
//
// When /me cannot reach the server and it last answered with a member on this
// device, the saved pages open read-only: the service worker answers their
// reads from its cache, and every write is refused for want of a CSRF token.
// /me runs even while the browser reports offline, so that case fails fast
// instead of waiting, and it is fetched again when the browser is back online.
export function AuthBootstrap({ children }: { children: ReactNode }) {
const api = useApi()
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity })
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity, networkMode: 'always' })
// fetch rejects with a TypeError only when the network fails.
const savedPages = me.isError && me.error instanceof TypeError && api.session.verified()
const { refetch } = me
useEffect(() => {
if (!savedPages) return
return api.network.subscribe(() => {
if (!api.network.offline()) void refetch()
})
}, [savedPages, api, refetch])
if (forbidden) return <NotInHouseholdPage />
if (me.isSuccess) return children
if (me.isSuccess || savedPages) return children
if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) {
return (
<main className={styles.screen}>
+83 -13
View File
@@ -1,15 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts'
import { ApiError, createApiClient, CSRF_HEADER } from './index.ts'
import { ApiError, createApiClient, createVerifiedSession, CSRF_HEADER, VERIFIED_SESSION_KEY } from './index.ts'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('apiFetch', () => {
it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => {
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, { ok: 1 }), jsonResponse(200, { ok: 2 }))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 })
@@ -29,7 +32,7 @@ describe('apiFetch', () => {
it('refuses a non-GET request before /me has loaded', async () => {
const fetchMock = stubFetch()
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
expect(fetchMock).not.toHaveBeenCalled()
})
@@ -47,7 +50,7 @@ describe('apiFetch', () => {
'api/v1/me',
])('refuses %s without sending it or the CSRF token', async (path) => {
const fetchMock = stubFetch(jsonResponse(200, ME))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow(
@@ -60,7 +63,7 @@ describe('apiFetch', () => {
it('rejects with a typed ApiError from the error body', async () => {
stubFetch(errorResponse(503, 'unavailable', true))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e)
expect(error).toBeInstanceOf(ApiError)
expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true })
@@ -68,14 +71,14 @@ describe('apiFetch', () => {
it('rejects a response without the API error body', async () => {
stubFetch(new Response('<html>bad gateway</html>', { status: 502 }))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.apiFetch('/api/v1/jobs/j1')).rejects.toThrow('GET /api/v1/jobs/j1: 502 response is not JSON')
})
it('on 401 navigates to sign-in with the current path and search to return to', async () => {
stubFetch(errorResponse(401, 'unauthenticated'))
const location = fakeLocation('/plan/shopping', '?week=2026-10-05')
const api = createApiClient(location)
const api = createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 })
expect(location.assign).toHaveBeenCalledExactlyOnceWith(
@@ -90,7 +93,7 @@ describe('apiFetch', () => {
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
jsonResponse(201, { id: 'j1' }),
)
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({
@@ -111,7 +114,7 @@ describe('apiFetch', () => {
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
errorResponse(403, 'csrf'),
)
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({
@@ -124,7 +127,7 @@ describe('apiFetch', () => {
it('marks any other 403 as not in this household and notifies subscribers', async () => {
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
const listener = vi.fn()
api.subscribe(listener)
@@ -136,14 +139,14 @@ describe('apiFetch', () => {
it('does not take a 403 without the API error body as not in this household', async () => {
stubFetch(new Response('<html>denied by proxy</html>', { status: 403 }))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON')
expect(api.forbidden()).toBe(false)
})
it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => {
const fetchMock = stubFetch(errorResponse(403, 'csrf'))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' })
expect(fetchMock).toHaveBeenCalledTimes(1)
})
@@ -152,7 +155,74 @@ describe('apiFetch', () => {
describe('loadMe', () => {
it('rejects a /me response without a CSRF token', async () => {
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token')
})
})
describe('verified session', () => {
it('is remembered when /me answers with a member', async () => {
stubFetch(jsonResponse(200, ME))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
expect(api.session.verified()).toBe(false)
await api.loadMe()
expect(api.session.verified()).toBe(true)
expect(localStorage.getItem(VERIFIED_SESSION_KEY)).toBe('1')
})
it('stores nothing from the /me response', async () => {
stubFetch(jsonResponse(200, ME))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
expect(Object.keys(localStorage)).toEqual([VERIFIED_SESSION_KEY])
expect(JSON.stringify({ ...localStorage })).not.toContain(ME.csrfToken)
})
it('is not remembered for a /me response that is not a member', async () => {
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.loadMe()).rejects.toThrow()
expect(api.session.verified()).toBe(false)
})
it('is forgotten on a 401', async () => {
stubFetch(jsonResponse(200, ME), errorResponse(401, 'unauthenticated'))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
await expect(api.apiFetch('/api/v1/plans')).rejects.toMatchObject({ status: 401 })
expect(api.session.verified()).toBe(false)
})
it('is forgotten when the account is not in this household', async () => {
stubFetch(jsonResponse(200, ME), errorResponse(403, 'forbidden'))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
await expect(api.apiFetch('/api/v1/plans')).rejects.toMatchObject({ status: 403 })
expect(api.session.verified()).toBe(false)
})
it('is kept when a request fails with a network error', async () => {
stubFetch(jsonResponse(200, ME))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
await expect(api.loadMe()).rejects.toThrow(TypeError)
expect(api.session.verified()).toBe(true)
})
})
describe('network status', () => {
it('counts a response the service worker answered from its cache as offline', async () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
const cached = jsonResponse(200, { id: '01J9ZR' })
cached.headers.set(cacheFallbackHeader, '1')
stubFetch(cached, jsonResponse(200, { id: '01J9ZR' }))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' })
expect(api.network.offline()).toBe(true)
await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' })
expect(api.network.offline()).toBe(false)
})
})
+44 -8
View File
@@ -1,4 +1,7 @@
import type { NetworkStatus } from '../pwa/networkStatus.ts'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { ApiError, isRecord, parseApiError } from './errors.ts'
import type { VerifiedSession } from './verifiedSession.ts'
// CSRF_HEADER carries the session's CSRF token on every non-GET request.
export const CSRF_HEADER = 'X-MealQuest-CSRF'
@@ -36,7 +39,7 @@ export type ApiLocation = Pick<Location, 'origin' | 'pathname' | 'search' | 'ass
export type ApiRequest = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
// Sent as JSON.
// Sent as JSON. A GET must not have one.
body?: unknown
}
@@ -51,11 +54,18 @@ export type ApiClient = {
// account is not in this household; subscribe is notified when it changes.
forbidden(): boolean
subscribe(listener: () => void): () => void
// network learns of every request that failed with a network error or was
// answered from the service worker's cache, and of every request the
// server answered.
network: NetworkStatus
// session remembers, on this device, whether /me last answered with a
// member; offline, AuthBootstrap opens the saved pages only if it did.
session: VerifiedSession
}
// createApiClient builds the app's API client. location is window.location in
// the app: a 401 sends it to /auth/login with the current path to return to.
export function createApiClient(location: ApiLocation): ApiClient {
export function createApiClient(location: ApiLocation, network: NetworkStatus, session: VerifiedSession): ApiClient {
let csrfToken: string | null = null
let refreshing: Promise<Me> | null = null
let forbidden = false
@@ -70,12 +80,28 @@ export function createApiClient(location: ApiLocation): ApiClient {
}
headers[CSRF_HEADER] = csrfToken
}
return fetch(path, {
method,
headers,
credentials: 'same-origin',
body: body === undefined ? undefined : JSON.stringify(body),
})
// Serialize before fetching so a body that cannot be sent is its own error
// and never counts as a network failure.
let json: string | undefined
try {
json = body === undefined ? undefined : JSON.stringify(body)
} catch (error) {
throw new Error(`${method} ${path}: request body is not serializable as JSON`, { cause: error })
}
let response: Response
try {
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json })
} catch (error) {
// fetch rejects with a TypeError when the network fails; an abort is a
// DOMException and says nothing about the network.
if (error instanceof TypeError) network.requestFailed()
throw error
}
// The service worker marks a response it answered from its cache because
// the server could not be reached in time.
if (response.headers.has(cacheFallbackHeader)) network.requestFailed()
else network.requestSucceeded()
return response
}
// refreshMe shares one in-flight /me request between every request that
@@ -90,6 +116,11 @@ export function createApiClient(location: ApiLocation): ApiClient {
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
const method = request.method ?? 'GET'
const url = apiURL(location.origin, method, path)
// fetch refuses a GET with a body by rejecting with a TypeError, the same
// error as a network failure, so refuse it here before anything is sent.
if (method === 'GET' && request.body !== undefined) {
throw new Error(`GET ${path}: a GET request cannot carry a body`)
}
let response = await send(method, url, request.body)
if (response.status === 403) {
const error = await parseForbidden(method, path, response)
@@ -113,12 +144,14 @@ export function createApiClient(location: ApiLocation): ApiClient {
function markForbidden() {
if (forbidden) return
session.forget()
forbidden = true
listeners.forEach((listener) => listener())
}
async function decode<T>(method: string, path: string, response: Response): Promise<T> {
if (response.status === 401) {
session.forget()
location.assign(`/auth/login?return=${encodeURIComponent(location.pathname + location.search)}`)
}
if (!response.ok) throw await parseApiError(method, path, response)
@@ -140,6 +173,7 @@ export function createApiClient(location: ApiLocation): ApiClient {
throw new Error(`GET ${ME_PATH}: response is not a member with a CSRF token`)
}
csrfToken = me.csrfToken
session.remember()
return me as Me
}
@@ -151,5 +185,7 @@ export function createApiClient(location: ApiLocation): ApiClient {
listeners.add(listener)
return () => listeners.delete(listener)
},
network,
session,
}
}
+1
View File
@@ -2,4 +2,5 @@ export { ApiProvider, useApi } from './ApiContext.tsx'
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts'
export { ApiError } from './errors.ts'
export { createQueryClient, shouldRetry } from './queryClient.ts'
export { createVerifiedSession, VERIFIED_SESSION_KEY, type VerifiedSession } from './verifiedSession.ts'
export { useJob, type Job, type JobState, type JobStatus } from './useJob.ts'
+3 -2
View File
@@ -2,7 +2,8 @@ import { act, renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts'
import { ApiProvider, createApiClient, useJob } from './index.ts'
import { ApiProvider, createApiClient, createVerifiedSession, useJob } from './index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
type Draft = { dinners: string[] }
@@ -11,7 +12,7 @@ function job(status: string, result: Draft | null, error: string | null = null)
}
function renderJob(jobId: string | null) {
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
}
+24
View File
@@ -0,0 +1,24 @@
// VERIFIED_SESSION_KEY is the localStorage key marking that GET /api/v1/me
// last answered with a household member on this device. It holds only "1" —
// never the /me response, its CSRF token or anything from /auth.
export const VERIFIED_SESSION_KEY = 'mealquest.verified-session'
export type VerifiedSession = {
// verified reports whether /me last answered with a member on this device.
verified(): boolean
// remember records a /me that answered with a member.
remember(): void
// forget records that the server refused the session (401) or the account
// (not in this household).
forget(): void
}
// createVerifiedSession keeps the marker in storage (window.localStorage in
// the app). A storage failure is thrown to the caller.
export function createVerifiedSession(storage: Storage): VerifiedSession {
return {
verified: () => storage.getItem(VERIFIED_SESSION_KEY) === '1',
remember: () => storage.setItem(VERIFIED_SESSION_KEY, '1'),
forget: () => storage.removeItem(VERIFIED_SESSION_KEY),
}
}
+4 -3
View File
@@ -4,19 +4,20 @@ import { BrowserRouter } from 'react-router'
import './styles/tokens.css'
import './styles/base.css'
import App from './App.tsx'
import { createApiClient } from './api/index.ts'
import { createApiClient, createVerifiedSession } from './api/index.ts'
import { createNetworkStatus } from './pwa/networkStatus.ts'
const root = document.getElementById('root')
if (!root) {
throw new Error('MealQuest: #root element missing from index.html')
}
const api = createApiClient(window.location)
const api = createApiClient(window.location, createNetworkStatus(window), createVerifiedSession(window.localStorage))
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App api={api} />
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
</BrowserRouter>
</StrictMode>,
)
+157
View File
@@ -0,0 +1,157 @@
// @vitest-environment node
// Builds the app with the production config and serves the result, so the
// assertions cover what ships in dist: the web manifest, the service worker,
// every icon, and the service worker registration that only builds include.
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { build, createServer, preview, type PreviewServer, type ViteDevServer } from 'vite'
import tokens from './styles/tokens.css?raw'
const outDir = 'node_modules/.tmp/pwa-test-dist'
const icons = [
{ path: '/icons/icon-192.png', size: 192 },
{ path: '/icons/icon-512.png', size: 512 },
{ path: '/icons/icon-maskable-512.png', size: 512 },
{ path: '/apple-touch-icon.png', size: 180 },
]
function token(name: string): string {
const value = tokens.match(new RegExp(`^\\s*${name}:\\s*([^;\\s]+);`, 'm'))?.[1]
if (!value) throw new Error(`${name} is not defined in tokens.css`)
return value
}
// Width and height from a PNG's IHDR chunk, which follows the 8-byte
// signature, the chunk length and the chunk type.
// The precache list vite-plugin-pwa injects into sw.js: a JSON array of
// { revision, url } entries.
function precacheEntries(sw: string): Array<{ revision: string | null; url: string }> {
const start = sw.indexOf('[{"revision":')
expect(start, 'injected precache list').toBeGreaterThanOrEqual(0)
return JSON.parse(sw.slice(start, sw.indexOf('}]', start) + 2))
}
function pngSize(bytes: Uint8Array): { width: number; height: number } {
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
expect(Array.from(bytes.subarray(0, 8))).toEqual(signature)
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
return { width: view.getUint32(16), height: view.getUint32(20) }
}
describe('production build', () => {
let server: PreviewServer
let origin: string
async function get(path: string): Promise<Response> {
const response = await fetch(new URL(path, origin))
expect(response.status, path).toBe(200)
return response
}
beforeAll(async () => {
await build({ logLevel: 'error', build: { outDir, emptyOutDir: true } })
server = await preview({
logLevel: 'error',
build: { outDir },
preview: { port: 0, strictPort: false, host: '127.0.0.1' },
})
const url = server.resolvedUrls?.local[0]
if (!url) throw new Error('preview server did not report a URL')
origin = url
}, 60_000)
afterAll(async () => {
await server?.close()
})
it('emits manifest.webmanifest with the MealQuest identity', async () => {
const manifest = await (await get('/manifest.webmanifest')).json()
expect(manifest).toMatchObject({
name: 'MealQuest',
short_name: 'MealQuest',
start_url: '/',
scope: '/',
display: 'standalone',
// --color-accent and --color-bg hold the manifest colours; hex colours
// may not be written outside tokens.css.
theme_color: token('--color-accent'),
background_color: token('--color-bg'),
})
expect(manifest.icons).toEqual([
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{
src: '/icons/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
])
})
it.each(icons)('ships $path as a $size px square PNG', async ({ path, size }) => {
const bytes = new Uint8Array(await (await get(path)).arrayBuffer())
expect(pngSize(bytes)).toEqual({ width: size, height: size })
})
it('emits sw.js with the precache list injected', async () => {
const sw = await (await get('/sw.js')).text()
expect(sw).not.toContain('self.__WB_MANIFEST')
expect(sw).toContain('"url":"index.html"')
for (const { path } of icons.slice(0, 3)) {
expect(sw).toContain(`"url":"${path.slice(1)}"`)
}
})
it('precaches the shell, JS, CSS and every self-hosted font', async () => {
const entries = precacheEntries(await (await get('/sw.js')).text())
const urls = entries.map((entry) => entry.url)
// index.html keeps its URL across builds, so it carries a content
// revision; the other assets have content hashes in their names.
expect(entries.find((entry) => entry.url === 'index.html')?.revision).toMatch(/^[0-9a-f]{32}$/)
expect(urls.some((url) => url.endsWith('.js'))).toBe(true)
expect(urls.some((url) => url.endsWith('.css'))).toBe(true)
for (const font of [
'BagelFatOne-LatinExt',
'BagelFatOne-Latin',
'Figtree-Regular',
'Figtree-SemiBold',
'Figtree-Bold',
]) {
expect(urls.some((url) => url.startsWith(`assets/${font}`) && url.endsWith('.woff2'))).toBe(true)
}
})
it('links the manifest, theme colour and apple-touch-icon from index.html', async () => {
const html = await (await get('/')).text()
expect(html.match(/<link rel="manifest" href="\/manifest\.webmanifest">/g)).toHaveLength(1)
expect(html).toContain(`<meta name="theme-color" content="${token('--color-accent')}" />`)
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />')
})
it('registers /sw.js at scope / from index.html', async () => {
const html = await (await get('/')).text()
expect(html).toContain('<script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>')
const register = await (await get('/registerSW.js')).text()
expect(register).toContain("navigator.serviceWorker.register('/sw.js', { scope: '/' })")
})
})
describe('dev server', () => {
let server: ViteDevServer
beforeAll(async () => {
server = await createServer({ logLevel: 'error', server: { middlewareMode: true } })
})
afterAll(async () => {
await server?.close()
})
it('does not register a service worker', async () => {
const html = await server.transformIndexHtml('/', '<html><head></head><body></body></html>')
expect(html).not.toContain('registerSW')
expect(html).not.toContain('serviceWorker')
expect(html).not.toContain('rel="manifest"')
})
})
+18
View File
@@ -0,0 +1,18 @@
.hint {
display: flex;
align-items: center;
gap: var(--space-3);
}
.icon {
color: var(--color-accent);
}
.message {
flex: 1;
margin: 0;
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
line-height: 1.35;
}
+146
View File
@@ -0,0 +1,146 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DISMISSED_KEY, InstallHint } from './InstallHint.tsx'
const IPHONE_SAFARI =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'
const IPAD_SAFARI =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15'
const MAC_SAFARI = IPAD_SAFARI
const IPHONE_CHROME =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.119 Mobile/15E148 Safari/604.1'
const IPHONE_FIREFOX =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/141.0 Mobile/15E148 Safari/605.1.15'
const IPHONE_EDGE =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/138.3351.83 Mobile/15E148 Safari/605.1.15'
const IPHONE_OPERA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 OPT/5.4.0'
const IPHONE_MQQBROWSER =
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 MQQBrowser/11.3.5 Mobile/15E148 Safari/604.1'
const ANDROID_CHROME =
'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36'
const DESKTOP_CHROME =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'
const copy = 'Tap Share, then Add to Home Screen to get notifications'
type Device = {
userAgent: string
maxTouchPoints?: number
// display-mode: standalone
displayStandalone?: boolean
// navigator.standalone (Safari only; undefined elsewhere)
navigatorStandalone?: boolean
}
// mockDevice pretends to be a browser with the given user agent and
// display mode; jsdom has no matchMedia, navigator.maxTouchPoints or
// navigator.standalone.
function mockDevice({ userAgent, maxTouchPoints = 0, displayStandalone = false, navigatorStandalone }: Device) {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(userAgent)
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: maxTouchPoints })
Object.defineProperty(navigator, 'standalone', { configurable: true, value: navigatorStandalone })
vi.stubGlobal('matchMedia', (query: string) => {
if (query !== '(display-mode: standalone)') throw new Error(`unexpected media query ${query}`)
return { matches: displayStandalone, media: query } as MediaQueryList
})
}
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
Reflect.deleteProperty(navigator, 'maxTouchPoints')
Reflect.deleteProperty(navigator, 'standalone')
localStorage.clear()
})
describe('InstallHint', () => {
it('shows on iPhone Safari in a browser tab', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
render(<InstallHint />)
expect(screen.getByRole('note')).toHaveTextContent(copy)
})
it('shows on iPadOS Safari, which reports itself as a Mac with a touch screen', () => {
mockDevice({ userAgent: IPAD_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
render(<InstallHint />)
expect(screen.getByRole('note')).toHaveTextContent(copy)
})
it('is hidden when navigator.standalone is true', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: true })
render(<InstallHint />)
expect(screen.queryByRole('note')).toBeNull()
})
it('is hidden when display-mode is standalone', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, displayStandalone: true })
render(<InstallHint />)
expect(screen.queryByRole('note')).toBeNull()
})
it.each([
['Android Chrome', { userAgent: ANDROID_CHROME, maxTouchPoints: 5 }],
['desktop Chrome', { userAgent: DESKTOP_CHROME }],
['desktop Safari on a Mac', { userAgent: MAC_SAFARI }],
['Chrome on iPhone', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 }],
['Firefox on iPhone', { userAgent: IPHONE_FIREFOX, maxTouchPoints: 5 }],
['Edge on iPhone', { userAgent: IPHONE_EDGE, maxTouchPoints: 5 }],
['Opera on iPhone', { userAgent: IPHONE_OPERA, maxTouchPoints: 5 }],
['MQQBrowser on iPhone', { userAgent: IPHONE_MQQBROWSER, maxTouchPoints: 5 }],
])('is hidden on %s', (_name, device: Device) => {
mockDevice(device)
render(<InstallHint />)
expect(screen.queryByRole('note')).toBeNull()
})
it('stays dismissed on this device', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
const first = render(<InstallHint />)
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.queryByRole('note')).toBeNull()
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
first.unmount()
render(<InstallHint />)
expect(screen.queryByRole('note')).toBeNull()
})
it('stays up and reports the error when the dismissal cannot be saved', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('quota exceeded', 'QuotaExceededError')
})
render(<InstallHint />)
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.getByRole('note')).toHaveTextContent(copy)
expect(screen.getByRole('alert')).toHaveTextContent(
'Could not remember on this device that this hint was dismissed: QuotaExceededError: quota exceeded',
)
setItem.mockRestore()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(screen.queryByRole('note')).toBeNull()
expect(screen.queryByRole('alert')).toBeNull()
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
})
it('shows and reports the error when storage cannot be read', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('storage blocked', 'SecurityError')
})
render(<InstallHint />)
expect(screen.getByRole('note')).toHaveTextContent(copy)
expect(screen.getByRole('alert')).toHaveTextContent(
'Could not check whether this hint was dismissed on this device: SecurityError: storage blocked',
)
getItem.mockRestore()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(screen.getByRole('note')).toHaveTextContent(copy)
expect(screen.queryByRole('alert')).toBeNull()
})
})
+97
View File
@@ -0,0 +1,97 @@
import { useState } from 'react'
import { Share, X } from 'lucide-react'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import styles from './InstallHint.module.css'
// DISMISSED_KEY is the localStorage key remembering, on this device, that the
// household dismissed the hint.
export const DISMISSED_KEY = 'mealquest.installHint.dismissed'
// Safari's user agent ends "(KHTML, like Gecko) Version/<v> Mobile/<build>
// Safari/<v>" (iPadOS leaves out Mobile/). Every other iOS browser and in-app
// web view adds its own token (CriOS, FxiOS, EdgiOS, OPiOS, MQQBrowser, GSA,
// ...) or drops Version/ or Safari/, so only this exact tail is Safari.
const SAFARI_TAIL = /\(KHTML, like Gecko\) Version\/[\d.]+ (?:Mobile\/\w+ )?Safari\/[\d.]+$/
// isIosSafari reports whether the page is open in Safari on iPhone, iPod or
// iPad. iPadOS 13+ asks for the desktop site and reports itself as a Mac, so
// a "Macintosh" with a touch screen is an iPad.
export function isIosSafari(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
const ua = nav.userAgent
const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1)
return ios && SAFARI_TAIL.test(ua)
}
// isStandalone reports whether the app is running from the Home Screen.
// Safari sets navigator.standalone; other browsers answer the display-mode
// media query.
export function isStandalone(): boolean {
const legacy = (navigator as Navigator & { standalone?: boolean }).standalone
return legacy === true || window.matchMedia('(display-mode: standalone)').matches
}
// HintState is whether the household dismissed the hint on this device, and
// the storage failure, if any, that stopped us reading or saving that.
type HintState = {
dismissed: boolean
failure: { action: 'read' | 'write'; message: string } | null
}
// Storage can be unavailable (blocked by the browser or privacy settings), in
// which case using it throws. The failure is reported, never treated as a
// remembered dismissal.
function readDismissed(): HintState {
try {
return { dismissed: window.localStorage.getItem(DISMISSED_KEY) === '1', failure: null }
} catch (err) {
return { dismissed: false, failure: { action: 'read', message: String(err) } }
}
}
function writeDismissed(): HintState {
try {
window.localStorage.setItem(DISMISSED_KEY, '1')
return { dismissed: true, failure: null }
} catch (err) {
return { dismissed: false, failure: { action: 'write', message: String(err) } }
}
}
const failureText = {
read: 'Could not check whether this hint was dismissed on this device',
write: 'Could not remember on this device that this hint was dismissed',
}
// InstallHint tells iOS and iPadOS Safari users how to install the app, which
// iOS requires before it delivers Web Push. It shows only in a Safari browser
// tab, never once installed, and stays dismissed on this device. If storage
// fails, the hint stays up with an error saying so. Used by onboarding step 3
// and the Notifications screen.
export function InstallHint() {
const [eligible] = useState(() => isIosSafari(navigator) && !isStandalone())
const [state, setState] = useState<HintState>(() =>
eligible ? readDismissed() : { dismissed: false, failure: null },
)
if (!eligible || state.dismissed) return null
const { failure } = state
return (
<>
<Card tone="muted" role="note" className={styles.hint}>
<Icon icon={Share} className={styles.icon} />
<p className={styles.message}>Tap Share, then Add to Home Screen to get notifications</p>
<IconButton label="Dismiss" onClick={() => setState(writeDismissed())}>
<Icon icon={X} />
</IconButton>
</Card>
{failure && (
<ErrorBanner onRetry={() => setState(failure.action === 'read' ? readDismissed() : writeDismissed())}>
{failureText[failure.action]}: {failure.message}
</ErrorBanner>
)}
</>
)
}
+22
View File
@@ -0,0 +1,22 @@
.pill {
position: fixed;
top: calc(var(--space-3) + env(safe-area-inset-top));
left: 50%;
z-index: 20;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: 0;
padding: 6px 14px;
border-radius: var(--radius-pill);
background: var(--color-neutral-800);
color: var(--on-accent);
box-shadow: var(--shadow-md);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
pointer-events: none;
}
+150
View File
@@ -0,0 +1,150 @@
import { act, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createVerifiedSession, type ApiClient } from '../api/index.ts'
import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { createNetworkStatus } from './networkStatus.ts'
import { OfflinePill } from './OfflinePill.tsx'
const copy = 'Offline — showing saved data'
let onLine: boolean
beforeEach(() => {
onLine = true
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function goOffline() {
onLine = false
window.dispatchEvent(new Event('offline'))
}
function goOnline() {
onLine = true
window.dispatchEvent(new Event('online'))
}
function renderPill(): ApiClient {
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<ApiProvider client={api}>
<OfflinePill />
</ApiProvider>,
)
return api
}
describe('OfflinePill', () => {
it('is hidden while online', () => {
renderPill()
expect(screen.queryByRole('status')).toBeNull()
})
it('shows while the browser is offline when it renders', () => {
onLine = false
renderPill()
expect(screen.getByRole('status')).toHaveTextContent(copy)
})
it('appears on the offline event and disappears on the online event', () => {
renderPill()
act(goOffline)
expect(screen.getByRole('status')).toHaveTextContent(copy)
act(goOnline)
expect(screen.queryByRole('status')).toBeNull()
})
it('appears when a request fails with a network error', async () => {
const api = renderPill()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError))
expect(screen.getByRole('status')).toHaveTextContent(copy)
act(goOnline)
expect(screen.queryByRole('status')).toBeNull()
})
it('disappears when a later request gets a response', async () => {
const api = renderPill()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError))
expect(screen.getByRole('status')).toBeInTheDocument()
stubFetch(jsonResponse(200, {}))
await act(() => api.apiFetch('/api/v1/plan'))
expect(screen.queryByRole('status')).toBeNull()
})
it('shows while online when the service worker answers from its cache', async () => {
const api = renderPill()
const cached = jsonResponse(200, { id: '01J9ZR' })
cached.headers.set(cacheFallbackHeader, '1')
stubFetch(cached, jsonResponse(200, { id: '01J9ZR' }))
await act(() => api.apiFetch('/api/v1/recipes/01J9ZR'))
expect(navigator.onLine).toBe(true)
expect(screen.getByRole('status')).toHaveTextContent(copy)
// The server answers again.
await act(() => api.apiFetch('/api/v1/recipes/01J9ZR'))
expect(screen.queryByRole('status')).toBeNull()
})
it('ignores a request body that cannot be serialized', async () => {
const api = renderPill()
const fetchMock = stubFetch(jsonResponse(200, ME))
await act(() => api.loadMe())
const body: Record<string, unknown> = {}
body.self = body
await act(() =>
expect(api.apiFetch('/api/v1/plan', { method: 'PUT', body })).rejects.toThrow(
'PUT /api/v1/plan: request body is not serializable as JSON',
),
)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('status')).toBeNull()
})
it('ignores a GET request with a body, which fetch would refuse to send', async () => {
const api = renderPill()
const fetchMock = stubFetch()
await act(() =>
expect(api.apiFetch('/api/v1/plan', { body: { week: 1 } })).rejects.toThrow(
'GET /api/v1/plan: a GET request cannot carry a body',
),
)
expect(fetchMock).not.toHaveBeenCalled()
expect(screen.queryByRole('status')).toBeNull()
})
it('ignores an aborted request', async () => {
const api = renderPill()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new DOMException('aborted', 'AbortError')))
await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow('aborted'))
expect(screen.queryByRole('status')).toBeNull()
})
it('removes its online and offline listeners on unmount', () => {
const add = vi.spyOn(window, 'addEventListener')
const remove = vi.spyOn(window, 'removeEventListener')
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
const { unmount } = render(
<ApiProvider client={api}>
<OfflinePill />
</ApiProvider>,
)
const added = add.mock.calls.filter(([type]) => type === 'online' || type === 'offline')
expect(added.map(([type]) => type).sort()).toEqual(['offline', 'online'])
unmount()
for (const [type, listener] of added) expect(remove).toHaveBeenCalledWith(type, listener)
})
})
+19
View File
@@ -0,0 +1,19 @@
import { useSyncExternalStore } from 'react'
import { WifiOff } from 'lucide-react'
import { useApi } from '../api/index.ts'
import { Icon } from '../ui/Icon.tsx'
import styles from './OfflinePill.module.css'
// OfflinePill tells the household that what they see came from the cache
// while the browser is offline or the server cannot be reached.
export function OfflinePill() {
const { network } = useApi()
const offline = useSyncExternalStore(network.subscribe, network.offline)
if (!offline) return null
return (
<p role="status" className={styles.pill}>
<Icon icon={WifiOff} />
Offline — showing saved data
</p>
)
}
+22
View File
@@ -0,0 +1,22 @@
.toast {
position: fixed;
left: 50%;
bottom: calc(110px + env(safe-area-inset-bottom));
z-index: 20;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: var(--space-3);
width: max-content;
max-width: calc(100vw - 2 * var(--space-4));
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
box-shadow: var(--shadow-lg);
}
.message {
margin: 0;
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
line-height: 1.35;
}
+165
View File
@@ -0,0 +1,165 @@
import { act, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { SKIP_WAITING, UpdatePrompt, type UpdateContainer } from './UpdatePrompt.tsx'
// FakeWorker is a ServiceWorker whose state the test moves along.
class FakeWorker extends EventTarget {
state: ServiceWorkerState = 'installing'
postMessage = vi.fn()
setState(state: ServiceWorkerState) {
this.state = state
this.dispatchEvent(new Event('statechange'))
}
}
class FakeRegistration extends EventTarget {
installing: FakeWorker | null = null
waiting: FakeWorker | null = null
// install starts a new worker, as the browser does on finding an update.
install(): FakeWorker {
const worker = new FakeWorker()
this.installing = worker
this.dispatchEvent(new Event('updatefound'))
return worker
}
// finishInstall moves the installing worker to waiting.
finishInstall(worker: FakeWorker) {
this.installing = null
this.waiting = worker
worker.setState('installed')
}
}
class FakeContainer extends EventTarget {
controller: object | null = {}
registration = new FakeRegistration()
ready = Promise.resolve(this.registration)
// takeOver makes the waiting worker the controller.
takeOver() {
const worker = this.registration.waiting
this.registration.waiting = null
this.controller = worker
worker?.setState('activated')
this.dispatchEvent(new Event('controllerchange'))
}
}
async function renderPrompt(container: FakeContainer | undefined) {
const reload = vi.fn()
const view = render(<UpdatePrompt container={container as unknown as UpdateContainer} reload={reload} />)
await act(async () => {
await container?.ready
})
return { reload, ...view }
}
const copy = 'New version ready · Reload'
describe('UpdatePrompt', () => {
it('shows nothing without a service worker', async () => {
await renderPrompt(undefined)
expect(screen.queryByRole('status')).toBeNull()
})
it('shows nothing while no worker is waiting', async () => {
const container = new FakeContainer()
await renderPrompt(container)
act(() => {
container.registration.install()
})
expect(screen.queryByRole('status')).toBeNull()
})
it('shows nothing for a waiting worker when the page has no controller', async () => {
const container = new FakeContainer()
container.controller = null
container.registration.waiting = new FakeWorker()
await renderPrompt(container)
expect(screen.queryByRole('status')).toBeNull()
})
it('shows the toast for a worker already waiting when the page loads', async () => {
const container = new FakeContainer()
container.registration.waiting = new FakeWorker()
await renderPrompt(container)
expect(screen.getByRole('status')).toHaveTextContent(copy)
})
it('shows the toast when an update finishes installing', async () => {
const container = new FakeContainer()
await renderPrompt(container)
act(() => container.registration.finishInstall(container.registration.install()))
expect(screen.getByRole('status')).toHaveTextContent(copy)
})
it('sends SKIP_WAITING on Reload and reloads once the new worker controls the page', async () => {
const container = new FakeContainer()
const { reload } = await renderPrompt(container)
const worker = container.registration.install()
act(() => container.registration.finishInstall(worker))
act(() => screen.getByRole('button', { name: 'Reload' }).click())
expect(worker.postMessage).toHaveBeenCalledExactlyOnceWith(SKIP_WAITING)
expect(screen.queryByRole('status')).toBeNull()
expect(reload).not.toHaveBeenCalled()
act(() => container.takeOver())
expect(reload).toHaveBeenCalledOnce()
// A later controller change (another tab's update) does not reload again.
act(() => container.dispatchEvent(new Event('controllerchange')))
expect(reload).toHaveBeenCalledOnce()
expect(screen.queryByRole('status')).toBeNull()
})
it('does not reappear for the worker the household chose to reload into', async () => {
const container = new FakeContainer()
container.registration.waiting = new FakeWorker()
await renderPrompt(container)
act(() => screen.getByRole('button', { name: 'Reload' }).click())
// The worker reports another state change before it takes over.
act(() => container.registration.waiting?.setState('installed'))
expect(screen.queryByRole('status')).toBeNull()
})
it('hides when dismissed until the next update is waiting', async () => {
const container = new FakeContainer()
const { reload } = await renderPrompt(container)
const first = container.registration.install()
act(() => container.registration.finishInstall(first))
act(() => screen.getByRole('button', { name: 'Dismiss' }).click())
expect(screen.queryByRole('status')).toBeNull()
expect(first.postMessage).not.toHaveBeenCalled()
act(() => first.setState('installed'))
expect(screen.queryByRole('status')).toBeNull()
act(() => container.registration.finishInstall(container.registration.install()))
expect(screen.getByRole('status')).toHaveTextContent(copy)
expect(reload).not.toHaveBeenCalled()
})
it('hides when another tab activates the waiting worker', async () => {
const container = new FakeContainer()
container.registration.waiting = new FakeWorker()
const { reload } = await renderPrompt(container)
act(() => container.takeOver())
expect(screen.queryByRole('status')).toBeNull()
expect(reload).not.toHaveBeenCalled()
})
it('removes its listeners on unmount', async () => {
const container = new FakeContainer()
const worker = new FakeWorker()
container.registration.waiting = worker
const { reload, unmount } = await renderPrompt(container)
const removeRegistration = vi.spyOn(container.registration, 'removeEventListener')
const removeWorker = vi.spyOn(worker, 'removeEventListener')
act(() => screen.getByRole('button', { name: 'Reload' }).click())
unmount()
expect(removeRegistration).toHaveBeenCalledWith('updatefound', expect.any(Function))
expect(removeWorker).toHaveBeenCalledWith('statechange', expect.any(Function))
container.takeOver()
expect(reload).not.toHaveBeenCalled()
})
})
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import styles from './UpdatePrompt.module.css'
// UpdateContainer is the part of navigator.serviceWorker the prompt uses.
export type UpdateContainer = Pick<ServiceWorkerContainer, 'ready' | 'controller' | 'addEventListener' | 'removeEventListener'>
export type UpdatePromptProps = {
// navigator.serviceWorker, or undefined where the browser has none.
container: UpdateContainer | undefined
// Reloads the page once the new worker controls it.
reload: () => void
}
// SKIP_WAITING is the message src/sw.ts answers by activating the waiting
// worker.
export const SKIP_WAITING = { type: 'SKIP_WAITING' } as const
// UpdatePrompt offers to reload when a new service worker is installed and
// waiting to replace the one controlling the page. Reload asks the waiting
// worker to activate and reloads once it controls the page; dismissing hides
// the prompt until a different worker is waiting.
export function UpdatePrompt({ container, reload }: UpdatePromptProps) {
const [waiting, setWaiting] = useState<ServiceWorker | null>(null)
const [dismissed, setDismissed] = useState<ServiceWorker | null>(null)
const [accepted, setAccepted] = useState<ServiceWorker | null>(null)
const reloadListener = useRef<(() => void) | null>(null)
useEffect(() => {
if (container === undefined) return
const sw = container
let registration: ServiceWorkerRegistration | null = null
let unmounted = false
const watched = new Set<ServiceWorker>()
// A waiting worker only replaces something when the page has a
// controller; without one there is no controllerchange to reload on.
function check() {
if (registration === null) return
setWaiting(sw.controller === null ? null : registration.waiting)
}
function watch(worker: ServiceWorker | null) {
if (worker === null || watched.has(worker)) return
watched.add(worker)
worker.addEventListener('statechange', check)
}
function updateFound() {
watch(registration?.installing ?? null)
}
void sw.ready.then((ready) => {
if (unmounted) return
registration = ready
ready.addEventListener('updatefound', updateFound)
watch(ready.installing)
watch(ready.waiting)
check()
})
return () => {
unmounted = true
registration?.removeEventListener('updatefound', updateFound)
watched.forEach((worker) => worker.removeEventListener('statechange', check))
if (reloadListener.current !== null) {
sw.removeEventListener('controllerchange', reloadListener.current)
reloadListener.current = null
}
}
}, [container])
if (container === undefined || waiting === null || waiting === dismissed || waiting === accepted) return null
function update() {
if (container === undefined || waiting === null || reloadListener.current !== null) return
const listener = () => {
reloadListener.current = null
reload()
}
reloadListener.current = listener
container.addEventListener('controllerchange', listener, { once: true })
setAccepted(waiting)
waiting.postMessage(SKIP_WAITING)
}
return (
<Card role="status" className={styles.toast}>
<p className={styles.message}>
New version ready <span aria-hidden="true">·</span>
</p>
{/* The space keeps the copy "New version ready · Reload" in the text;
a flex container does not render it. */}{' '}
<Button onClick={update}>Reload</Button>
<IconButton label="Dismiss" onClick={() => setDismissed(waiting)}>
<Icon icon={X} />
</IconButton>
</Card>
)
}
+51
View File
@@ -0,0 +1,51 @@
// NetworkWindow is the part of window the network status reads: the browser's
// online flag and its online/offline events.
export type NetworkWindow = Pick<Window, 'navigator' | 'addEventListener' | 'removeEventListener'>
export type NetworkStatus = {
// offline reports whether the browser says it is offline or the last API
// request failed with a network error or was answered from the cache.
offline(): boolean
// subscribe is notified when offline may have changed. It listens for the
// window's online/offline events until the returned function is called.
subscribe(listener: () => void): () => void
// requestFailed records a fetch that rejected with a network error or that
// the service worker answered from its cache.
requestFailed(): void
// requestSucceeded records a fetch the server answered.
requestSucceeded(): void
}
// createNetworkStatus tracks whether MealQuest can reach the server. A failed
// request counts as offline until a later request gets a response or the
// browser reports that it is back online.
export function createNetworkStatus(win: NetworkWindow): NetworkStatus {
let failed = false
const listeners = new Set<() => void>()
function setFailed(value: boolean) {
if (failed === value) return
failed = value
listeners.forEach((listener) => listener())
}
return {
offline: () => !win.navigator.onLine || failed,
subscribe(listener) {
const online = () => {
failed = false
listener()
}
listeners.add(listener)
win.addEventListener('online', online)
win.addEventListener('offline', listener)
return () => {
listeners.delete(listener)
win.removeEventListener('online', online)
win.removeEventListener('offline', listener)
}
},
requestFailed: () => setFailed(true),
requestSucceeded: () => setFailed(false),
}
}
+115
View File
@@ -0,0 +1,115 @@
// MealQuest service worker. vite-plugin-pwa replaces self.__WB_MANIFEST with
// the build's precache list (shell, JS, CSS and fonts, each with a revision).
// Every other request goes through the route src/sw/routes.ts decides.
// A network-only request is left to the browser: the worker never answers it,
// so it reaches the network exactly as the page sent it. That matters for
// POST /auth/logout, whose Clear-Site-Data header wipes the worker and its
// caches — Chrome stalls that navigation when the worker answers it.
// Fonts are precached too, but their route is registered ahead of the
// precache route, so a font fetch goes through the expiring CacheFirst
// strategy and falls back to the precached copy when the network fails.
import { cacheNames as workboxCacheNames, setCacheNameDetails } from 'workbox-core'
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
import { ExpirationPlugin } from 'workbox-expiration'
import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
import { createApiStrategy } from './sw/apiStrategy'
import {
cacheNames,
cacheVersion,
routeFor,
staleCaches,
type Route,
} from './sw/routes'
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<{ url: string; revision: string | null }>
}
// The precache becomes mealquest-precache-v1.
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
const day = 24 * 60 * 60
const strategies = {
[cacheNames.api]: createApiStrategy(),
[cacheNames.media]: new CacheFirst({
cacheName: cacheNames.media,
plugins: [
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * day, purgeOnQuotaError: true }),
],
}),
[cacheNames.fonts]: new CacheFirst({
cacheName: cacheNames.fonts,
plugins: [
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 365 * day, purgeOnQuotaError: true }),
// Offline with no runtime copy yet: the font from the precache.
{ handlerDidError: async ({ request }) => matchPrecache(request.url) },
],
}),
}
const navigation = new NetworkOnly({
plugins: [new PrecacheFallbackPlugin({ fallbackURL: 'index.html' })],
})
type AnsweredRoute = Exclude<Route, { strategy: 'network-only' }>
function strategyFor(route: AnsweredRoute): Strategy {
switch (route.strategy) {
case 'network-first':
case 'cache-first':
return strategies[route.cacheName]
case 'navigation-fallback':
return navigation
}
}
function routeOf({ request, url }: { request: Request; url: URL }): Route {
return routeFor({ method: request.method, url, mode: request.mode }, self.location.origin)
}
// answered matches every request the worker answers: all but network-only.
function answered(options: { request: Request; url: URL }): boolean {
return routeOf(options).strategy !== 'network-only'
}
function handle({ request, url, event }: RouteHandlerCallbackOptions): Promise<Response> {
const route = routeOf({ request, url })
if (route.strategy === 'network-only') {
throw new Error(`the worker never answers network-only ${request.method} ${url.href}`)
}
return strategyFor(route).handle({ request, event })
}
function isFont(options: { request: Request; url: URL }): boolean {
const route = routeOf(options)
return route.strategy === 'cache-first' && route.cacheName === cacheNames.fonts
}
// Workbox uses the first route that matches, so the font route comes before
// the precache route, which would otherwise answer every precached font.
registerRoute(isFont, handle, 'GET')
precacheAndRoute(self.__WB_MANIFEST)
// Workbox routes are per method; every method goes through routeFor, which
// sends every non-GET to the network only, so only GETs are ever answered.
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const) {
registerRoute(answered, handle, method)
}
self.addEventListener('activate', (event) => {
const current = [workboxCacheNames.precache, ...Object.values(cacheNames)]
event.waitUntil(
caches
.keys()
.then((names) => Promise.all(staleCaches(names, current).map((name) => caches.delete(name)))),
)
})
// The update prompt (src/pwa/UpdatePrompt.tsx) asks a waiting worker to take
// over when the household chooses Reload.
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting()
})
+165
View File
@@ -0,0 +1,165 @@
// Runs the real Workbox network-first strategy against an in-memory cache
// storage: jsdom has none, and the worker itself needs service worker globals.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
import { createApiClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { fakeLocation, jsonResponse } from '../test/fetchStub.ts'
import { createApiStrategy } from './apiStrategy'
import { cacheFallbackHeader, cacheNames, routeFor } from './routes'
const origin = 'https://mealquest.test'
// FakeCache keys entries by URL, which is all the API cache needs.
class FakeCache {
readonly entries = new Map<string, Response>()
async match(request: RequestInfo | URL): Promise<Response | undefined> {
return this.entries.get(new Request(request).url)?.clone()
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
this.entries.set(new Request(request).url, response)
}
}
// FakeExtendableEvent stands in for the worker-only ExtendableEvent,
// collecting the promises the strategy waits on.
class FakeExtendableEvent extends Event {
readonly pending: Promise<unknown>[] = []
waitUntil(promise: Promise<unknown>) {
this.pending.push(promise)
}
}
let cache: FakeCache
beforeEach(() => {
cache = new FakeCache()
// Workbox checks its arguments against these worker-only globals.
vi.stubGlobal('FetchEvent', class FetchEvent {})
vi.stubGlobal('ExtendableEvent', FakeExtendableEvent)
const only = (name: string | undefined) => {
if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`)
return cache
}
vi.stubGlobal('caches', {
open: async (name: string) => only(name),
match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) =>
only(options?.cacheName).match(request),
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
// handle sends a GET through the API strategy, as src/sw.ts does for a
// network-first route.
async function handle(path: string): Promise<Response> {
return answer(new Request(new URL(path, origin)))
}
async function answer(request: Request): Promise<Response> {
expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({
strategy: 'network-first',
cacheName: cacheNames.api,
})
const event = new FakeExtendableEvent('fetch')
const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] })
await Promise.all(event.pending)
return response
}
function recipe(): Response {
return new Response(JSON.stringify({ id: '01J9ZR', name: 'Chicken tikka masala' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
describe('API strategy', () => {
it('answers from the network while the server is reachable, unmarked', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(recipe()))
const response = await handle('/api/v1/recipes/01J9ZR')
expect(response.headers.has(cacheFallbackHeader)).toBe(false)
expect(await response.json()).toMatchObject({ id: '01J9ZR' })
expect(cache.entries.has(`${origin}/api/v1/recipes/01J9ZR`)).toBe(true)
})
it('opens a previously viewed recipe offline, marked as a cache fallback', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(recipe()))
await handle('/api/v1/recipes/01J9ZR')
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
const response = await handle('/api/v1/recipes/01J9ZR')
expect(response.headers.get(cacheFallbackHeader)).toBe('1')
expect(await response.json()).toEqual({ id: '01J9ZR', name: 'Chicken tikka masala' })
})
it('fails offline for a recipe never viewed', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
await expect(handle('/api/v1/recipes/01J9ZS')).rejects.toThrow()
})
})
describe('previously viewed plan and recipe reads', () => {
const plan = { week: '2026-W40', days: [{ date: '2026-10-05', dinner: 'Chicken tikka masala' }] }
const recipe = { id: '01J9ZR', name: 'Chicken tikka masala', steps: ['Marinate', 'Simmer'] }
const server: Record<string, unknown> = { '/api/v1/plans/2026-W40': plan, '/api/v1/recipes/01J9ZR': recipe }
let reachable: boolean
// The page's fetch goes through the worker's API strategy; the strategy's
// own fetch (always a Request) is the network. The API client fetches a
// path string, so the two are told apart by the argument's type. As in a
// browser, a worker that cannot answer is a network error to the page.
beforeEach(() => {
reachable = true
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
if (!(input instanceof Request)) {
return answer(new Request(new URL(String(input), origin), init)).catch((error: unknown) => {
throw new TypeError('Failed to fetch', { cause: error })
})
}
if (!reachable) throw new TypeError('Failed to fetch')
const body = server[new URL(input.url).pathname]
if (body === undefined) throw new Error(`unexpected network fetch: ${input.url}`)
return jsonResponse(200, body)
})
})
afterEach(() => {
vi.restoreAllMocks()
})
function client() {
return createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
}
it('serves the plan and recipe fetched online once the network is gone', async () => {
const online = client()
expect(await online.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan)
expect(await online.apiFetch('/api/v1/recipes/01J9ZR')).toEqual(recipe)
expect(online.network.offline()).toBe(false)
reachable = false
const offline = client()
expect(await offline.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan)
expect(await offline.apiFetch('/api/v1/recipes/01J9ZR')).toEqual(recipe)
expect(offline.network.offline()).toBe(true)
})
it('shows the offline indicator when the cached copy answers while the browser reports online', async () => {
await client().apiFetch('/api/v1/plans/2026-W40')
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
reachable = false
const api = client()
expect(await api.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan)
expect(api.network.offline()).toBe(true)
})
it('fails offline for a plan never fetched', async () => {
reachable = false
await expect(client().apiFetch('/api/v1/plans/2026-W41')).rejects.toThrow(TypeError)
})
})
+16
View File
@@ -0,0 +1,16 @@
// The network-first strategy for API reads, kept apart from src/sw.ts so the
// tests can run it without a service worker.
import type { WorkboxPlugin } from 'workbox-core/types'
import { NetworkFirst } from 'workbox-strategies'
import { cacheNames, markCacheFallback, networkTimeoutSeconds } from './routes'
// NetworkFirst reads its cache only when the network fails or times out, so
// every cached response it uses is a fallback.
const markFallback: WorkboxPlugin = {
cachedResponseWillBeUsed: async ({ cachedResponse }) =>
cachedResponse === undefined ? undefined : markCacheFallback(cachedResponse),
}
export function createApiStrategy(): NetworkFirst {
return new NetworkFirst({ cacheName: cacheNames.api, networkTimeoutSeconds, plugins: [markFallback] })
}
+180
View File
@@ -0,0 +1,180 @@
import { describe, expect, it } from 'vitest'
import { cacheFallbackHeader, cacheNames, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
const origin = 'https://mealquest.test'
function route(path: string, method = 'GET', mode: RequestMode = 'cors'): Route {
return routeFor({ method, url: new URL(path, origin), mode }, origin)
}
const apiCache: Route = { strategy: 'network-first', cacheName: cacheNames.api }
const networkOnly: Route = { strategy: 'network-only' }
describe('routeFor', () => {
it.each([
'/api/v1/plans',
'/api/v1/plans/2026-W40',
'/api/v1/shopping/current',
'/api/v1/dinners',
'/api/v1/dinners/01J9ZK?include=recipe',
'/api/v1/recipes',
'/api/v1/recipes/01J9ZR',
'/api/v1/home',
])('GET %s is network-first in the API cache', (path) => {
expect(route(path)).toEqual(apiCache)
})
it.each(['/media/photos/abc.jpg', '/media/thumbs/abc.webp'])(
'GET %s is cache-first in the media cache',
(path) => {
expect(route(path)).toEqual({ strategy: 'cache-first', cacheName: cacheNames.media })
},
)
it('GET of a font file is cache-first in the fonts cache', () => {
expect(route('/assets/Figtree-Bold-a1b2c3.woff2')).toEqual({
strategy: 'cache-first',
cacheName: cacheNames.fonts,
})
})
it.each([
'/auth/login',
'/auth/callback?code=x&state=y',
'/auth/logout',
'/auth/me',
'/api/v1/settings',
'/api/v1/settings/ai',
'/api/v1/jobs',
'/api/v1/jobs/01J9ZK',
])('GET %s is network-only', (path) => {
expect(route(path)).toEqual(networkOnly)
})
it.each(['/auth/login', '/auth/callback?code=x&state=y', '/api/v1/settings/ai'])(
'a navigation to %s is network-only with no offline fallback',
(path) => {
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
},
)
it.each([
'/%61uth/login',
'/auth%2Fcallback?code=x&state=y',
'/%61pi/v1/settings/household',
'/api/v1/%73ettings/ai',
'/api/v1/settings%2Fai',
'/%61pi/v1/jobs/01J9ZK',
'/api/v1/j%6Fbs',
'/api/v1/plans%2F..%2Fsettings',
'/api/v1/recipes%2F..%2Fsettings%2Fai',
'/api/v1/recipes/..%2F..%2F..%2Fauth%2Flogin',
'/plan%2F..%2Fauth%2Flogin',
])('an encoded reserved path %s is network-only, navigated or not', (path) => {
expect(route(path)).toEqual(networkOnly)
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
})
it.each(['/%E0%A4%A', '/auth%', '/plan/%zz', '/api/v1/plans/%C3'])(
'a malformed encoding in %s is network-only, navigated or not',
(path) => {
expect(route(path)).toEqual(networkOnly)
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
},
)
it('classifies other API paths by their decoded form', () => {
expect(route('/%61pi/v1/plans/2026-W40')).toEqual(apiCache)
expect(route('/%61pi/v1/recipes/01J9ZR')).toEqual(apiCache)
expect(route('/%61pi/v1/ideas', 'GET', 'navigate')).toEqual(networkOnly)
})
it.each(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])(
'%s is network-only on every URL class',
(method) => {
for (const path of [
'/api/v1/plans/2026-W40',
'/api/v1/shopping/current',
'/api/v1/dinners/01J9ZK',
'/api/v1/recipes/01J9ZR',
'/api/v1/home',
'/media/photos/abc.jpg',
'/assets/Figtree-Bold-a1b2c3.woff2',
'/auth/logout',
'/plan',
]) {
expect(route(path, method), `${method} ${path}`).toEqual(networkOnly)
expect(route(path, method, 'navigate'), `${method} ${path}`).toEqual(networkOnly)
}
},
)
it('matches whole path segments only', () => {
expect(route('/api/v1/plansx')).toEqual(networkOnly)
expect(route('/api/v1/homepage')).toEqual(networkOnly)
expect(route('/api/v1/recipesx')).toEqual(networkOnly)
expect(route('/mediax/a.jpg')).toEqual(networkOnly)
expect(route('/authors')).toEqual(networkOnly)
expect(route('/api/v1/settingsx')).toEqual(networkOnly)
})
it('leaves other API calls network-only', () => {
expect(route('/api/v1/me')).toEqual(networkOnly)
expect(route('/api/v1/ideas', 'GET', 'navigate')).toEqual(networkOnly)
})
it('leaves cross-origin requests network-only', () => {
for (const path of ['/api/v1/plans', '/media/a.jpg', '/assets/a.woff2']) {
const url = new URL(path, 'https://cdn.example')
expect(routeFor({ method: 'GET', url, mode: 'no-cors' }, origin)).toEqual(networkOnly)
}
})
it.each(['/', '/plan', '/supply-run', '/dinners/01J9ZK'])(
'a navigation to the app at %s falls back to the precached index.html',
(path) => {
expect(route(path, 'GET', 'navigate')).toEqual({ strategy: 'navigation-fallback' })
},
)
it('leaves other same-origin GETs network-only', () => {
expect(route('/health')).toEqual(networkOnly)
})
})
describe('cache names', () => {
it('all start with mealquest-', () => {
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
})
})
describe('staleCaches', () => {
it('deletes only MealQuest caches that are not current', () => {
const current = ['mealquest-precache-v1', ...Object.values(cacheNames)]
const names = [
...current,
'mealquest-precache',
'mealquest-api-v0',
'workbox-precache-v2-https://mealquest.test/',
'other-app-cache',
'my-mealquest-cache',
]
expect(staleCaches(names, current)).toEqual(['mealquest-precache', 'mealquest-api-v0'])
})
})
describe('markCacheFallback', () => {
it('copies the cached response with the cache fallback header set', async () => {
const cached = new Response('{"id":"01J9ZR"}', {
status: 200,
statusText: 'OK',
headers: { 'Content-Type': 'application/json' },
})
const marked = markCacheFallback(cached)
expect(marked.status).toBe(200)
expect(marked.statusText).toBe('OK')
expect(marked.headers.get('Content-Type')).toBe('application/json')
expect(marked.headers.get(cacheFallbackHeader)).toBe('1')
expect(await marked.json()).toEqual({ id: '01J9ZR' })
})
})
+108
View File
@@ -0,0 +1,108 @@
// Decides how the service worker handles each request. This module is pure —
// no Workbox, no service worker globals — so the tests can assert every URL
// class without a worker; src/sw.ts maps each route to a Workbox strategy.
//
// Security rule: /auth/*, /api/v1/settings/*, /api/v1/jobs/* and every
// non-GET request are network-only and never reach a cache. Paths are matched
// as the server sees them — percent-decoded, before and after dot segments are
// resolved — so /%61uth/login is /auth/login; a malformed encoding is
// network-only.
export type Route =
// Network with a 3 s timeout, then the last cached response.
| { strategy: 'network-first'; cacheName: typeof cacheNames.api }
// Cached response when there is one, else the network; entries expire.
| { strategy: 'cache-first'; cacheName: typeof cacheNames.media | typeof cacheNames.fonts }
// Network only, nothing cached: the worker leaves the request to the browser.
| { strategy: 'network-only' }
// Network only, nothing cached; offline, the precached index.html.
| { strategy: 'navigation-fallback' }
export interface RouteRequest {
method: string
url: URL
mode: RequestMode
}
// Bumping the version renames every cache; activate then deletes the old ones.
export const cacheVersion = 'v1'
export const cachePrefix = 'mealquest-'
export const cacheNames = {
api: `${cachePrefix}api-${cacheVersion}`,
media: `${cachePrefix}media-${cacheVersion}`,
fonts: `${cachePrefix}fonts-${cacheVersion}`,
} as const
export const networkTimeoutSeconds = 3
// cacheFallbackHeader marks a network-first response answered from the cache
// because the server could not be reached in time, so the app can show the
// offline indicator even while the browser reports that it is online.
export const cacheFallbackHeader = 'X-MealQuest-Cache-Fallback'
// markCacheFallback copies a cached response with cacheFallbackHeader set.
export function markCacheFallback(cached: Response): Response {
const headers = new Headers(cached.headers)
headers.set(cacheFallbackHeader, '1')
return new Response(cached.body, { status: cached.status, statusText: cached.statusText, headers })
}
const neverCached = ['/auth', '/api/v1/settings', '/api/v1/jobs']
const networkFirst = ['/api/v1/plans', '/api/v1/shopping', '/api/v1/dinners', '/api/v1/recipes', '/api/v1/home']
// True when path is prefix itself or a path below it: /api/v1/plans matches
// /api/v1/plans and /api/v1/plans/2026-W40, never /api/v1/plansx.
function under(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}/`)
}
// The pathname percent-decoded, as the server's router sees it, or undefined
// when the encoding is malformed.
function decodePath(pathname: string): string | undefined {
try {
return decodeURIComponent(pathname)
} catch {
return undefined
}
}
// Resolves "." and ".." segments the way the server's path.Clean does, so a
// decoded %2F cannot carry a request out of a reserved prefix:
// /api/v1/plans/../settings cleans to /api/v1/settings.
function cleanPath(path: string): string {
const segments: string[] = []
for (const segment of path.split('/')) {
if (segment === '' || segment === '.') continue
if (segment === '..') segments.pop()
else segments.push(segment)
}
return `/${segments.join('/')}`
}
const networkOnly: Route = { strategy: 'network-only' }
export function routeFor(request: RouteRequest, origin: string): Route {
const { method, url, mode } = request
if (method !== 'GET') return networkOnly
if (url.origin !== origin) return networkOnly
const path = decodePath(url.pathname)
if (path === undefined) return networkOnly
const cleaned = cleanPath(path)
const reserved = (prefix: string) => under(path, prefix) || under(cleaned, prefix)
if (neverCached.some(reserved)) return networkOnly
if (networkFirst.some((prefix) => under(path, prefix))) {
return { strategy: 'network-first', cacheName: cacheNames.api }
}
if (under(path, '/media')) return { strategy: 'cache-first', cacheName: cacheNames.media }
if (path.endsWith('.woff2')) return { strategy: 'cache-first', cacheName: cacheNames.fonts }
if (reserved('/api')) return networkOnly
if (mode === 'navigate') return { strategy: 'navigation-fallback' }
return networkOnly
}
// The caches activate deletes: MealQuest caches that are no longer current.
// Caches without the mealquest- prefix belong to someone else and are kept.
export function staleCaches(names: readonly string[], current: readonly string[]): string[] {
return names.filter((name) => name.startsWith(cachePrefix) && !current.includes(name))
}
+45
View File
@@ -0,0 +1,45 @@
// The service worker itself needs worker globals, so these tests read its
// source: they pin the parts routes.ts cannot express — the API cache's
// strategy, the font strategy's expiration, the font route's place ahead of
// the precache route and network-only requests left to the browser.
import { describe, expect, it } from 'vitest'
import source from '../sw.ts?raw'
describe('service worker source', () => {
it('serves the API cache with the strategy that marks cache fallbacks', () => {
expect(source).toContain('[cacheNames.api]: createApiStrategy(),')
})
it('gives the fonts cache a CacheFirst strategy with an expiration plugin', () => {
expect(source).toMatch(
/\[cacheNames\.fonts\]: new CacheFirst\(\{\s*cacheName: cacheNames\.fonts,\s*plugins: \[\s*new ExpirationPlugin\(\{ maxEntries: \d+, maxAgeSeconds: [^}]+\}\)/,
)
})
it('falls back to the precached font when the network fails', () => {
expect(source).toContain('handlerDidError: async ({ request }) => matchPrecache(request.url)')
})
it('registers the font route before the precache route', () => {
const fontRoute = source.indexOf("registerRoute(isFont, handle, 'GET')")
const precache = source.indexOf('precacheAndRoute(self.__WB_MANIFEST)')
expect(fontRoute).toBeGreaterThanOrEqual(0)
expect(precache).toBeGreaterThan(fontRoute)
})
it('leaves network-only requests to the browser instead of answering them', () => {
// Chrome stalls a navigation whose Clear-Site-Data response (POST
// /auth/logout) the worker answered, so the worker must not match it.
expect(source).toMatch(
/function answered\(options: \{ request: Request; url: URL \}\): boolean \{\s*return routeOf\(options\)\.strategy !== 'network-only'/,
)
expect(source).toContain('registerRoute(answered, handle, method)')
expect(source).not.toContain('registerRoute(() => true')
})
it('activates a waiting worker when the update prompt sends SKIP_WAITING', () => {
expect(source).toMatch(
/self\.addEventListener\('message', \(event\) => \{\s*if \(event\.data\?\.type === 'SKIP_WAITING'\) void self\.skipWaiting\(\)/,
)
})
})
+3
View File
@@ -4,4 +4,7 @@ import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
// The verified-session marker and other device state must not leak between
// tests. Tests in the node environment (src/pwa.test.ts) have no storage.
if (typeof localStorage !== 'undefined') localStorage.clear()
})
+3 -1
View File
@@ -13,9 +13,11 @@
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
"include": ["src", "vite.config.ts", "manifest.json"],
"exclude": ["src/sw.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["ES2022", "WebWorker"],
"types": []
},
"include": ["src/sw.ts"],
"exclude": []
}
+21 -1
View File
@@ -1,10 +1,30 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { VitePWA, type ManifestOptions } from 'vite-plugin-pwa'
import manifest from './manifest.json' with { type: 'json' }
const backend = 'http://localhost:8080'
export default defineConfig({
plugins: [react()],
plugins: [
react(),
// The plugin writes manifest.webmanifest and sw.js, links the manifest
// from index.html and adds the registerSW.js script. It does both only
// in production builds: dev options stay off, so neither `vite` nor
// Vitest registers a service worker.
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectRegister: 'script',
// Precache the shell, JS, CSS and the self-hosted fonts.
injectManifest: { globPatterns: ['**/*.{html,js,css,woff2}'] },
// The web manifest lives in manifest.json: its theme and background
// colours are --color-accent and --color-bg from src/styles/tokens.css,
// and hex colours may not be written in TypeScript.
manifest: manifest as Partial<ManifestOptions>,
}),
],
server: {
proxy: {
'/api': backend,