@loopwise/sdk 0.1.0-beta.3 → 0.1.0-beta.4
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 +39 -1
- package/README.md +43 -2
- package/dist/config.d.mts +11 -0
- package/dist/index.d.mts +10 -10
- package/dist/index.mjs +24 -5
- package/package.json +5 -4
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,38 @@ follow the same rules as a single 0.x line.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [0.1.0-beta.4] — 2026-09-10
|
|
13
|
+
|
|
14
|
+
Storefront schema pinning for browser clients. A design partner could
|
|
15
|
+
not tell a honoured `API-Version` pin from an ignored header because the
|
|
16
|
+
same-origin `/api/graphql` proxy dropped it (TEACH-22755, #16417); the
|
|
17
|
+
proxy now forwards it, answers the CORS preflight for off-origin callers
|
|
18
|
+
(TEACH-22760, #16429), and this release gives the SDK a first-class way
|
|
19
|
+
to send the pin.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `LoopwiseConfig.apiVersion` — pins the storefront schema to a release
|
|
24
|
+
date. Sent as the `API-Version` header on every request (GET and POST);
|
|
25
|
+
the server echoes the resolved version in the `API-Version` response
|
|
26
|
+
header. A blank value throws `LoopwiseError` code `'CONFIG'` at
|
|
27
|
+
`createClient()`, because the server would treat it as absent and serve
|
|
28
|
+
the baseline silently. (TEACH-22755)
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **Breaking:** a non-2xx response whose body is a GraphQL errors payload
|
|
33
|
+
(objects with a `message`) now raises `LoopwiseError` with code
|
|
34
|
+
`'GRAPHQL'` and the errors on `graphqlErrors`, instead of `'NETWORK'` /
|
|
35
|
+
`Upstream <status>`. The first such response is the 400
|
|
36
|
+
`INVALID_API_VERSION` for a malformed `apiVersion`. Migration: code that
|
|
37
|
+
retries or branches on `code === 'NETWORK'` for a 4xx/5xx carrying a
|
|
38
|
+
GraphQL body should also handle `'GRAPHQL'` (check
|
|
39
|
+
`graphqlErrors[0].extensions.code` for the server's reason). Status
|
|
40
|
+
precedence is unchanged: 401/403 stay `'AUTH'` and 429 stays
|
|
41
|
+
`'RATE_LIMIT'` whatever their body, and other non-2xx responses without a
|
|
42
|
+
GraphQL body stay `'NETWORK'`.
|
|
43
|
+
|
|
12
44
|
## [0.1.0-beta.3] — 2026-05-20
|
|
13
45
|
|
|
14
46
|
### Added
|
|
@@ -62,8 +94,14 @@ follow the same rules as a single 0.x line.
|
|
|
62
94
|
(not line total), and null-guards the `tradeNo` URL-param lookup
|
|
63
95
|
example.
|
|
64
96
|
|
|
65
|
-
## [0.1.0-beta.1]
|
|
97
|
+
## [0.1.0-beta.1] — 2026-05-05
|
|
66
98
|
|
|
67
99
|
Initial public beta. `courses.list/get`, `posts.list/get`, `orders.get`,
|
|
68
100
|
`batch()`, raw `query()`. Schema-lock pipeline. Same-origin BFF and
|
|
69
101
|
explicit cross-origin endpoint resolution.
|
|
102
|
+
|
|
103
|
+
[Unreleased]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.4...HEAD
|
|
104
|
+
[0.1.0-beta.4]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.3...@loopwise/sdk@0.1.0-beta.4
|
|
105
|
+
[0.1.0-beta.3]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.2...@loopwise/sdk@0.1.0-beta.3
|
|
106
|
+
[0.1.0-beta.2]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.1...@loopwise/sdk@0.1.0-beta.2
|
|
107
|
+
[0.1.0-beta.1]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.0...@loopwise/sdk@0.1.0-beta.1
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Storefront GraphQL client for Loopwise / Teachify. Pick your path:
|
|
|
6
6
|
|---|---|---|
|
|
7
7
|
| A single self-contained HTML file (no build step — for `deploy_static_page`, a Claude Design artifact, or a CodePen) | none — load from a CDN | `https://esm.sh/@loopwise/sdk@beta` |
|
|
8
8
|
| A project with a bundler (Vite / Astro / Next.js / Remix) | `npm install @loopwise/sdk@beta` | `@loopwise/sdk` |
|
|
9
|
-
| **React** UI in either of the above | (see [`@loopwise/
|
|
9
|
+
| **React** UI in either of the above | (see [`@loopwise/react`](#react--coming-soon)) | — |
|
|
10
10
|
|
|
11
11
|
Same package powers all three; the difference is the loader.
|
|
12
12
|
|
|
@@ -63,6 +63,16 @@ that require a signed-in visitor — `cart`, `viewer.profile`,
|
|
|
63
63
|
authenticated mutations — **will not work** from off-site code.
|
|
64
64
|
Public, anonymous reads (`courses.list`, `posts.list`, etc.) work fine.
|
|
65
65
|
|
|
66
|
+
The site's `/api/graphql` answers the browser's CORS preflight with a
|
|
67
|
+
static `Access-Control-Allow-Origin: *` and no credentials. The
|
|
68
|
+
`Access-Control-Allow-Headers` grant names the non-safelisted headers
|
|
69
|
+
this SDK sends (`content-type` with a JSON value, `api-version`,
|
|
70
|
+
`x-loopwise-cache`); CORS-safelisted headers such as `Accept` need no
|
|
71
|
+
grant. A hand-rolled `fetch` that adds any other non-safelisted header
|
|
72
|
+
(`Authorization`, for one) fails the preflight, and
|
|
73
|
+
`credentials: 'include'` is refused by the browser. The response exposes
|
|
74
|
+
`API-Version` and `X-Request-Id`.
|
|
75
|
+
|
|
66
76
|
This is the dominant case for `deploy_static_page` bundles (which
|
|
67
77
|
serve at `<scriptName>.loopwise.page`):
|
|
68
78
|
|
|
@@ -123,6 +133,37 @@ is the explicit, type-system-visible declaration of "this must never be cached".
|
|
|
123
133
|
per Vercel POP, depending on traffic. Use shorter TTLs for time-sensitive
|
|
124
134
|
content; use `cache: 'no-store'` for admin previews.
|
|
125
135
|
|
|
136
|
+
## `apiVersion` option
|
|
137
|
+
|
|
138
|
+
The storefront schema is versioned by release date. Pin the one your code
|
|
139
|
+
was built against and the SDK sends it as the `API-Version` header on
|
|
140
|
+
every request, GET and POST alike:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const loopwise = createClient({ apiVersion: '2027-01-01' });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Omit it to get the baseline schema. The server echoes the version it
|
|
147
|
+
resolved in the `API-Version` response header (and lists the header in
|
|
148
|
+
`Vary`), and this holds through the same-origin `/api/graphql` proxy too.
|
|
149
|
+
The SDK's methods return parsed data only and do not surface response
|
|
150
|
+
headers, so confirm the pin once with the browser's network inspector or
|
|
151
|
+
a direct `fetch` to the same endpoint, not through an SDK return value:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const res = await fetch('/api/graphql', {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { 'content-type': 'application/json', 'API-Version': '2027-01-01' },
|
|
157
|
+
body: JSON.stringify({ query: '{ __typename }' }),
|
|
158
|
+
});
|
|
159
|
+
res.headers.get('API-Version'); // '2027-01-01' when the pin is honoured
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A value that is not a date is rejected with a 400 whose body is a GraphQL
|
|
163
|
+
errors payload; the SDK raises it as a `LoopwiseError` with code
|
|
164
|
+
`'GRAPHQL'` and `graphqlErrors[0].extensions.code === 'INVALID_API_VERSION'`,
|
|
165
|
+
so a malformed pin does fail loudly through the SDK.
|
|
166
|
+
|
|
126
167
|
## Preview modes
|
|
127
168
|
|
|
128
169
|
Two preview surfaces exist, each with different capability:
|
|
@@ -145,7 +186,7 @@ resolve under `/_preview/<scriptName>/`; root-absolute paths like
|
|
|
145
186
|
|
|
146
187
|
## React — coming soon
|
|
147
188
|
|
|
148
|
-
A `@loopwise/
|
|
189
|
+
A `@loopwise/react` companion package is planned, providing:
|
|
149
190
|
|
|
150
191
|
- `<LoopwiseProvider client={loopwise}>` context provider
|
|
151
192
|
- `useCourses({ limit })`, `useCourse({ slug })` hooks with Suspense support
|
package/dist/config.d.mts
CHANGED
|
@@ -32,6 +32,17 @@ interface LoopwiseConfig {
|
|
|
32
32
|
* is omitted).
|
|
33
33
|
*/
|
|
34
34
|
endpoint?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Pin the storefront schema to a release date (`'2027-01-01'`). Sent as
|
|
37
|
+
* the `API-Version` header on every request; omit it to get the baseline
|
|
38
|
+
* schema. The server echoes the resolved version in the `API-Version`
|
|
39
|
+
* response header; SDK methods return parsed data only, so read that
|
|
40
|
+
* header with a network inspector or a direct `fetch`. A value that is
|
|
41
|
+
* not a date is rejected with a 400 whose GraphQL error carries
|
|
42
|
+
* `extensions.code: 'INVALID_API_VERSION'` (surfaced as a
|
|
43
|
+
* `LoopwiseError` with code `'GRAPHQL'`).
|
|
44
|
+
*/
|
|
45
|
+
apiVersion?: string;
|
|
35
46
|
}
|
|
36
47
|
declare function defineConfig(config: LoopwiseConfig): LoopwiseConfig;
|
|
37
48
|
//#endregion
|
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { LoopwiseConfig } from "./config.mjs";
|
|
2
|
-
|
|
3
2
|
//#region src/error.d.ts
|
|
4
|
-
type LoopwiseErrorCode =
|
|
3
|
+
type LoopwiseErrorCode = "NETWORK" | "AUTH" | "GRAPHQL" | "RATE_LIMIT" | "CONFIG";
|
|
5
4
|
interface GraphQLError {
|
|
6
5
|
message: string;
|
|
7
6
|
path?: (string | number)[];
|
|
@@ -17,7 +16,8 @@ declare class LoopwiseError extends Error {
|
|
|
17
16
|
readonly graphqlErrors?: GraphQLError[];
|
|
18
17
|
readonly requestId?: string;
|
|
19
18
|
constructor(code: LoopwiseErrorCode, message: string, detail?: {
|
|
20
|
-
site?: string;
|
|
19
|
+
site?: string;
|
|
20
|
+
/** @deprecated Renamed to `site`. Removed in 0.2.0. */
|
|
21
21
|
tenant?: string;
|
|
22
22
|
endpoint?: string;
|
|
23
23
|
graphqlErrors?: GraphQLError[];
|
|
@@ -26,9 +26,9 @@ declare class LoopwiseError extends Error {
|
|
|
26
26
|
}
|
|
27
27
|
//#endregion
|
|
28
28
|
//#region src/select.d.ts
|
|
29
|
-
type SelectionInner<T> = T extends Array<infer U> ? SelectionInner<U> : T extends object ? { [K in keyof T]?: SelectionInner<T[K]
|
|
29
|
+
type SelectionInner<T> = T extends Array<infer U> ? SelectionInner<U> : T extends object ? { [K in keyof T]?: SelectionInner<T[K]>; } : true;
|
|
30
30
|
type Selection<T> = SelectionInner<NonNullable<T>>;
|
|
31
|
-
type SelectedInner<T, S> = T extends null | undefined ? T : S extends true ? T : T extends Array<infer U> ? Array<SelectedInner<U, S>> : T extends object ? { [K in keyof S & keyof T]: SelectedInner<T[K], S[K]
|
|
31
|
+
type SelectedInner<T, S> = T extends null | undefined ? T : S extends true ? T : T extends Array<infer U> ? Array<SelectedInner<U, S>> : T extends object ? { [K in keyof S & keyof T]: SelectedInner<T[K], S[K]>; } : T;
|
|
32
32
|
type Selected<T, S> = SelectedInner<T, S>;
|
|
33
33
|
//#endregion
|
|
34
34
|
//#region src/query.d.ts
|
|
@@ -64,7 +64,7 @@ interface FetchOptions {
|
|
|
64
64
|
* `Cache-Control: public, s-maxage=<seconds>`. `'no-store'` forces POST and
|
|
65
65
|
* skips any cache hint. Omit for the legacy POST behaviour.
|
|
66
66
|
*/
|
|
67
|
-
cache?: number |
|
|
67
|
+
cache?: number | "no-store";
|
|
68
68
|
}
|
|
69
69
|
interface BatchedFetcher {
|
|
70
70
|
/**
|
|
@@ -96,7 +96,7 @@ declare class LoopwiseClient implements BatchedFetcher {
|
|
|
96
96
|
raw<T = unknown>(document: string, variables?: Record<string, unknown>, operationName?: string, options?: FetchOptions): Promise<T>;
|
|
97
97
|
batch<R extends Record<string, unknown>>(builders: { [K in keyof R]: {
|
|
98
98
|
descriptor: QueryDescriptor;
|
|
99
|
-
} }, options?: FetchOptions): Promise<R>;
|
|
99
|
+
}; }, options?: FetchOptions): Promise<R>;
|
|
100
100
|
private fetchGraphQL;
|
|
101
101
|
private shouldUseGet;
|
|
102
102
|
private buildGetUrl;
|
|
@@ -144,7 +144,7 @@ interface Post {
|
|
|
144
144
|
/** Known values: `login_required`, `paid`, `public_access`. The
|
|
145
145
|
* `string & {}` escape hatch keeps autocomplete on the known set
|
|
146
146
|
* while accepting forward-compat values the schema may add. */
|
|
147
|
-
accessType:
|
|
147
|
+
accessType: "login_required" | "paid" | "public_access" | (string & {});
|
|
148
148
|
tags: string[] | null;
|
|
149
149
|
noindex: boolean;
|
|
150
150
|
author: Lecturer | null;
|
|
@@ -161,7 +161,7 @@ interface ListResult<T> {
|
|
|
161
161
|
pageInfo: PageInfo;
|
|
162
162
|
}
|
|
163
163
|
/** Simplified order status. Mirrors the storefront `Order.status` enum. */
|
|
164
|
-
type OrderStatus =
|
|
164
|
+
type OrderStatus = "PAID" | "PENDING" | "FAILED" | "REFUNDED";
|
|
165
165
|
interface OrderItem {
|
|
166
166
|
/**
|
|
167
167
|
* Meta-pixel-aligned identifier (Lineitem#tracking_id server-side).
|
|
@@ -331,7 +331,7 @@ interface Loopwise {
|
|
|
331
331
|
* recent: loopwise.courses.list({ limit: 3, page: 2 }),
|
|
332
332
|
* }, { cache: 60 })
|
|
333
333
|
*/
|
|
334
|
-
batch<R extends Record<string, QueryBuilder<unknown>>>(builders: R, options?: FetchOptions): Promise<{ [K in keyof R]: R[K] extends QueryBuilder<infer T> ? T : never }>;
|
|
334
|
+
batch<R extends Record<string, QueryBuilder<unknown>>>(builders: R, options?: FetchOptions): Promise<{ [K in keyof R]: R[K] extends QueryBuilder<infer T> ? T : never; }>;
|
|
335
335
|
/**
|
|
336
336
|
* Raw GraphQL escape hatch. Use when a typed resource method doesn't cover
|
|
337
337
|
* what you need (new schema fields, custom queries, ad-hoc inspection).
|
package/dist/index.mjs
CHANGED
|
@@ -36,6 +36,7 @@ var LoopwiseClient = class {
|
|
|
36
36
|
constructor(config) {
|
|
37
37
|
const site = config.site ?? config.tenant;
|
|
38
38
|
if (detectRuntime() !== "browser" && !site) throw new LoopwiseError("CONFIG", "site is required outside browser runtime. Pass it to createClient({ site }).");
|
|
39
|
+
if (config.apiVersion !== void 0 && config.apiVersion.trim() === "") throw new LoopwiseError("CONFIG", "apiVersion must be a release date (YYYY-MM-DD) or omitted; a blank value would be served as the baseline schema.");
|
|
39
40
|
this.config = {
|
|
40
41
|
...config,
|
|
41
42
|
site,
|
|
@@ -101,6 +102,7 @@ var LoopwiseClient = class {
|
|
|
101
102
|
const headers = { accept: "application/graphql-response+json, application/json" };
|
|
102
103
|
const apiKey = this.resolveApiKey();
|
|
103
104
|
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
|
105
|
+
if (this.config.apiVersion) headers["api-version"] = this.config.apiVersion;
|
|
104
106
|
const useGet = this.shouldUseGet(payload, options);
|
|
105
107
|
if (typeof options.cache === "number" && options.cache > 0) headers["x-loopwise-cache"] = String(options.cache);
|
|
106
108
|
let response;
|
|
@@ -134,11 +136,16 @@ var LoopwiseClient = class {
|
|
|
134
136
|
site: this.config.site,
|
|
135
137
|
endpoint: this.endpoint
|
|
136
138
|
});
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
139
|
+
let body;
|
|
140
|
+
if (response.ok) body = await response.json();
|
|
141
|
+
else {
|
|
142
|
+
const errorBody = await parseGraphQLErrorBody(response);
|
|
143
|
+
if (!errorBody) throw new LoopwiseError("NETWORK", `Upstream ${response.status}`, {
|
|
144
|
+
site: this.config.site,
|
|
145
|
+
endpoint: this.endpoint
|
|
146
|
+
});
|
|
147
|
+
body = errorBody;
|
|
148
|
+
}
|
|
142
149
|
if (body.errors?.length) throw new LoopwiseError("GRAPHQL", body.errors[0]?.message ?? "GraphQL error", {
|
|
143
150
|
site: this.config.site,
|
|
144
151
|
endpoint: this.endpoint,
|
|
@@ -168,6 +175,18 @@ var LoopwiseClient = class {
|
|
|
168
175
|
return globalThis.process?.env?.LOOPWISE_API_KEY;
|
|
169
176
|
}
|
|
170
177
|
};
|
|
178
|
+
async function parseGraphQLErrorBody(response) {
|
|
179
|
+
let parsed;
|
|
180
|
+
try {
|
|
181
|
+
parsed = JSON.parse(await response.text());
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
186
|
+
const errors = parsed.errors;
|
|
187
|
+
if (!Array.isArray(errors) || errors.length === 0) return null;
|
|
188
|
+
return errors.every((e) => e !== null && typeof e === "object" && typeof e.message === "string") ? parsed : null;
|
|
189
|
+
}
|
|
171
190
|
function firstOperationKeyword(document) {
|
|
172
191
|
let i = 0;
|
|
173
192
|
if (document.charCodeAt(0) === 65279) i = 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loopwise/sdk",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.4",
|
|
4
4
|
"description": "Loopwise platform SDK — agent-friendly headless API client",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,16 +43,17 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsdown",
|
|
45
45
|
"dev": "tsdown --watch",
|
|
46
|
-
"typecheck": "
|
|
46
|
+
"typecheck": "tsgo --noEmit",
|
|
47
47
|
"test": "vitest run",
|
|
48
48
|
"regen:lock": "node scripts/regen-lock.mjs"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
+
"@kaikhq/graphql-proxy": "workspace:*",
|
|
51
52
|
"graphql": "16.13.1",
|
|
52
53
|
"tsconfig": "workspace:*",
|
|
53
|
-
"tsdown": "
|
|
54
|
+
"tsdown": "catalog:",
|
|
54
55
|
"typescript": "5.9.3",
|
|
55
|
-
"vitest": "
|
|
56
|
+
"vitest": "catalog:"
|
|
56
57
|
},
|
|
57
58
|
"publishConfig": {
|
|
58
59
|
"access": "public"
|