@gemmein/sdk 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +209 -0
- package/README.md +82 -19
- package/REFERENCE.md +401 -50
- package/dist/expo.cjs +423 -0
- package/dist/expo.d.cts +152 -0
- package/dist/expo.d.ts +152 -0
- package/dist/expo.js +387 -0
- package/dist/index.cjs +330 -44
- package/dist/index.d.cts +243 -21
- package/dist/index.d.ts +243 -21
- package/dist/index.js +329 -44
- package/llms.txt +460 -78
- package/migrations/README.md +2 -0
- package/migrations/raw-calls-off.md +51 -0
- package/migrations/secure-store-set-throws.md +75 -0
- package/package.json +23 -2
package/dist/expo.cjs
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// W10 §1 A — `@gemmein/sdk/expo`: the SAME package, a second entry point.
|
|
3
|
+
//
|
|
4
|
+
// The core entry (`@gemmein/sdk`) stays platform-neutral and keeps ZERO
|
|
5
|
+
// dependency on Expo or React Native: every platform seam it needs is an
|
|
6
|
+
// option it already takes — `tokenStore`, `visibility`, `fetch`,
|
|
7
|
+
// `platform`. This file is nothing but those four options filled in with
|
|
8
|
+
// a phone's answers, plus the whole core surface re-exported so an Expo
|
|
9
|
+
// app never has to import from two places.
|
|
10
|
+
//
|
|
11
|
+
// Three rules this file keeps:
|
|
12
|
+
//
|
|
13
|
+
// 1. NOTHING is imported from Expo or React Native at module load. The
|
|
14
|
+
// modules are resolved lazily, inside a try, the first time a method
|
|
15
|
+
// that needs one runs — so importing this entry in Node (the tests
|
|
16
|
+
// below, an SSR pass, a script) resolves and runs without any of them
|
|
17
|
+
// installed. The specifiers are static strings so Metro can still see
|
|
18
|
+
// and bundle them in a real app.
|
|
19
|
+
// 2. Every module is INJECTABLE. Tests pass fakes; nothing here reaches
|
|
20
|
+
// for a global to decide what platform it is on.
|
|
21
|
+
// 3. A failure to READ the secure store degrades to "signed out" — the
|
|
22
|
+
// same law `BrowserTokenStore` keeps for a browser with storage
|
|
23
|
+
// blocked. A crash on a locked keychain would lock the app out. A
|
|
24
|
+
// failure to WRITE throws `secure_store_unavailable` (W10 row 9c): a
|
|
25
|
+
// store that cannot keep the session has broken the promise it exists
|
|
26
|
+
// for, and silence there signs the person out on the next launch with
|
|
27
|
+
// nothing anywhere saying why.
|
|
28
|
+
//
|
|
29
|
+
// One thing beyond those four seams: `upload()`'s picker shape. Expo's
|
|
30
|
+
// fetch refuses a bare `{ uri, name, type, size }` part, so the collections
|
|
31
|
+
// this entry hands out convert it to an `expo-file-system` `File` first —
|
|
32
|
+
// see `expoUploadPart`.
|
|
33
|
+
//
|
|
34
|
+
// Install in the app, not here (they are optional peer dependencies):
|
|
35
|
+
// npx expo install expo-secure-store expo-file-system
|
|
36
|
+
//
|
|
37
|
+
// import { createExpoGemmein } from "@gemmein/sdk/expo";
|
|
38
|
+
// import { Platform } from "react-native";
|
|
39
|
+
// export const g = createExpoGemmein({ appKey: "pk_live_..." }, { Platform });
|
|
40
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
41
|
+
if (k2 === undefined) k2 = k;
|
|
42
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
43
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
44
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
45
|
+
}
|
|
46
|
+
Object.defineProperty(o, k2, desc);
|
|
47
|
+
}) : (function(o, m, k, k2) {
|
|
48
|
+
if (k2 === undefined) k2 = k;
|
|
49
|
+
o[k2] = m[k];
|
|
50
|
+
}));
|
|
51
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
52
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
53
|
+
};
|
|
54
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
+
exports.SecureStoreTokenStore = exports.SECURE_STORE_VALUE_LIMIT = exports.GemmeinServer = exports.CollectionClient = exports.StorageClient = exports.AiClient = exports.CreditsClient = exports.AccountClient = exports.PaymentsClient = exports.SubscriptionsClient = exports.FilesClient = exports.PurchasesClient = exports.AuthClient = exports.GemmeinError = exports.Gemmein = void 0;
|
|
56
|
+
exports.appStateVisibility = appStateVisibility;
|
|
57
|
+
exports.expoFetch = expoFetch;
|
|
58
|
+
exports.expoPlatformTag = expoPlatformTag;
|
|
59
|
+
exports.expoUploadPart = expoUploadPart;
|
|
60
|
+
exports.createExpoGemmein = createExpoGemmein;
|
|
61
|
+
const index_js_1 = require("./index.cjs");
|
|
62
|
+
// The full core surface, re-exported. `export *` carries the types and the
|
|
63
|
+
// helpers; the explicit list below names every client class the SDK
|
|
64
|
+
// registry knows about (ops/docs/facts.ts SDK_CLASSES), which is what
|
|
65
|
+
// tests/security/sdkParity.test.ts reads to prove this entry is not a
|
|
66
|
+
// smaller SDK wearing the same name.
|
|
67
|
+
__exportStar(require("./index.cjs"), exports);
|
|
68
|
+
var index_js_2 = require("./index.cjs");
|
|
69
|
+
Object.defineProperty(exports, "Gemmein", { enumerable: true, get: function () { return index_js_2.Gemmein; } });
|
|
70
|
+
Object.defineProperty(exports, "GemmeinError", { enumerable: true, get: function () { return index_js_2.GemmeinError; } });
|
|
71
|
+
Object.defineProperty(exports, "AuthClient", { enumerable: true, get: function () { return index_js_2.AuthClient; } });
|
|
72
|
+
Object.defineProperty(exports, "PurchasesClient", { enumerable: true, get: function () { return index_js_2.PurchasesClient; } });
|
|
73
|
+
Object.defineProperty(exports, "FilesClient", { enumerable: true, get: function () { return index_js_2.FilesClient; } });
|
|
74
|
+
Object.defineProperty(exports, "SubscriptionsClient", { enumerable: true, get: function () { return index_js_2.SubscriptionsClient; } });
|
|
75
|
+
Object.defineProperty(exports, "PaymentsClient", { enumerable: true, get: function () { return index_js_2.PaymentsClient; } });
|
|
76
|
+
Object.defineProperty(exports, "AccountClient", { enumerable: true, get: function () { return index_js_2.AccountClient; } });
|
|
77
|
+
Object.defineProperty(exports, "CreditsClient", { enumerable: true, get: function () { return index_js_2.CreditsClient; } });
|
|
78
|
+
Object.defineProperty(exports, "AiClient", { enumerable: true, get: function () { return index_js_2.AiClient; } });
|
|
79
|
+
Object.defineProperty(exports, "StorageClient", { enumerable: true, get: function () { return index_js_2.StorageClient; } });
|
|
80
|
+
Object.defineProperty(exports, "CollectionClient", { enumerable: true, get: function () { return index_js_2.CollectionClient; } });
|
|
81
|
+
Object.defineProperty(exports, "GemmeinServer", { enumerable: true, get: function () { return index_js_2.GemmeinServer; } });
|
|
82
|
+
/**
|
|
83
|
+
* The lazy resolver. Static specifiers so Metro bundles them; a `catch`
|
|
84
|
+
* so Node — where none of them exist — gets `undefined` rather than an
|
|
85
|
+
* unhandled rejection. Resolved once per specifier and remembered.
|
|
86
|
+
*/
|
|
87
|
+
const resolved = new Map();
|
|
88
|
+
function loadModule(name) {
|
|
89
|
+
const cached = resolved.get(name);
|
|
90
|
+
if (cached)
|
|
91
|
+
return cached;
|
|
92
|
+
const attempt = (async () => {
|
|
93
|
+
try {
|
|
94
|
+
switch (name) {
|
|
95
|
+
// @ts-ignore optional peer dependency — present only in an Expo app
|
|
96
|
+
case "expo-secure-store": return (await Promise.resolve().then(() => require("expo-secure-store")));
|
|
97
|
+
// @ts-ignore optional peer dependency — present only in a React Native app
|
|
98
|
+
case "react-native": return (await Promise.resolve().then(() => require("react-native")));
|
|
99
|
+
// @ts-ignore optional peer dependency — present only in an Expo app
|
|
100
|
+
case "expo/fetch": return (await Promise.resolve().then(() => require("expo/fetch")));
|
|
101
|
+
// @ts-ignore optional peer dependency — present only in an Expo app
|
|
102
|
+
case "expo-file-system": return (await Promise.resolve().then(() => require("expo-file-system")));
|
|
103
|
+
default: return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
resolved.set(name, attempt);
|
|
111
|
+
return attempt;
|
|
112
|
+
}
|
|
113
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
114
|
+
// SecureStoreTokenStore — the session in the Keychain / Keystore
|
|
115
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
116
|
+
/**
|
|
117
|
+
* iOS refuses a Keychain value much over 2 KB through expo-secure-store,
|
|
118
|
+
* and the failure is a native throw the app cannot read. A Gemmein session
|
|
119
|
+
* token is a short opaque string — two orders of magnitude under this — so
|
|
120
|
+
* anything at this size is a caller storing the wrong thing. It is refused
|
|
121
|
+
* HERE, with a sentence, rather than on a device the author cannot see.
|
|
122
|
+
*/
|
|
123
|
+
exports.SECURE_STORE_VALUE_LIMIT = 2048;
|
|
124
|
+
/**
|
|
125
|
+
* The one sentence a refused WRITE reads as (W10 row 9c). The device's own
|
|
126
|
+
* throw travels on `cause`, so an app that logs it keeps the native message
|
|
127
|
+
* — `errSecMissingEntitlement`, a locked keychain, a Keystore that is not
|
|
128
|
+
* there — instead of a Gemmein sentence standing in for it.
|
|
129
|
+
*/
|
|
130
|
+
function secureStoreUnavailable(cause) {
|
|
131
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
132
|
+
return new index_js_1.GemmeinError({
|
|
133
|
+
status: 0,
|
|
134
|
+
code: "secure_store_unavailable",
|
|
135
|
+
message: `The session could not be stored: expo-secure-store is not available or refused the write (${detail}) ` +
|
|
136
|
+
"— install expo-secure-store, or pass a tokenStore",
|
|
137
|
+
cause,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The session, kept in the iOS Keychain / Android Keystore through
|
|
142
|
+
* `expo-secure-store`. Keyed per app key exactly as `BrowserTokenStore`
|
|
143
|
+
* keys localStorage, so two Gemmein apps in one binary never share a
|
|
144
|
+
* token.
|
|
145
|
+
*
|
|
146
|
+
* `keychainAccessible: WHEN_UNLOCKED_THIS_DEVICE_ONLY` — the token is
|
|
147
|
+
* readable only while the device is unlocked AND never travels in an
|
|
148
|
+
* iCloud/iTunes backup or to a restored device. A session that survives a
|
|
149
|
+
* device restore is a session the person cannot revoke by losing the
|
|
150
|
+
* phone.
|
|
151
|
+
*
|
|
152
|
+
* Reads and clears are guarded: a locked keychain, a wiped entry, or a
|
|
153
|
+
* runtime with no secure store at all degrades to "signed out", never to
|
|
154
|
+
* a crash. A WRITE the device refuses throws `secure_store_unavailable` —
|
|
155
|
+
* see the note on `set`.
|
|
156
|
+
*/
|
|
157
|
+
class SecureStoreTokenStore {
|
|
158
|
+
constructor(appKey, secureStore) {
|
|
159
|
+
// The same derivation as BrowserTokenStore (packages/sdk/src/index.ts):
|
|
160
|
+
// one law, two platforms. Every character a pk_ key can carry is legal
|
|
161
|
+
// in a SecureStore key.
|
|
162
|
+
this.key = `gemmein_session_${appKey.slice(0, 20)}`;
|
|
163
|
+
this.injected = secureStore;
|
|
164
|
+
}
|
|
165
|
+
async store() {
|
|
166
|
+
if (this.injected)
|
|
167
|
+
return this.injected;
|
|
168
|
+
const mod = await loadModule("expo-secure-store");
|
|
169
|
+
if (!mod)
|
|
170
|
+
return undefined;
|
|
171
|
+
const candidate = (mod.default ?? mod);
|
|
172
|
+
return typeof candidate?.getItemAsync === "function" ? candidate : undefined;
|
|
173
|
+
}
|
|
174
|
+
/** The accessibility class, read off the module so a version that renames
|
|
175
|
+
* the constant does not silently fall back to the iCloud-backed default. */
|
|
176
|
+
options(mod) {
|
|
177
|
+
const accessible = mod.WHEN_UNLOCKED_THIS_DEVICE_ONLY;
|
|
178
|
+
return accessible === undefined ? {} : { keychainAccessible: accessible };
|
|
179
|
+
}
|
|
180
|
+
async get() {
|
|
181
|
+
try {
|
|
182
|
+
const mod = await this.store();
|
|
183
|
+
if (!mod)
|
|
184
|
+
return undefined;
|
|
185
|
+
return (await mod.getItemAsync(this.key, this.options(mod))) ?? undefined;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return undefined; // locked, missing, or unavailable — signed out
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async set(token) {
|
|
192
|
+
// Measured in BYTES: the limit is the platform's, not JavaScript's.
|
|
193
|
+
const bytes = new TextEncoder().encode(token).length;
|
|
194
|
+
if (bytes > exports.SECURE_STORE_VALUE_LIMIT) {
|
|
195
|
+
throw new index_js_1.GemmeinError({
|
|
196
|
+
status: 0,
|
|
197
|
+
code: "token_too_large",
|
|
198
|
+
message: `This value is ${bytes} bytes; the device secure store holds ${exports.SECURE_STORE_VALUE_LIMIT}. ` +
|
|
199
|
+
"A Gemmein session token is far smaller — store the token, not the session payload.",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
// W10 row 9c. This used to return silently on both arms below, so an app
|
|
203
|
+
// signed in, stored nothing, and reported itself signed out on the next
|
|
204
|
+
// launch with nothing anywhere saying why — the same silence the Swift
|
|
205
|
+
// `KeychainTokenStore` kept, found by driving the reference app on an
|
|
206
|
+
// unsigned simulator build. A store that cannot PERSIST the session has
|
|
207
|
+
// broken the one promise it exists for, so it says so. Reads and clears
|
|
208
|
+
// stay lenient: unreadable means signed out, which every app handles.
|
|
209
|
+
//
|
|
210
|
+
// The session is real either way — it was minted server-side and
|
|
211
|
+
// `verifyEmailCode` never gets to return it — so an app that would rather
|
|
212
|
+
// run than stop can catch this one code and rebuild its client with a
|
|
213
|
+
// `MemoryTokenStore`: sign-in works, and ends with the process.
|
|
214
|
+
let mod;
|
|
215
|
+
try {
|
|
216
|
+
mod = await this.store();
|
|
217
|
+
}
|
|
218
|
+
catch (cause) {
|
|
219
|
+
throw secureStoreUnavailable(cause);
|
|
220
|
+
}
|
|
221
|
+
if (!mod)
|
|
222
|
+
throw secureStoreUnavailable(new Error("expo-secure-store did not resolve"));
|
|
223
|
+
try {
|
|
224
|
+
await mod.setItemAsync(this.key, token, this.options(mod));
|
|
225
|
+
}
|
|
226
|
+
catch (cause) {
|
|
227
|
+
throw secureStoreUnavailable(cause);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async clear() {
|
|
231
|
+
try {
|
|
232
|
+
const mod = await this.store();
|
|
233
|
+
if (!mod)
|
|
234
|
+
return;
|
|
235
|
+
await mod.deleteItemAsync(this.key, this.options(mod));
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
/* nothing to clear */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
exports.SecureStoreTokenStore = SecureStoreTokenStore;
|
|
243
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
244
|
+
// appStateVisibility — "is the app in front of the person?"
|
|
245
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
246
|
+
/**
|
|
247
|
+
* The `VisibilityHook` `watch()` sleeps on, answered by React Native's
|
|
248
|
+
* `AppState` instead of the browser's `document.visibilityState`.
|
|
249
|
+
*
|
|
250
|
+
* `AppState.currentState` is `"active" | "background" | "inactive"` (and
|
|
251
|
+
* `null` before the first read). Only `"active"` is in front of the
|
|
252
|
+
* person: `"inactive"` is the app-switcher card, a phone call, the
|
|
253
|
+
* Control Center pulled down — a screen the person is not reading, so
|
|
254
|
+
* polling there burns battery for nothing.
|
|
255
|
+
*
|
|
256
|
+
* Before the module has resolved the app is treated as VISIBLE, which is
|
|
257
|
+
* the browser default and the safe one: a watcher that wrongly believes
|
|
258
|
+
* it is hidden goes quiet, and quiet is the failure nobody notices.
|
|
259
|
+
*/
|
|
260
|
+
function appStateVisibility(appState) {
|
|
261
|
+
let mod = appState;
|
|
262
|
+
const waiting = [];
|
|
263
|
+
if (!mod) {
|
|
264
|
+
void loadModule("react-native").then((rn) => {
|
|
265
|
+
const candidate = rn?.AppState;
|
|
266
|
+
if (!candidate || typeof candidate.addEventListener !== "function")
|
|
267
|
+
return;
|
|
268
|
+
mod = candidate;
|
|
269
|
+
for (const attach of waiting.splice(0))
|
|
270
|
+
attach(candidate);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
isHidden() {
|
|
275
|
+
return mod ? mod.currentState !== "active" : false;
|
|
276
|
+
},
|
|
277
|
+
onChange(cb) {
|
|
278
|
+
let remove;
|
|
279
|
+
let cancelled = false;
|
|
280
|
+
const attach = (m) => {
|
|
281
|
+
if (cancelled)
|
|
282
|
+
return;
|
|
283
|
+
// RN ≥ 0.65 returns a subscription with `.remove()`; older builds
|
|
284
|
+
// returned nothing and were removed by `removeEventListener`. Both
|
|
285
|
+
// shapes collapse to one remover here.
|
|
286
|
+
const subscription = m.addEventListener("change", () => cb());
|
|
287
|
+
remove = typeof subscription === "function"
|
|
288
|
+
? subscription
|
|
289
|
+
: subscription && typeof subscription.remove === "function"
|
|
290
|
+
? () => subscription.remove()
|
|
291
|
+
: undefined;
|
|
292
|
+
};
|
|
293
|
+
if (mod)
|
|
294
|
+
attach(mod);
|
|
295
|
+
else
|
|
296
|
+
waiting.push(attach);
|
|
297
|
+
return () => {
|
|
298
|
+
cancelled = true;
|
|
299
|
+
const off = remove;
|
|
300
|
+
remove = undefined;
|
|
301
|
+
off?.();
|
|
302
|
+
};
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
307
|
+
// The fetch and the platform tag
|
|
308
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
309
|
+
/**
|
|
310
|
+
* `expo/fetch` — the WinterCG fetch Expo ships. React Native's own fetch
|
|
311
|
+
* is XHR-backed and cannot give a streaming body, which is what
|
|
312
|
+
* `g.ai.chat({ stream: true })` and `g.ai.run(..., { stream: true })`
|
|
313
|
+
* return. Resolved on the first request; `globalThis.fetch` if Expo's is
|
|
314
|
+
* not there, so a bare React Native app still works for everything that
|
|
315
|
+
* is not a stream.
|
|
316
|
+
*/
|
|
317
|
+
function expoFetch(injected) {
|
|
318
|
+
let impl = injected;
|
|
319
|
+
return (async (input, init) => {
|
|
320
|
+
if (!impl) {
|
|
321
|
+
const mod = await loadModule("expo/fetch");
|
|
322
|
+
const candidate = mod?.fetch;
|
|
323
|
+
impl = typeof candidate === "function" ? candidate : (i, n) => globalThis.fetch(i, n);
|
|
324
|
+
}
|
|
325
|
+
return impl(input, init);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The `x-client-info` platform tag: `expo-ios`, `expo-android`, `expo-web`.
|
|
330
|
+
*
|
|
331
|
+
* It is baked into the header when the client is CONSTRUCTED, so it cannot
|
|
332
|
+
* wait on a dynamic import. Pass `Platform` from `react-native` (a static
|
|
333
|
+
* import that costs an Expo app nothing) and the tag names the OS; without
|
|
334
|
+
* it the tag is the honest `expo`. Either way it is a report, never a
|
|
335
|
+
* proof — the ledger stores it as one.
|
|
336
|
+
*/
|
|
337
|
+
function expoPlatformTag(platform) {
|
|
338
|
+
const os = typeof platform?.OS === "string" ? platform.OS : "";
|
|
339
|
+
return os ? `expo-${os}` : "expo";
|
|
340
|
+
}
|
|
341
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
342
|
+
// The upload part — the picker's `{ uri }`, turned into bytes
|
|
343
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
344
|
+
/**
|
|
345
|
+
* W10 row 9, found by driving the Expo app: `upload()` takes the picker's
|
|
346
|
+
* `{ uri, name, type, size }`, but `expo/fetch` — the fetch this entry
|
|
347
|
+
* hands the client, and the only one that can stream an AI answer — refuses
|
|
348
|
+
* that part when its `FormData` serialises it:
|
|
349
|
+
* `Unsupported FormDataPart implementation`. React Native's own fetch reads
|
|
350
|
+
* the bytes at `uri`; Expo's will not.
|
|
351
|
+
*
|
|
352
|
+
* So the shape is converted here, before it ever reaches core `upload()`:
|
|
353
|
+
* `new File(uri)` from `expo-file-system` is a real `Blob` that carries
|
|
354
|
+
* `uri`, `name`, `type` and `size` — the three fields the presign reads,
|
|
355
|
+
* plus bytes any FormData can serialise. The picker shape therefore works
|
|
356
|
+
* through `@gemmein/sdk/expo` exactly as the docs say it does.
|
|
357
|
+
*
|
|
358
|
+
* Anything that already carries its own bytes — a `Blob`, a browser `File`,
|
|
359
|
+
* an `expo-file-system` `File` — is passed through untouched.
|
|
360
|
+
*/
|
|
361
|
+
async function expoUploadPart(file, modules = {}) {
|
|
362
|
+
const part = file;
|
|
363
|
+
// Already byte-bearing: a Blob, a File, an expo-file-system File. Duck-typed
|
|
364
|
+
// rather than `instanceof Blob`, because the class a phone's runtime has is
|
|
365
|
+
// not always the one this module can see.
|
|
366
|
+
const carriesBytes = typeof part.arrayBuffer === "function" || typeof part.stream === "function" ||
|
|
367
|
+
typeof part.slice === "function" || typeof part.bytes === "function";
|
|
368
|
+
if (carriesBytes || typeof part.uri !== "string")
|
|
369
|
+
return file;
|
|
370
|
+
const injected = modules.FileSystem;
|
|
371
|
+
const mod = injected ?? (await loadModule("expo-file-system"));
|
|
372
|
+
const FileClass = mod?.File;
|
|
373
|
+
if (typeof FileClass !== "function") {
|
|
374
|
+
throw new index_js_1.GemmeinError({
|
|
375
|
+
status: 0,
|
|
376
|
+
code: "upload_input_unsupported",
|
|
377
|
+
message: "This upload input cannot be read on Expo — pass a Blob or an expo-file-system File; " +
|
|
378
|
+
"the picker's { uri } needs expo-file-system installed (npx expo install expo-file-system)",
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
return new FileClass(part.uri);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Every collection this client hands out gets the conversion above in front
|
|
385
|
+
* of its `upload`. It is wrapped on the ONE factory both spellings go
|
|
386
|
+
* through — `g.collection(name)` delegates to `g.storage.collection(name)`
|
|
387
|
+
* — so there is one seam, not two, and every other method is the core
|
|
388
|
+
* client's own, untouched.
|
|
389
|
+
*/
|
|
390
|
+
function wrapUploads(client, modules) {
|
|
391
|
+
const storage = client.storage;
|
|
392
|
+
const core = storage.collection.bind(client.storage);
|
|
393
|
+
storage.collection = (name, options) => {
|
|
394
|
+
const collection = core(name, options);
|
|
395
|
+
const upload = collection.upload.bind(collection);
|
|
396
|
+
collection.upload = async (file, uploadOptions) => upload(await expoUploadPart(file, modules), uploadOptions);
|
|
397
|
+
return collection;
|
|
398
|
+
};
|
|
399
|
+
return client;
|
|
400
|
+
}
|
|
401
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
402
|
+
// createExpoGemmein — the four options, filled in
|
|
403
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
404
|
+
/**
|
|
405
|
+
* The Expo client. Identical to `gemmein()` in every method, because it IS
|
|
406
|
+
* `Gemmein` — the only difference is where the session is kept, how "is
|
|
407
|
+
* the app in front?" is answered, which fetch carries the bytes, and what
|
|
408
|
+
* the client tag says.
|
|
409
|
+
*
|
|
410
|
+
* const g = createExpoGemmein({ appKey: "pk_live_..." }, { Platform });
|
|
411
|
+
*
|
|
412
|
+
* Every default is overridable: pass your own `tokenStore`, `visibility`,
|
|
413
|
+
* `fetch` or `platform` in `options` and it wins.
|
|
414
|
+
*/
|
|
415
|
+
function createExpoGemmein(options, modules = {}) {
|
|
416
|
+
return wrapUploads(new index_js_1.Gemmein({
|
|
417
|
+
tokenStore: new SecureStoreTokenStore(options.appKey, modules.SecureStore),
|
|
418
|
+
visibility: appStateVisibility(modules.AppState),
|
|
419
|
+
fetch: expoFetch(modules.fetch),
|
|
420
|
+
platform: expoPlatformTag(modules.Platform),
|
|
421
|
+
...options,
|
|
422
|
+
}), modules);
|
|
423
|
+
}
|
package/dist/expo.d.cts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Gemmein } from "./index.js";
|
|
2
|
+
import type { GemmeinOptions, TokenStore, UploadInput, VisibilityHook } from "./index.js";
|
|
3
|
+
export * from "./index.js";
|
|
4
|
+
export { Gemmein, GemmeinError, AuthClient, PurchasesClient, FilesClient, SubscriptionsClient, PaymentsClient, AccountClient, CreditsClient, AiClient, StorageClient, CollectionClient, GemmeinServer, } from "./index.js";
|
|
5
|
+
/** The slice of `expo-secure-store` this entry calls. */
|
|
6
|
+
export type SecureStoreModule = {
|
|
7
|
+
getItemAsync(key: string, options?: Record<string, unknown>): Promise<string | null>;
|
|
8
|
+
setItemAsync(key: string, value: string, options?: Record<string, unknown>): Promise<void>;
|
|
9
|
+
deleteItemAsync(key: string, options?: Record<string, unknown>): Promise<void>;
|
|
10
|
+
/** `WHEN_UNLOCKED_THIS_DEVICE_ONLY` — read off the module when present. */
|
|
11
|
+
WHEN_UNLOCKED_THIS_DEVICE_ONLY?: unknown;
|
|
12
|
+
};
|
|
13
|
+
/** The slice of React Native's `AppState` this entry subscribes to. */
|
|
14
|
+
export type AppStateModule = {
|
|
15
|
+
currentState: string | null;
|
|
16
|
+
addEventListener(type: string, handler: (state: string) => void): {
|
|
17
|
+
remove(): void;
|
|
18
|
+
} | (() => void);
|
|
19
|
+
};
|
|
20
|
+
/** The slice of React Native's `Platform` this entry reads. */
|
|
21
|
+
export type PlatformModule = {
|
|
22
|
+
OS: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The slice of `expo-file-system` this entry calls: its `File` class.
|
|
26
|
+
*
|
|
27
|
+
* `new File(uri)` — the constructor takes the `file:///` URI a picker or a
|
|
28
|
+
* camera handed back (it accepts more segments, and a `Directory`/`File`
|
|
29
|
+
* first argument, which this entry never needs). The instance implements
|
|
30
|
+
* `Blob` and carries `uri`, `name`, `type` and `size`, which is exactly
|
|
31
|
+
* what `upload()` reads.
|
|
32
|
+
*/
|
|
33
|
+
export type FileSystemModule = {
|
|
34
|
+
File: new (uri: string) => Blob & {
|
|
35
|
+
uri?: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
type?: string | null;
|
|
38
|
+
size?: number | null;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/** Everything an Expo app can hand in instead of letting this file resolve
|
|
42
|
+
* it. Tests pass fakes here; an app may pass its own static imports, which
|
|
43
|
+
* is the fastest path (no dynamic resolution at all). */
|
|
44
|
+
export type ExpoModules = {
|
|
45
|
+
SecureStore?: SecureStoreModule;
|
|
46
|
+
AppState?: AppStateModule;
|
|
47
|
+
Platform?: PlatformModule;
|
|
48
|
+
/** `expo-file-system`, for `upload()`'s picker shape — see `expoUploadPart`. */
|
|
49
|
+
FileSystem?: FileSystemModule;
|
|
50
|
+
fetch?: typeof fetch;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* iOS refuses a Keychain value much over 2 KB through expo-secure-store,
|
|
54
|
+
* and the failure is a native throw the app cannot read. A Gemmein session
|
|
55
|
+
* token is a short opaque string — two orders of magnitude under this — so
|
|
56
|
+
* anything at this size is a caller storing the wrong thing. It is refused
|
|
57
|
+
* HERE, with a sentence, rather than on a device the author cannot see.
|
|
58
|
+
*/
|
|
59
|
+
export declare const SECURE_STORE_VALUE_LIMIT = 2048;
|
|
60
|
+
/**
|
|
61
|
+
* The session, kept in the iOS Keychain / Android Keystore through
|
|
62
|
+
* `expo-secure-store`. Keyed per app key exactly as `BrowserTokenStore`
|
|
63
|
+
* keys localStorage, so two Gemmein apps in one binary never share a
|
|
64
|
+
* token.
|
|
65
|
+
*
|
|
66
|
+
* `keychainAccessible: WHEN_UNLOCKED_THIS_DEVICE_ONLY` — the token is
|
|
67
|
+
* readable only while the device is unlocked AND never travels in an
|
|
68
|
+
* iCloud/iTunes backup or to a restored device. A session that survives a
|
|
69
|
+
* device restore is a session the person cannot revoke by losing the
|
|
70
|
+
* phone.
|
|
71
|
+
*
|
|
72
|
+
* Reads and clears are guarded: a locked keychain, a wiped entry, or a
|
|
73
|
+
* runtime with no secure store at all degrades to "signed out", never to
|
|
74
|
+
* a crash. A WRITE the device refuses throws `secure_store_unavailable` —
|
|
75
|
+
* see the note on `set`.
|
|
76
|
+
*/
|
|
77
|
+
export declare class SecureStoreTokenStore implements TokenStore {
|
|
78
|
+
private readonly key;
|
|
79
|
+
private readonly injected?;
|
|
80
|
+
constructor(appKey: string, secureStore?: SecureStoreModule);
|
|
81
|
+
private store;
|
|
82
|
+
/** The accessibility class, read off the module so a version that renames
|
|
83
|
+
* the constant does not silently fall back to the iCloud-backed default. */
|
|
84
|
+
private options;
|
|
85
|
+
get(): Promise<string | undefined>;
|
|
86
|
+
set(token: string): Promise<void>;
|
|
87
|
+
clear(): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The `VisibilityHook` `watch()` sleeps on, answered by React Native's
|
|
91
|
+
* `AppState` instead of the browser's `document.visibilityState`.
|
|
92
|
+
*
|
|
93
|
+
* `AppState.currentState` is `"active" | "background" | "inactive"` (and
|
|
94
|
+
* `null` before the first read). Only `"active"` is in front of the
|
|
95
|
+
* person: `"inactive"` is the app-switcher card, a phone call, the
|
|
96
|
+
* Control Center pulled down — a screen the person is not reading, so
|
|
97
|
+
* polling there burns battery for nothing.
|
|
98
|
+
*
|
|
99
|
+
* Before the module has resolved the app is treated as VISIBLE, which is
|
|
100
|
+
* the browser default and the safe one: a watcher that wrongly believes
|
|
101
|
+
* it is hidden goes quiet, and quiet is the failure nobody notices.
|
|
102
|
+
*/
|
|
103
|
+
export declare function appStateVisibility(appState?: AppStateModule): VisibilityHook;
|
|
104
|
+
/**
|
|
105
|
+
* `expo/fetch` — the WinterCG fetch Expo ships. React Native's own fetch
|
|
106
|
+
* is XHR-backed and cannot give a streaming body, which is what
|
|
107
|
+
* `g.ai.chat({ stream: true })` and `g.ai.run(..., { stream: true })`
|
|
108
|
+
* return. Resolved on the first request; `globalThis.fetch` if Expo's is
|
|
109
|
+
* not there, so a bare React Native app still works for everything that
|
|
110
|
+
* is not a stream.
|
|
111
|
+
*/
|
|
112
|
+
export declare function expoFetch(injected?: typeof fetch): typeof fetch;
|
|
113
|
+
/**
|
|
114
|
+
* The `x-client-info` platform tag: `expo-ios`, `expo-android`, `expo-web`.
|
|
115
|
+
*
|
|
116
|
+
* It is baked into the header when the client is CONSTRUCTED, so it cannot
|
|
117
|
+
* wait on a dynamic import. Pass `Platform` from `react-native` (a static
|
|
118
|
+
* import that costs an Expo app nothing) and the tag names the OS; without
|
|
119
|
+
* it the tag is the honest `expo`. Either way it is a report, never a
|
|
120
|
+
* proof — the ledger stores it as one.
|
|
121
|
+
*/
|
|
122
|
+
export declare function expoPlatformTag(platform?: PlatformModule): string;
|
|
123
|
+
/**
|
|
124
|
+
* W10 row 9, found by driving the Expo app: `upload()` takes the picker's
|
|
125
|
+
* `{ uri, name, type, size }`, but `expo/fetch` — the fetch this entry
|
|
126
|
+
* hands the client, and the only one that can stream an AI answer — refuses
|
|
127
|
+
* that part when its `FormData` serialises it:
|
|
128
|
+
* `Unsupported FormDataPart implementation`. React Native's own fetch reads
|
|
129
|
+
* the bytes at `uri`; Expo's will not.
|
|
130
|
+
*
|
|
131
|
+
* So the shape is converted here, before it ever reaches core `upload()`:
|
|
132
|
+
* `new File(uri)` from `expo-file-system` is a real `Blob` that carries
|
|
133
|
+
* `uri`, `name`, `type` and `size` — the three fields the presign reads,
|
|
134
|
+
* plus bytes any FormData can serialise. The picker shape therefore works
|
|
135
|
+
* through `@gemmein/sdk/expo` exactly as the docs say it does.
|
|
136
|
+
*
|
|
137
|
+
* Anything that already carries its own bytes — a `Blob`, a browser `File`,
|
|
138
|
+
* an `expo-file-system` `File` — is passed through untouched.
|
|
139
|
+
*/
|
|
140
|
+
export declare function expoUploadPart(file: UploadInput, modules?: ExpoModules): Promise<UploadInput>;
|
|
141
|
+
/**
|
|
142
|
+
* The Expo client. Identical to `gemmein()` in every method, because it IS
|
|
143
|
+
* `Gemmein` — the only difference is where the session is kept, how "is
|
|
144
|
+
* the app in front?" is answered, which fetch carries the bytes, and what
|
|
145
|
+
* the client tag says.
|
|
146
|
+
*
|
|
147
|
+
* const g = createExpoGemmein({ appKey: "pk_live_..." }, { Platform });
|
|
148
|
+
*
|
|
149
|
+
* Every default is overridable: pass your own `tokenStore`, `visibility`,
|
|
150
|
+
* `fetch` or `platform` in `options` and it wins.
|
|
151
|
+
*/
|
|
152
|
+
export declare function createExpoGemmein(options: GemmeinOptions, modules?: ExpoModules): Gemmein;
|