@loopwise/sdk 0.1.0-beta.2 → 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 +107 -0
- package/README.md +85 -40
- package/dist/config.d.mts +29 -6
- package/dist/index.d.mts +29 -19
- package/dist/index.mjs +50 -26
- package/package.json +7 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@loopwise/sdk` are documented here.
|
|
4
|
+
|
|
5
|
+
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
Versioning: [SemVer](https://semver.org/) with pre-1.0 semantics — minor bumps
|
|
7
|
+
(`0.x → 0.x+1`) may include breaking changes; beta tags (`0.1.0-beta.N`)
|
|
8
|
+
follow the same rules as a single 0.x line.
|
|
9
|
+
|
|
10
|
+
## [Unreleased]
|
|
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
|
+
|
|
44
|
+
## [0.1.0-beta.3] — 2026-05-20
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- `LoopwiseConfig.site` — preferred name for the site this client reads
|
|
49
|
+
from. Accepts a subdomain (`'acme'`) or full domain
|
|
50
|
+
(`'acme.myteachify.com'`, `'nii.school'`). Replaces the deprecated
|
|
51
|
+
`tenant` field; both fields are accepted, `site` wins when both are set.
|
|
52
|
+
- `LoopwiseError.site` — mirror of the deprecated `tenant` property.
|
|
53
|
+
Catch-and-read consumers can migrate at their own pace.
|
|
54
|
+
|
|
55
|
+
### Deprecated
|
|
56
|
+
|
|
57
|
+
- `LoopwiseConfig.tenant` — renamed to `site`. Both fields still work;
|
|
58
|
+
`site` wins when both are set. **Removed in `0.2.0`.**
|
|
59
|
+
- `LoopwiseError.tenant` — renamed to `site`. **Removed in `0.2.0`.**
|
|
60
|
+
|
|
61
|
+
### Removed
|
|
62
|
+
|
|
63
|
+
- `LoopwiseErrorCode = '…' | 'TENANT_NOT_FOUND'` — the literal was never
|
|
64
|
+
emitted by any SDK code path (it was a reserved/aspirational shape
|
|
65
|
+
that never got runtime mapping). Removed instead of renaming to
|
|
66
|
+
`'SITE_NOT_FOUND'` — no point preserving phantom API surface. If a
|
|
67
|
+
future SDK release adds actual runtime mapping (e.g. translating
|
|
68
|
+
Rails' `School-000` GraphQL error into a typed code), the canonical
|
|
69
|
+
`'SITE_NOT_FOUND'` literal will be added then. Removal is type-only
|
|
70
|
+
(narrowing checks like `case 'TENANT_NOT_FOUND':` were unreachable
|
|
71
|
+
branches in any consumer code).
|
|
72
|
+
|
|
73
|
+
### Changed (non-breaking)
|
|
74
|
+
|
|
75
|
+
- README + every user-visible JSDoc reframed to use "site" language
|
|
76
|
+
instead of "tenant" / "multi-tenant" / "BFF derives tenant from host"
|
|
77
|
+
— SDK consumers don't need to learn the platform's multi-tenancy
|
|
78
|
+
model, just the SDK's behaviour and limits (`createClient({})` reads
|
|
79
|
+
from the page's own site; `createClient({ site: '…' })` reads from a
|
|
80
|
+
different site at the cost of authenticated calls).
|
|
81
|
+
|
|
82
|
+
## [0.1.0-beta.2] — 2026-05-19
|
|
83
|
+
|
|
84
|
+
### Fixed
|
|
85
|
+
|
|
86
|
+
- `orders.get` returns `null` from a null GraphQL payload instead of
|
|
87
|
+
throwing.
|
|
88
|
+
- `orders.get` passes the `id` as an inline GraphQL argument rather than
|
|
89
|
+
a `$variable`, avoiding collision when composed via `batch()`.
|
|
90
|
+
|
|
91
|
+
### Documentation
|
|
92
|
+
|
|
93
|
+
- `orders.get` JSDoc clarifies `OrderItem.amount` is per-unit price
|
|
94
|
+
(not line total), and null-guards the `tradeNo` URL-param lookup
|
|
95
|
+
example.
|
|
96
|
+
|
|
97
|
+
## [0.1.0-beta.1] — 2026-05-05
|
|
98
|
+
|
|
99
|
+
Initial public beta. `courses.list/get`, `posts.list/get`, `orders.get`,
|
|
100
|
+
`batch()`, raw `query()`. Schema-lock pipeline. Same-origin BFF and
|
|
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
|
|
|
@@ -15,15 +15,15 @@ Same package powers all three; the difference is the loader.
|
|
|
15
15
|
```ts
|
|
16
16
|
import { createClient } from '@loopwise/sdk';
|
|
17
17
|
|
|
18
|
-
//
|
|
18
|
+
// Reads from the site this page is on. No config needed.
|
|
19
19
|
const loopwise = createClient({});
|
|
20
20
|
const { items } = await loopwise.courses.list({ limit: 20 });
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
That's
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
That's it. The SDK calls the site that's serving the page
|
|
24
|
+
(`acme.kaik.io`, `acme.myteachify.com`, `nii.school`, etc.) and the
|
|
25
|
+
site's API binds the response to itself — you never identify the site
|
|
26
|
+
yourself. To call a different site, see [Calling a different site](#calling-a-different-site).
|
|
27
27
|
|
|
28
28
|
## Quick start — single-file HTML (no build)
|
|
29
29
|
|
|
@@ -32,44 +32,57 @@ response on the trusted server side.
|
|
|
32
32
|
<script type="module">
|
|
33
33
|
import { createClient } from 'https://esm.sh/@loopwise/sdk@beta';
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
// (e.g. acme.kaik.io). Empty config; BFF derives the tenant from host.
|
|
35
|
+
// Reads from the site this HTML is loaded on.
|
|
37
36
|
const loopwise = createClient({});
|
|
38
37
|
const { items } = await loopwise.courses.list({ limit: 20 });
|
|
39
38
|
document.body.textContent = items.map(c => c.title).join(', ');
|
|
40
39
|
</script>
|
|
41
40
|
```
|
|
42
41
|
|
|
43
|
-
##
|
|
42
|
+
## Calling a different site
|
|
44
43
|
|
|
45
|
-
When
|
|
46
|
-
|
|
44
|
+
When your code runs **somewhere other than the site itself** — a Loopwise
|
|
45
|
+
Pages preview at `<scriptName>.loopwise.page`, a build / SSR pipeline,
|
|
46
|
+
server-to-server tooling — name the site you want to read from:
|
|
47
47
|
|
|
48
48
|
```ts
|
|
49
49
|
const loopwise = createClient({
|
|
50
|
-
|
|
51
|
-
apiKey: process.env.LOOPWISE_API_KEY,
|
|
50
|
+
site: 'acme', // 'acme' or 'acme.myteachify.com'
|
|
51
|
+
apiKey: process.env.LOOPWISE_API_KEY, // build/SSR only; browser ignores
|
|
52
52
|
});
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
`site` accepts a subdomain (`'acme'`) or a full domain
|
|
56
|
+
(`'acme.myteachify.com'`, `'nii.school'`). The SDK uses it to pick the
|
|
57
|
+
right API origin.
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
— that is **not** a tenant origin, so:
|
|
59
|
+
### Limits when not on the site itself
|
|
59
60
|
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
Cross-origin requests **do not carry first-party cookies**. So calls
|
|
62
|
+
that require a signed-in visitor — `cart`, `viewer.profile`,
|
|
63
|
+
authenticated mutations — **will not work** from off-site code.
|
|
64
|
+
Public, anonymous reads (`courses.list`, `posts.list`, etc.) work fine.
|
|
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
|
+
|
|
76
|
+
This is the dominant case for `deploy_static_page` bundles (which
|
|
77
|
+
serve at `<scriptName>.loopwise.page`):
|
|
65
78
|
|
|
66
79
|
```ts
|
|
67
80
|
// Inside an HTML bundle deployed to https://lw-acme-home.loopwise.page/
|
|
68
|
-
const loopwise = createClient({
|
|
81
|
+
const loopwise = createClient({ site: 'acme' });
|
|
69
82
|
const { items } = await loopwise.courses.list({ limit: 20 }, { cache: 60 });
|
|
70
83
|
```
|
|
71
84
|
|
|
72
|
-
For preview UX that needs authenticated
|
|
85
|
+
For preview UX that needs an authenticated viewer, see
|
|
73
86
|
[Preview modes](#preview-modes) below.
|
|
74
87
|
|
|
75
88
|
## `cache` option
|
|
@@ -120,30 +133,60 @@ is the explicit, type-system-visible declaration of "this must never be cached".
|
|
|
120
133
|
per Vercel POP, depending on traffic. Use shorter TTLs for time-sensitive
|
|
121
134
|
content; use `cache: 'no-store'` for admin previews.
|
|
122
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
|
+
|
|
123
167
|
## Preview modes
|
|
124
168
|
|
|
125
169
|
Two preview surfaces exist, each with different capability:
|
|
126
170
|
|
|
127
|
-
| Surface |
|
|
171
|
+
| Surface | URL | Hosted on the site? | Authenticated viewer? | Use for |
|
|
128
172
|
|---|---|---|---|---|
|
|
129
|
-
| **Loopwise Pages preview** | `<scriptName>.loopwise.page` | ❌
|
|
130
|
-
| **
|
|
173
|
+
| **Loopwise Pages preview** | `<scriptName>.loopwise.page` | ❌ separate origin | ❌ no first-party cookies | Anonymous storefront, marketing pages, public listings |
|
|
174
|
+
| **Site-origin preview** | `<site>/_preview/<scriptName>/` | ✅ same origin as the site | ✅ admins can preview signed in | Cart, checkout, member-gated content, full storefront |
|
|
131
175
|
|
|
132
|
-
The
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
`<TENANT_ORIGIN>` placeholder).
|
|
176
|
+
The site URL can be `<sub>.kaik.io`, `<sub>.myteachify.com`, or a custom
|
|
177
|
+
domain like `nii.school`. `deploy_static_page` returns both URLs in its
|
|
178
|
+
response.
|
|
136
179
|
|
|
137
|
-
Pick the surface that matches what you're testing — anonymous
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
180
|
+
Pick the surface that matches what you're testing — anonymous reads on
|
|
181
|
+
Surface A (faster, edge-cacheable), authenticated flows on Surface B
|
|
182
|
+
(signed-in only, never edge-cached). Surface B requires relative asset
|
|
183
|
+
paths in your bundle (the proxy injects `<base>` so relative paths
|
|
184
|
+
resolve under `/_preview/<scriptName>/`; root-absolute paths like
|
|
185
|
+
`/style.css` still go to the site root and 404).
|
|
143
186
|
|
|
144
187
|
## React — coming soon
|
|
145
188
|
|
|
146
|
-
A `@loopwise/
|
|
189
|
+
A `@loopwise/react` companion package is planned, providing:
|
|
147
190
|
|
|
148
191
|
- `<LoopwiseProvider client={loopwise}>` context provider
|
|
149
192
|
- `useCourses({ limit })`, `useCourse({ slug })` hooks with Suspense support
|
|
@@ -155,10 +198,12 @@ your own state (`useEffect` + `useState`, TanStack Query, etc.).
|
|
|
155
198
|
|
|
156
199
|
## Public API
|
|
157
200
|
|
|
158
|
-
- `createClient(config)` — storefront client. `config.
|
|
159
|
-
|
|
160
|
-
|
|
201
|
+
- `createClient(config)` — storefront client. Pass `config.site` only
|
|
202
|
+
when calling a different site than the one your code is running on
|
|
203
|
+
(see [Calling a different site](#calling-a-different-site)).
|
|
161
204
|
- `loopwise.courses.list(args, options?)` / `.get(args, options?)`.
|
|
205
|
+
- `loopwise.posts.list(args, options?)` / `.get(args, options?)`.
|
|
206
|
+
- `loopwise.orders.get(args, options?)` — post-checkout pixel-tracking pattern; see the method's JSDoc for the `?tradeNo=…` → `fbq('track', 'Purchase', …)` recipe.
|
|
162
207
|
- `loopwise.batch(builders, options?)` — composes N queries into one HTTP request.
|
|
163
208
|
- `loopwise.query<T>(document, variables?, operationName?, options?)` — raw GraphQL escape hatch.
|
|
164
209
|
|
package/dist/config.d.mts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
//#region src/config.d.ts
|
|
2
2
|
interface LoopwiseConfig {
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* The site this client reads from. Accepts a subdomain (`'acme'`) or a
|
|
5
|
+
* full domain (`'acme.myteachify.com'`, `'nii.school'`).
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
7
|
+
* Omit when your code runs on the site itself — the SDK calls the
|
|
8
|
+
* page's own origin. Required when running off-origin: cross-domain
|
|
9
|
+
* embeds (Loopwise Pages previews at `*.loopwise.page`), SSR / build
|
|
10
|
+
* pipelines, server-to-server tooling.
|
|
11
|
+
*
|
|
12
|
+
* Cross-origin requests carry no first-party cookies, so authenticated
|
|
13
|
+
* calls (`cart`, `viewer.profile`, mutations that need a signed-in
|
|
14
|
+
* visitor) won't work in that mode — public reads do.
|
|
15
|
+
*/
|
|
16
|
+
site?: string;
|
|
17
|
+
/**
|
|
18
|
+
* @deprecated Renamed to `site`. Both fields work for now; if both are
|
|
19
|
+
* set, `site` wins. **Will be removed in `0.2.0`.**
|
|
8
20
|
*/
|
|
9
21
|
tenant?: string;
|
|
10
22
|
/**
|
|
@@ -15,11 +27,22 @@ interface LoopwiseConfig {
|
|
|
15
27
|
*/
|
|
16
28
|
apiKey?: string;
|
|
17
29
|
/**
|
|
18
|
-
* Override the GraphQL endpoint.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
30
|
+
* Override the GraphQL endpoint. You normally never set this — the SDK
|
|
31
|
+
* picks the right URL from `site` (or the page's own origin when `site`
|
|
32
|
+
* is omitted).
|
|
21
33
|
*/
|
|
22
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;
|
|
23
46
|
}
|
|
24
47
|
declare function defineConfig(config: LoopwiseConfig): LoopwiseConfig;
|
|
25
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)[];
|
|
@@ -9,11 +8,16 @@ interface GraphQLError {
|
|
|
9
8
|
}
|
|
10
9
|
declare class LoopwiseError extends Error {
|
|
11
10
|
readonly code: LoopwiseErrorCode;
|
|
11
|
+
/** The site this client was configured for. */
|
|
12
|
+
readonly site?: string;
|
|
13
|
+
/** @deprecated Renamed to `site`. Holds the same resolved value. Removed in 0.2.0. */
|
|
12
14
|
readonly tenant?: string;
|
|
13
15
|
readonly endpoint?: string;
|
|
14
16
|
readonly graphqlErrors?: GraphQLError[];
|
|
15
17
|
readonly requestId?: string;
|
|
16
18
|
constructor(code: LoopwiseErrorCode, message: string, detail?: {
|
|
19
|
+
site?: string;
|
|
20
|
+
/** @deprecated Renamed to `site`. Removed in 0.2.0. */
|
|
17
21
|
tenant?: string;
|
|
18
22
|
endpoint?: string;
|
|
19
23
|
graphqlErrors?: GraphQLError[];
|
|
@@ -22,9 +26,9 @@ declare class LoopwiseError extends Error {
|
|
|
22
26
|
}
|
|
23
27
|
//#endregion
|
|
24
28
|
//#region src/select.d.ts
|
|
25
|
-
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;
|
|
26
30
|
type Selection<T> = SelectionInner<NonNullable<T>>;
|
|
27
|
-
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;
|
|
28
32
|
type Selected<T, S> = SelectedInner<T, S>;
|
|
29
33
|
//#endregion
|
|
30
34
|
//#region src/query.d.ts
|
|
@@ -60,7 +64,7 @@ interface FetchOptions {
|
|
|
60
64
|
* `Cache-Control: public, s-maxage=<seconds>`. `'no-store'` forces POST and
|
|
61
65
|
* skips any cache hint. Omit for the legacy POST behaviour.
|
|
62
66
|
*/
|
|
63
|
-
cache?: number |
|
|
67
|
+
cache?: number | "no-store";
|
|
64
68
|
}
|
|
65
69
|
interface BatchedFetcher {
|
|
66
70
|
/**
|
|
@@ -79,9 +83,7 @@ declare class QueryBuilder<T> implements PromiseLike<T> {
|
|
|
79
83
|
//#endregion
|
|
80
84
|
//#region src/client.d.ts
|
|
81
85
|
declare class LoopwiseClient implements BatchedFetcher {
|
|
82
|
-
readonly config:
|
|
83
|
-
tenant?: string;
|
|
84
|
-
} & LoopwiseConfig;
|
|
86
|
+
readonly config: LoopwiseConfig;
|
|
85
87
|
private endpoint;
|
|
86
88
|
constructor(config: LoopwiseConfig);
|
|
87
89
|
private defaultEndpoint;
|
|
@@ -94,7 +96,7 @@ declare class LoopwiseClient implements BatchedFetcher {
|
|
|
94
96
|
raw<T = unknown>(document: string, variables?: Record<string, unknown>, operationName?: string, options?: FetchOptions): Promise<T>;
|
|
95
97
|
batch<R extends Record<string, unknown>>(builders: { [K in keyof R]: {
|
|
96
98
|
descriptor: QueryDescriptor;
|
|
97
|
-
} }, options?: FetchOptions): Promise<R>;
|
|
99
|
+
}; }, options?: FetchOptions): Promise<R>;
|
|
98
100
|
private fetchGraphQL;
|
|
99
101
|
private shouldUseGet;
|
|
100
102
|
private buildGetUrl;
|
|
@@ -142,7 +144,7 @@ interface Post {
|
|
|
142
144
|
/** Known values: `login_required`, `paid`, `public_access`. The
|
|
143
145
|
* `string & {}` escape hatch keeps autocomplete on the known set
|
|
144
146
|
* while accepting forward-compat values the schema may add. */
|
|
145
|
-
accessType:
|
|
147
|
+
accessType: "login_required" | "paid" | "public_access" | (string & {});
|
|
146
148
|
tags: string[] | null;
|
|
147
149
|
noindex: boolean;
|
|
148
150
|
author: Lecturer | null;
|
|
@@ -159,7 +161,7 @@ interface ListResult<T> {
|
|
|
159
161
|
pageInfo: PageInfo;
|
|
160
162
|
}
|
|
161
163
|
/** Simplified order status. Mirrors the storefront `Order.status` enum. */
|
|
162
|
-
type OrderStatus =
|
|
164
|
+
type OrderStatus = "PAID" | "PENDING" | "FAILED" | "REFUNDED";
|
|
163
165
|
interface OrderItem {
|
|
164
166
|
/**
|
|
165
167
|
* Meta-pixel-aligned identifier (Lineitem#tracking_id server-side).
|
|
@@ -201,7 +203,7 @@ declare class CoursesResource {
|
|
|
201
203
|
private client;
|
|
202
204
|
constructor(client: LoopwiseClient);
|
|
203
205
|
/**
|
|
204
|
-
* List courses
|
|
206
|
+
* List published courses.
|
|
205
207
|
* @example
|
|
206
208
|
* const { items, pageInfo } = await loopwise.courses.list({ limit: 20 })
|
|
207
209
|
* @example
|
|
@@ -248,9 +250,8 @@ declare class OrdersResource {
|
|
|
248
250
|
* Look up a PII-safe order projection by id (the value the storefront
|
|
249
251
|
* appends to a merchant's `checkoutSuccessUrl` as `?tradeNo=...`).
|
|
250
252
|
*
|
|
251
|
-
* Returns `null
|
|
252
|
-
*
|
|
253
|
-
* returned `status` to gate Meta Pixel `Purchase` events:
|
|
253
|
+
* Returns `null` when the id doesn't resolve on this site — does NOT
|
|
254
|
+
* throw. Use the returned `status` to gate Meta Pixel `Purchase` events:
|
|
254
255
|
*
|
|
255
256
|
* @example
|
|
256
257
|
* const id = new URLSearchParams(location.search).get('tradeNo');
|
|
@@ -289,7 +290,7 @@ declare class PostsResource {
|
|
|
289
290
|
private client;
|
|
290
291
|
constructor(client: LoopwiseClient);
|
|
291
292
|
/**
|
|
292
|
-
* List published posts
|
|
293
|
+
* List published posts, ordered by publishedAt desc.
|
|
293
294
|
* @example
|
|
294
295
|
* const { items, pageInfo } = await loopwise.posts.list({ limit: 10 })
|
|
295
296
|
* @example
|
|
@@ -330,13 +331,22 @@ interface Loopwise {
|
|
|
330
331
|
* recent: loopwise.courses.list({ limit: 3, page: 2 }),
|
|
331
332
|
* }, { cache: 60 })
|
|
332
333
|
*/
|
|
333
|
-
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; }>;
|
|
334
335
|
/**
|
|
335
336
|
* Raw GraphQL escape hatch. Use when a typed resource method doesn't cover
|
|
336
337
|
* what you need (new schema fields, custom queries, ad-hoc inspection).
|
|
338
|
+
* Variables are sent as-is; errors are still mapped to LoopwiseError.
|
|
339
|
+
*
|
|
340
|
+
* Most storefront fields (`courses`, `posts`, `order`, ...) take a
|
|
341
|
+
* required `subdomain: String!` argument. The BFF rewrites that variable
|
|
342
|
+
* to the trusted server-derived value on every request, so consumers
|
|
343
|
+
* declaring `$subdomain` see whatever value the BFF binds — not what
|
|
344
|
+
* they pass.
|
|
345
|
+
*
|
|
337
346
|
* @example
|
|
338
|
-
*
|
|
339
|
-
*
|
|
347
|
+
* // Minimal shape demo (always valid, no scoping required):
|
|
348
|
+
* const data = await loopwise.query<{ __typename: string }>(`
|
|
349
|
+
* query { __typename }
|
|
340
350
|
* `)
|
|
341
351
|
*/
|
|
342
352
|
query<T = unknown>(document: string, variables?: Record<string, unknown>, operationName?: string, options?: FetchOptions): Promise<T>;
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
//#region src/error.ts
|
|
2
2
|
var LoopwiseError = class extends Error {
|
|
3
3
|
code;
|
|
4
|
+
/** The site this client was configured for. */
|
|
5
|
+
site;
|
|
6
|
+
/** @deprecated Renamed to `site`. Holds the same resolved value. Removed in 0.2.0. */
|
|
4
7
|
tenant;
|
|
5
8
|
endpoint;
|
|
6
9
|
graphqlErrors;
|
|
@@ -9,7 +12,9 @@ var LoopwiseError = class extends Error {
|
|
|
9
12
|
super(message);
|
|
10
13
|
this.name = "LoopwiseError";
|
|
11
14
|
this.code = code;
|
|
12
|
-
|
|
15
|
+
const site = detail.site ?? detail.tenant;
|
|
16
|
+
this.site = site;
|
|
17
|
+
this.tenant = site;
|
|
13
18
|
this.endpoint = detail.endpoint;
|
|
14
19
|
this.graphqlErrors = detail.graphqlErrors;
|
|
15
20
|
this.requestId = detail.requestId;
|
|
@@ -29,20 +34,22 @@ var LoopwiseClient = class {
|
|
|
29
34
|
config;
|
|
30
35
|
endpoint;
|
|
31
36
|
constructor(config) {
|
|
32
|
-
const
|
|
33
|
-
if (detectRuntime() !== "browser" && !
|
|
37
|
+
const site = config.site ?? config.tenant;
|
|
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.");
|
|
34
40
|
this.config = {
|
|
35
41
|
...config,
|
|
36
|
-
|
|
42
|
+
site,
|
|
43
|
+
tenant: site
|
|
37
44
|
};
|
|
38
45
|
this.endpoint = config.endpoint ?? this.defaultEndpoint();
|
|
39
46
|
}
|
|
40
47
|
defaultEndpoint() {
|
|
41
|
-
const
|
|
42
|
-
if (detectRuntime() === "browser" && !
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
return `https://${
|
|
48
|
+
const site = this.config.site;
|
|
49
|
+
if (detectRuntime() === "browser" && !site) return "/api/graphql";
|
|
50
|
+
const s = site;
|
|
51
|
+
if (s.includes(".")) return `https://${s}/api/graphql`;
|
|
52
|
+
return `https://${s}.kaik.io/api/graphql`;
|
|
46
53
|
}
|
|
47
54
|
async execute(descriptor, options = {}) {
|
|
48
55
|
const document = renderSingleDocument(descriptor);
|
|
@@ -95,6 +102,7 @@ var LoopwiseClient = class {
|
|
|
95
102
|
const headers = { accept: "application/graphql-response+json, application/json" };
|
|
96
103
|
const apiKey = this.resolveApiKey();
|
|
97
104
|
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
|
105
|
+
if (this.config.apiVersion) headers["api-version"] = this.config.apiVersion;
|
|
98
106
|
const useGet = this.shouldUseGet(payload, options);
|
|
99
107
|
if (typeof options.cache === "number" && options.cache > 0) headers["x-loopwise-cache"] = String(options.cache);
|
|
100
108
|
let response;
|
|
@@ -115,26 +123,31 @@ var LoopwiseClient = class {
|
|
|
115
123
|
});
|
|
116
124
|
} catch (err) {
|
|
117
125
|
throw new LoopwiseError("NETWORK", `Network error: ${err.message}`, {
|
|
118
|
-
|
|
126
|
+
site: this.config.site,
|
|
119
127
|
endpoint: this.endpoint
|
|
120
128
|
});
|
|
121
129
|
}
|
|
122
130
|
if (response.status === 401 || response.status === 403) throw new LoopwiseError("AUTH", `Auth failed (${response.status})`, {
|
|
123
|
-
|
|
131
|
+
site: this.config.site,
|
|
124
132
|
endpoint: this.endpoint,
|
|
125
133
|
requestId: response.headers.get("x-request-id") ?? void 0
|
|
126
134
|
});
|
|
127
135
|
if (response.status === 429) throw new LoopwiseError("RATE_LIMIT", "Rate limited", {
|
|
128
|
-
|
|
136
|
+
site: this.config.site,
|
|
129
137
|
endpoint: this.endpoint
|
|
130
138
|
});
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
}
|
|
136
149
|
if (body.errors?.length) throw new LoopwiseError("GRAPHQL", body.errors[0]?.message ?? "GraphQL error", {
|
|
137
|
-
|
|
150
|
+
site: this.config.site,
|
|
138
151
|
endpoint: this.endpoint,
|
|
139
152
|
graphqlErrors: body.errors
|
|
140
153
|
});
|
|
@@ -162,6 +175,18 @@ var LoopwiseClient = class {
|
|
|
162
175
|
return globalThis.process?.env?.LOOPWISE_API_KEY;
|
|
163
176
|
}
|
|
164
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
|
+
}
|
|
165
190
|
function firstOperationKeyword(document) {
|
|
166
191
|
let i = 0;
|
|
167
192
|
if (document.charCodeAt(0) === 65279) i = 1;
|
|
@@ -319,7 +344,7 @@ const DEFAULT_GET_FIELDS$2 = {
|
|
|
319
344
|
function tenantVars$2(client) {
|
|
320
345
|
return { subdomain: {
|
|
321
346
|
type: "String!",
|
|
322
|
-
value: client.config.
|
|
347
|
+
value: client.config.site ?? ""
|
|
323
348
|
} };
|
|
324
349
|
}
|
|
325
350
|
var CoursesResource = class {
|
|
@@ -328,7 +353,7 @@ var CoursesResource = class {
|
|
|
328
353
|
this.client = client;
|
|
329
354
|
}
|
|
330
355
|
/**
|
|
331
|
-
* List courses
|
|
356
|
+
* List published courses.
|
|
332
357
|
* @example
|
|
333
358
|
* const { items, pageInfo } = await loopwise.courses.list({ limit: 20 })
|
|
334
359
|
* @example
|
|
@@ -433,7 +458,7 @@ const DEFAULT_GET_FIELDS$1 = {
|
|
|
433
458
|
function tenantVars$1(client) {
|
|
434
459
|
return { subdomain: {
|
|
435
460
|
type: "String!",
|
|
436
|
-
value: client.config.
|
|
461
|
+
value: client.config.site ?? ""
|
|
437
462
|
} };
|
|
438
463
|
}
|
|
439
464
|
var OrdersResource = class {
|
|
@@ -445,9 +470,8 @@ var OrdersResource = class {
|
|
|
445
470
|
* Look up a PII-safe order projection by id (the value the storefront
|
|
446
471
|
* appends to a merchant's `checkoutSuccessUrl` as `?tradeNo=...`).
|
|
447
472
|
*
|
|
448
|
-
* Returns `null
|
|
449
|
-
*
|
|
450
|
-
* returned `status` to gate Meta Pixel `Purchase` events:
|
|
473
|
+
* Returns `null` when the id doesn't resolve on this site — does NOT
|
|
474
|
+
* throw. Use the returned `status` to gate Meta Pixel `Purchase` events:
|
|
451
475
|
*
|
|
452
476
|
* @example
|
|
453
477
|
* const id = new URLSearchParams(location.search).get('tradeNo');
|
|
@@ -563,7 +587,7 @@ const DEFAULT_GET_FIELDS = {
|
|
|
563
587
|
function tenantVars(client) {
|
|
564
588
|
return { subdomain: {
|
|
565
589
|
type: "String!",
|
|
566
|
-
value: client.config.
|
|
590
|
+
value: client.config.site ?? ""
|
|
567
591
|
} };
|
|
568
592
|
}
|
|
569
593
|
var PostsResource = class {
|
|
@@ -572,7 +596,7 @@ var PostsResource = class {
|
|
|
572
596
|
this.client = client;
|
|
573
597
|
}
|
|
574
598
|
/**
|
|
575
|
-
* List published posts
|
|
599
|
+
* List published posts, ordered by publishedAt desc.
|
|
576
600
|
* @example
|
|
577
601
|
* const { items, pageInfo } = await loopwise.posts.list({ limit: 10 })
|
|
578
602
|
* @example
|
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",
|
|
@@ -37,21 +37,23 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"dist/*.mjs",
|
|
39
39
|
"dist/*.d.mts",
|
|
40
|
-
"README.md"
|
|
40
|
+
"README.md",
|
|
41
|
+
"CHANGELOG.md"
|
|
41
42
|
],
|
|
42
43
|
"scripts": {
|
|
43
44
|
"build": "tsdown",
|
|
44
45
|
"dev": "tsdown --watch",
|
|
45
|
-
"typecheck": "
|
|
46
|
+
"typecheck": "tsgo --noEmit",
|
|
46
47
|
"test": "vitest run",
|
|
47
48
|
"regen:lock": "node scripts/regen-lock.mjs"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
51
|
+
"@kaikhq/graphql-proxy": "workspace:*",
|
|
50
52
|
"graphql": "16.13.1",
|
|
51
53
|
"tsconfig": "workspace:*",
|
|
52
|
-
"tsdown": "
|
|
54
|
+
"tsdown": "catalog:",
|
|
53
55
|
"typescript": "5.9.3",
|
|
54
|
-
"vitest": "
|
|
56
|
+
"vitest": "catalog:"
|
|
55
57
|
},
|
|
56
58
|
"publishConfig": {
|
|
57
59
|
"access": "public"
|