-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapi-client.client.ts
More file actions
168 lines (152 loc) · 6.6 KB
/
api-client.client.ts
File metadata and controls
168 lines (152 loc) · 6.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
/**
* Configures the global `$fetch` with a runtime API base URL and the
* IndexedDB read-cache + mutation-invalidation interceptors.
*
* In SSR/PWA, `apiBaseUrl` is empty and same-origin relative paths work
* (`$fetch('/api/v1/entries')` hits the local Nitro server). In a static
* Capacitor build the bundle is served from `capacitor://localhost` (or
* similar) and relative `/api` paths would 404, so we prefix them with the
* cloud (or user-configured) backend.
*
* Resolution order for the base URL, highest priority first:
* 1. Per-user override stored in localStorage (Phase 2.4 server picker)
* 2. Build-time `NUXT_PUBLIC_API_BASE_URL` baked into runtime config
* 3. No prefix — relative URLs resolve against the page origin (default)
*
* Offline cache behaviour (Phase 2.5):
* - Every successful /api/* GET response is written to IndexedDB.
* - When a /api/* GET fails (offline, network error), the cache layer
* replays the last-seen body so the UI keeps working.
* - Every successful /api/* mutation invalidates cached entries whose
* path prefix matches the mutated resource.
* - When a mutation fails offline, the caller gets an `OfflineWriteError`
* with `code === "OFFLINE_WRITE"` that the UI surfaces as a toast.
*
* Phase 2.3 + 2.5 of v0.7.0. See docs/plans/native-android.md.
*/
import {
isCacheable,
get as cacheGet,
put as cachePut,
invalidatePrefix,
stripOrigin,
} from "~/utils/apiCache";
const STORAGE_KEY = "tada.apiBaseUrl";
export class OfflineWriteError extends Error {
code = "OFFLINE_WRITE" as const;
constructor(method: string, path: string) {
super(`Offline — ${method} ${path} will need to wait for connection.`);
}
}
function readUserOverride(): string {
if (typeof localStorage === "undefined") return "";
try {
return localStorage.getItem(STORAGE_KEY)?.trim() ?? "";
} catch {
return "";
}
}
function normaliseBaseUrl(url: string): string {
const trimmed = url.trim().replace(/\/+$/, "");
if (!trimmed) return "";
if (!/^https?:\/\/[^/\s]+/.test(trimmed)) return "";
return trimmed;
}
function isLikelyOfflineError(err: unknown): boolean {
// ofetch wraps fetch errors; the original is usually on `cause` or the
// top-level message contains "Failed to fetch" / "Network request failed".
if (typeof navigator !== "undefined" && navigator.onLine === false) return true;
const e = err as { message?: string; cause?: { message?: string } } | null;
const msg = (e?.message ?? "") + " " + (e?.cause?.message ?? "");
return /Failed to fetch|NetworkError|Network request failed|TypeError: fetch/i.test(
msg,
);
}
function isMutation(method: string): boolean {
const m = method.toUpperCase();
return m === "POST" || m === "PUT" || m === "PATCH" || m === "DELETE";
}
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();
const buildTime = String(config.public.apiBaseUrl ?? "");
const userOverride = readUserOverride();
const baseURL = normaliseBaseUrl(userOverride || buildTime);
const configured = $fetch.create({
...(baseURL ? { baseURL } : {}),
// Capacitor WebView origin is app.tada.living; API origin is tada.living.
// Same-site but cross-origin, so fetch needs credentials:'include' for
// the session cookie to ride along (Phase 3.2). On the PWA, baseURL is
// empty and this is a same-origin call where credentials default to
// 'same-origin' anyway.
credentials: baseURL ? "include" : "same-origin",
async onResponse({ request, response, options }) {
const method = String(options.method ?? "GET");
const url = typeof request === "string" ? request : String(request);
if (response.ok && isCacheable(url, method)) {
// Clone so we don't disturb the caller's view of the body.
try {
await cachePut(url, response._data, response.status);
} catch {
// Cache write failures must never break the response path.
}
}
if (response.ok && isMutation(method)) {
const path = stripOrigin(url).split("?")[0] ?? "";
if (path.startsWith("/api/")) await invalidatePrefix(path);
}
},
async onResponseError({ request, response, options }) {
const method = String(options.method ?? "GET");
const url = typeof request === "string" ? request : String(request);
// For 5xx we can also try the cache for GETs. For 4xx we should let
// the error propagate — it's a real auth/validation problem, not a
// network issue, and a stale cache hit would mask it.
if (response.status >= 500 && isCacheable(url, method)) {
const hit = await cacheGet(url);
if (hit) {
// Mutate response so downstream consumers see the cached body.
// ofetch will still throw because the status is non-2xx, so the
// caller has to catch and look at the data — for the in-app GETs
// we go through onRequestError below for actual offline cases.
(response as { _data: unknown })._data = hit.body;
}
}
},
});
// ofetch doesn't let us swap the body of a *rejected* response via
// onResponseError easily (the throw still happens). For offline GETs we
// wrap the configured fetch in a final layer that catches network errors
// and returns the cached body directly.
const wrapped = ((request: unknown, opts?: Record<string, unknown>) => {
const method = String((opts?.["method"] as string | undefined) ?? "GET");
const url = typeof request === "string" ? request : String(request);
if (isMutation(method) && typeof navigator !== "undefined" && navigator.onLine === false) {
const path = stripOrigin(url);
try {
useToast().warning(
"You're offline — couldn't save. Try again when you reconnect.",
);
} catch {
// If useToast isn't available yet (e.g. SSR fallback), don't block
// the rejection.
}
return Promise.reject(new OfflineWriteError(method, path));
}
return configured(request as Parameters<typeof configured>[0], opts).catch(
async (err: unknown) => {
if (!isCacheable(url, method)) throw err;
if (!isLikelyOfflineError(err)) throw err;
const hit = await cacheGet(url);
if (!hit) throw err;
return hit.body;
},
);
}) as typeof $fetch;
// Carry the helpers from the configured instance so callers that use
// `$fetch.raw(...)` or `$fetch.create(...)` still work.
Object.assign(wrapped, {
raw: configured.raw.bind(configured),
create: configured.create.bind(configured),
});
(globalThis as unknown as { $fetch: typeof $fetch }).$fetch = wrapped;
});