@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 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/sdk-react`](#react--coming-soon)) | — |
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/sdk-react` companion package is planned, providing:
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 = 'NETWORK' | 'AUTH' | 'GRAPHQL' | 'RATE_LIMIT' | 'CONFIG';
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; /** @deprecated Renamed to `site`. Removed in 0.2.0. */
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]> } : true;
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]> } : 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]>; } : 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 | 'no-store';
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: 'login_required' | 'paid' | 'public_access' | (string & {});
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 = 'PAID' | 'PENDING' | 'FAILED' | 'REFUNDED';
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
- if (!response.ok) throw new LoopwiseError("NETWORK", `Upstream ${response.status}`, {
138
- site: this.config.site,
139
- endpoint: this.endpoint
140
- });
141
- const body = await response.json();
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",
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": "tsc --noEmit",
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": "0.22.0",
54
+ "tsdown": "catalog:",
54
55
  "typescript": "5.9.3",
55
- "vitest": "4.1.0"
56
+ "vitest": "catalog:"
56
57
  },
57
58
  "publishConfig": {
58
59
  "access": "public"