@loopwise/sdk 0.1.0-beta.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/README.md +90 -0
- package/dist/config.d.mts +26 -0
- package/dist/config.mjs +7 -0
- package/dist/index.d.mts +193 -0
- package/dist/index.mjs +390 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @loopwise/sdk
|
|
2
|
+
|
|
3
|
+
Storefront GraphQL client for Loopwise / Teachify. The SDK has no concept of
|
|
4
|
+
"tenant" in its public surface — calls hit the same-origin BFF at
|
|
5
|
+
`/api/graphql`, which derives the tenant from the request host and binds it
|
|
6
|
+
to the response on the trusted server side.
|
|
7
|
+
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { createClient } from '@loopwise/sdk';
|
|
12
|
+
|
|
13
|
+
const loopwise = createClient({});
|
|
14
|
+
const { items } = await loopwise.courses.list({ limit: 20 });
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That's the entire happy path. Whether the page is on `acme.kaik.io`,
|
|
18
|
+
`acme.myteachify.com`, or `nii.school`, the BFF on each origin already knows
|
|
19
|
+
which tenant it's serving — the SDK just talks to its own origin.
|
|
20
|
+
|
|
21
|
+
### Cross-origin / SSR (advanced)
|
|
22
|
+
|
|
23
|
+
When the SDK runs outside a tenant origin (build pipeline, third-party site,
|
|
24
|
+
SSR worker), pass `tenant` explicitly so the SDK knows where to send the
|
|
25
|
+
request. An `apiKey` is required outside the browser to bypass cookie auth.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const loopwise = createClient({
|
|
29
|
+
tenant: 'acme',
|
|
30
|
+
apiKey: process.env.LOOPWISE_API_KEY, // SSR / build only
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Cache option (`cache`)
|
|
35
|
+
|
|
36
|
+
Read-only resource calls accept an opt-in `cache` option that flips the
|
|
37
|
+
request to a GraphQL-over-HTTP `GET` and asks the BFF to apply a
|
|
38
|
+
`Cache-Control` hint so Vercel's edge can serve repeats without a Rails
|
|
39
|
+
round-trip.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// 60s fresh + 300s stale-while-revalidate at the edge.
|
|
43
|
+
const { items } = await loopwise.courses.list({ limit: 20 }, { cache: 60 });
|
|
44
|
+
|
|
45
|
+
// Force fresh; never share the response across visitors.
|
|
46
|
+
const fresh = await loopwise.courses.list({ limit: 20 }, { cache: 'no-store' });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`courses.list()` returns a thenable `QueryBuilder` — without `await` (or
|
|
50
|
+
`.then()` / passing it to `batch()`) the call is a no-op and no HTTP request
|
|
51
|
+
is sent.
|
|
52
|
+
|
|
53
|
+
### When to use which
|
|
54
|
+
|
|
55
|
+
| Context | What to pass | Why |
|
|
56
|
+
|---------|--------------|-----|
|
|
57
|
+
| Storefront list / detail (public, semi-static) | `{ cache: 60 }` | Tenant-public, OK to share across visitors. |
|
|
58
|
+
| Storefront very-static metadata (school info, footer) | `{ cache: 300 }` | Capped at 300s by the BFF; rarely changes. |
|
|
59
|
+
| **Admin / preview path** | **`{ cache: 'no-store' }`** | Admin previewing a draft must always see fresh, never share their response. |
|
|
60
|
+
| Mutations | (n/a — never cached) | SDK always POSTs mutations. |
|
|
61
|
+
| Per-user data (cart, viewer profile) | `{ cache: 'no-store' }` (or omit entirely) | Caching per-user data on a shared edge would leak across visitors. |
|
|
62
|
+
|
|
63
|
+
### Defence in depth — what happens if you get it wrong
|
|
64
|
+
|
|
65
|
+
The BFF refuses to cache any response when:
|
|
66
|
+
|
|
67
|
+
1. The request carries `Cookie` or `Authorization` (admin sessions, logged-in
|
|
68
|
+
visitors). Even if you pass `cache: 60`, the cookie strips the hint.
|
|
69
|
+
2. The upstream Rails response sets `Set-Cookie` (session establishment).
|
|
70
|
+
3. The upstream returns a non-2xx status.
|
|
71
|
+
|
|
72
|
+
So a misconfigured `cache: 60` on an admin path is still safe — the cookie
|
|
73
|
+
guard prevents the response from reaching Vercel's shared cache. `cache: 'no-store'`
|
|
74
|
+
is the explicit, type-system-visible declaration of "this must never be cached".
|
|
75
|
+
|
|
76
|
+
### Propagation delay after content updates
|
|
77
|
+
|
|
78
|
+
`cache: 60` ⇒ a publish becomes visible to visitors in roughly 1–6 minutes
|
|
79
|
+
per Vercel POP, depending on traffic. Use shorter TTLs for time-sensitive
|
|
80
|
+
content; use `cache: 'no-store'` for admin previews.
|
|
81
|
+
|
|
82
|
+
## Public API
|
|
83
|
+
|
|
84
|
+
- `createClient(config)` — storefront client. `config.tenant` is optional
|
|
85
|
+
in the browser (BFF derives it from host) and required in SSR / build.
|
|
86
|
+
- `loopwise.courses.list(args, options?)` / `.get(args, options?)`.
|
|
87
|
+
- `loopwise.batch(builders, options?)` — composes N queries into one HTTP request.
|
|
88
|
+
- `loopwise.query<T>(document, variables?, operationName?, options?)` — raw GraphQL.
|
|
89
|
+
|
|
90
|
+
See `apps/sdk-runtime-poc/` for a runnable browser validation page.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//#region src/config.d.ts
|
|
2
|
+
interface LoopwiseConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Tenant identifier (subdomain "acme" or full FQDN "nii.school").
|
|
5
|
+
*
|
|
6
|
+
* Optional in browser runtime — SDK auto-resolves from `window.location.hostname`
|
|
7
|
+
* (e.g. `acme.kaik.io` -> `"acme"`). Required in build/SSR runtime.
|
|
8
|
+
*/
|
|
9
|
+
tenant?: string;
|
|
10
|
+
/**
|
|
11
|
+
* API key for build/SSR (Bearer auth). Reads from `LOOPWISE_API_KEY` env if
|
|
12
|
+
* omitted. NEVER bundle this into a browser build — `defineConfig` runs at
|
|
13
|
+
* build/SSR; the value never reaches client-side code unless the consumer
|
|
14
|
+
* misuses it.
|
|
15
|
+
*/
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Override the GraphQL endpoint. Defaults to:
|
|
19
|
+
* - Browser: same-origin `/api/graphql`
|
|
20
|
+
* - Build/SSR: `https://{tenant}/api/graphql` (or kaik.io fallback)
|
|
21
|
+
*/
|
|
22
|
+
endpoint?: string;
|
|
23
|
+
}
|
|
24
|
+
declare function defineConfig(config: LoopwiseConfig): LoopwiseConfig;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { LoopwiseConfig, defineConfig };
|
package/dist/config.mjs
ADDED
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { LoopwiseConfig } from "./config.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/error.d.ts
|
|
4
|
+
type LoopwiseErrorCode = 'NETWORK' | 'AUTH' | 'GRAPHQL' | 'RATE_LIMIT' | 'TENANT_NOT_FOUND' | 'CONFIG';
|
|
5
|
+
interface GraphQLError {
|
|
6
|
+
message: string;
|
|
7
|
+
path?: (string | number)[];
|
|
8
|
+
extensions?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
declare class LoopwiseError extends Error {
|
|
11
|
+
readonly code: LoopwiseErrorCode;
|
|
12
|
+
readonly tenant?: string;
|
|
13
|
+
readonly endpoint?: string;
|
|
14
|
+
readonly graphqlErrors?: GraphQLError[];
|
|
15
|
+
readonly requestId?: string;
|
|
16
|
+
constructor(code: LoopwiseErrorCode, message: string, detail?: {
|
|
17
|
+
tenant?: string;
|
|
18
|
+
endpoint?: string;
|
|
19
|
+
graphqlErrors?: GraphQLError[];
|
|
20
|
+
requestId?: string;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#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]> } : true;
|
|
26
|
+
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]> } : T;
|
|
28
|
+
type Selected<T, S> = SelectedInner<T, S>;
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/query.d.ts
|
|
31
|
+
interface QueryDescriptor {
|
|
32
|
+
/** GraphQL operation name (used as alias key inside batch documents). */
|
|
33
|
+
operation: string;
|
|
34
|
+
/** Pre-built selection set string ("id title { x }"). */
|
|
35
|
+
selectionSet: string;
|
|
36
|
+
/** Top-level field name on the GraphQL schema (e.g. "courses", "course"). */
|
|
37
|
+
field: string;
|
|
38
|
+
/** Top-level field arguments rendered as inline literals. */
|
|
39
|
+
args: Record<string, unknown>;
|
|
40
|
+
/**
|
|
41
|
+
* Top-level field arguments rendered as GraphQL variables. The renderer
|
|
42
|
+
* emits `query Op($name: Type, ...) { field(name: $name, ...) }` and
|
|
43
|
+
* places `value` into the request's `variables` payload. Variable values
|
|
44
|
+
* stay JSON-shaped on the wire, which is what lets the BFF / proxy layer
|
|
45
|
+
* rewrite tenant-scoped variables (`subdomain`) without parsing the
|
|
46
|
+
* GraphQL document.
|
|
47
|
+
*/
|
|
48
|
+
vars?: Record<string, {
|
|
49
|
+
type: string;
|
|
50
|
+
value: unknown;
|
|
51
|
+
}>;
|
|
52
|
+
/** Optional response transform (e.g. wrap into ListResult shape). */
|
|
53
|
+
transform?: (raw: unknown) => unknown;
|
|
54
|
+
}
|
|
55
|
+
interface FetchOptions {
|
|
56
|
+
/**
|
|
57
|
+
* Cache hint. A positive number opts the call into GraphQL-over-HTTP GET
|
|
58
|
+
* mode (per spec §5.1) so HTTP caches and CDNs can serve repeat hits, and
|
|
59
|
+
* sends `X-Loopwise-Cache: <seconds>` so the BFF can echo it as
|
|
60
|
+
* `Cache-Control: public, s-maxage=<seconds>`. `'no-store'` forces POST and
|
|
61
|
+
* skips any cache hint. Omit for the legacy POST behaviour.
|
|
62
|
+
*/
|
|
63
|
+
cache?: number | 'no-store';
|
|
64
|
+
}
|
|
65
|
+
interface BatchedFetcher {
|
|
66
|
+
/**
|
|
67
|
+
* Execute one descriptor. Implementations may either fire an HTTP request
|
|
68
|
+
* immediately, or buffer for `batch()` to flush.
|
|
69
|
+
*/
|
|
70
|
+
execute<T>(descriptor: QueryDescriptor, options?: FetchOptions): Promise<T>;
|
|
71
|
+
}
|
|
72
|
+
declare class QueryBuilder<T> implements PromiseLike<T> {
|
|
73
|
+
readonly descriptor: QueryDescriptor;
|
|
74
|
+
readonly fetchOptions: FetchOptions;
|
|
75
|
+
private fetcher;
|
|
76
|
+
constructor(fetcher: BatchedFetcher, descriptor: QueryDescriptor, fetchOptions?: FetchOptions);
|
|
77
|
+
then<R1 = T, R2 = never>(onFulfilled?: ((value: T) => R1 | PromiseLike<R1>) | null, onRejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null): PromiseLike<R1 | R2>;
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/client.d.ts
|
|
81
|
+
declare class LoopwiseClient implements BatchedFetcher {
|
|
82
|
+
readonly config: {
|
|
83
|
+
tenant?: string;
|
|
84
|
+
} & LoopwiseConfig;
|
|
85
|
+
private endpoint;
|
|
86
|
+
constructor(config: LoopwiseConfig);
|
|
87
|
+
private defaultEndpoint;
|
|
88
|
+
execute<T>(descriptor: QueryDescriptor, options?: FetchOptions): Promise<T>;
|
|
89
|
+
/**
|
|
90
|
+
* Raw GraphQL escape hatch. Caller writes the query/mutation document and
|
|
91
|
+
* receives the typed `data` payload. Variables are sent as-is. Errors are
|
|
92
|
+
* still mapped to LoopwiseError.
|
|
93
|
+
*/
|
|
94
|
+
raw<T = unknown>(document: string, variables?: Record<string, unknown>, operationName?: string, options?: FetchOptions): Promise<T>;
|
|
95
|
+
batch<R extends Record<string, unknown>>(builders: { [K in keyof R]: {
|
|
96
|
+
descriptor: QueryDescriptor;
|
|
97
|
+
} }, options?: FetchOptions): Promise<R>;
|
|
98
|
+
private fetchGraphQL;
|
|
99
|
+
private shouldUseGet;
|
|
100
|
+
private buildGetUrl;
|
|
101
|
+
private resolveApiKey;
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/types.d.ts
|
|
105
|
+
interface Course {
|
|
106
|
+
id: string;
|
|
107
|
+
slug: string;
|
|
108
|
+
/** Maps to schema `Course.name`. */
|
|
109
|
+
title: string;
|
|
110
|
+
description: string | null;
|
|
111
|
+
/** Maps to schema `Course.image`. */
|
|
112
|
+
coverImage: string | null;
|
|
113
|
+
createdAt: number;
|
|
114
|
+
lecturer: Lecturer | null;
|
|
115
|
+
}
|
|
116
|
+
interface Lecturer {
|
|
117
|
+
id: string;
|
|
118
|
+
/** Maps to schema `Lecturer.name`. */
|
|
119
|
+
name: string;
|
|
120
|
+
avatar: string | null;
|
|
121
|
+
}
|
|
122
|
+
interface PageInfo {
|
|
123
|
+
page: number;
|
|
124
|
+
totalPages: number;
|
|
125
|
+
totalCount: number;
|
|
126
|
+
hasNext: boolean;
|
|
127
|
+
hasPrev: boolean;
|
|
128
|
+
}
|
|
129
|
+
interface ListResult<T> {
|
|
130
|
+
items: T[];
|
|
131
|
+
pageInfo: PageInfo;
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/resources/courses.d.ts
|
|
135
|
+
declare const DEFAULT_LIST_FIELDS: Selection<Course>;
|
|
136
|
+
declare const DEFAULT_GET_FIELDS: Selection<Course>;
|
|
137
|
+
interface CourseListArgs<S extends Selection<Course> = typeof DEFAULT_LIST_FIELDS> {
|
|
138
|
+
limit?: number;
|
|
139
|
+
page?: number;
|
|
140
|
+
/** SDK `limit` maps to schema `perPage`. Schema also has its own `limit`
|
|
141
|
+
* which we expose as `topN` if needed (not in v0). */
|
|
142
|
+
select?: S;
|
|
143
|
+
}
|
|
144
|
+
interface CourseGetArgs<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS> {
|
|
145
|
+
slug: string;
|
|
146
|
+
select?: S;
|
|
147
|
+
}
|
|
148
|
+
declare class CoursesResource {
|
|
149
|
+
private client;
|
|
150
|
+
constructor(client: LoopwiseClient);
|
|
151
|
+
/**
|
|
152
|
+
* List courses for the current tenant.
|
|
153
|
+
* @example
|
|
154
|
+
* const { items, pageInfo } = await loopwise.courses.list({ limit: 20 })
|
|
155
|
+
* @example
|
|
156
|
+
* // Opt into GET + Cache-Control hint (60s):
|
|
157
|
+
* await loopwise.courses.list({ limit: 20 }, { cache: 60 })
|
|
158
|
+
*/
|
|
159
|
+
list<S extends Selection<Course> = typeof DEFAULT_LIST_FIELDS>(args?: CourseListArgs<S>, options?: FetchOptions): QueryBuilder<ListResult<Selected<Course, S>>>;
|
|
160
|
+
/**
|
|
161
|
+
* Get a single course by slug. Returns `null` if not found.
|
|
162
|
+
* @example
|
|
163
|
+
* const course = await loopwise.courses.get({ slug: 'finance' })
|
|
164
|
+
* if (!course) return Astro.redirect('/404')
|
|
165
|
+
*/
|
|
166
|
+
get<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS>(args: CourseGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Course, S> | null>;
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/index.d.ts
|
|
170
|
+
interface Loopwise {
|
|
171
|
+
readonly courses: CoursesResource;
|
|
172
|
+
/**
|
|
173
|
+
* Compose multiple resource queries into a single GraphQL HTTP request.
|
|
174
|
+
* @example
|
|
175
|
+
* const home = await loopwise.batch({
|
|
176
|
+
* featured: loopwise.courses.list({ limit: 6 }),
|
|
177
|
+
* recent: loopwise.courses.list({ limit: 3, page: 2 }),
|
|
178
|
+
* }, { cache: 60 })
|
|
179
|
+
*/
|
|
180
|
+
batch<R extends Record<string, QueryBuilder<unknown>>>(builders: R, options?: FetchOptions): Promise<{ [K in keyof R]: R[K] extends QueryBuilder<infer T> ? T : never }>;
|
|
181
|
+
/**
|
|
182
|
+
* Raw GraphQL escape hatch. Use when a typed resource method doesn't cover
|
|
183
|
+
* what you need (new schema fields, custom queries, ad-hoc inspection).
|
|
184
|
+
* @example
|
|
185
|
+
* const data = await loopwise.query<{ school: { name: string } }>(`
|
|
186
|
+
* query { school(subdomain: "acme") { name } }
|
|
187
|
+
* `)
|
|
188
|
+
*/
|
|
189
|
+
query<T = unknown>(document: string, variables?: Record<string, unknown>, operationName?: string, options?: FetchOptions): Promise<T>;
|
|
190
|
+
}
|
|
191
|
+
declare function createClient(config: LoopwiseConfig): Loopwise;
|
|
192
|
+
//#endregion
|
|
193
|
+
export { type Course, type FetchOptions, type Lecturer, type ListResult, Loopwise, type LoopwiseConfig, LoopwiseError, type PageInfo, QueryBuilder, type Selected, type Selection, createClient };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
//#region src/error.ts
|
|
2
|
+
var LoopwiseError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
tenant;
|
|
5
|
+
endpoint;
|
|
6
|
+
graphqlErrors;
|
|
7
|
+
requestId;
|
|
8
|
+
constructor(code, message, detail = {}) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "LoopwiseError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.tenant = detail.tenant;
|
|
13
|
+
this.endpoint = detail.endpoint;
|
|
14
|
+
this.graphqlErrors = detail.graphqlErrors;
|
|
15
|
+
this.requestId = detail.requestId;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/runtime.ts
|
|
21
|
+
function detectRuntime() {
|
|
22
|
+
if (typeof window !== "undefined" && typeof window.document !== "undefined") return "browser";
|
|
23
|
+
return "ssr";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/client.ts
|
|
28
|
+
const GET_URL_MAX_LENGTH = 2e3;
|
|
29
|
+
const GRAPHQL_NAME_RE = /^[_A-Za-z][_0-9A-Za-z]*$/;
|
|
30
|
+
var LoopwiseClient = class {
|
|
31
|
+
config;
|
|
32
|
+
endpoint;
|
|
33
|
+
constructor(config) {
|
|
34
|
+
const tenant = config.tenant ?? void 0;
|
|
35
|
+
if (detectRuntime() !== "browser" && !tenant) throw new LoopwiseError("CONFIG", "tenant is required outside browser runtime. Pass it to createClient({ tenant }).");
|
|
36
|
+
this.config = {
|
|
37
|
+
...config,
|
|
38
|
+
tenant
|
|
39
|
+
};
|
|
40
|
+
this.endpoint = config.endpoint ?? this.defaultEndpoint();
|
|
41
|
+
}
|
|
42
|
+
defaultEndpoint() {
|
|
43
|
+
if (detectRuntime() === "browser") return "/api/graphql";
|
|
44
|
+
const tenant = this.config.tenant;
|
|
45
|
+
if (tenant.includes(".")) return `https://${tenant}/api/graphql`;
|
|
46
|
+
return `https://${tenant}.kaik.io/api/graphql`;
|
|
47
|
+
}
|
|
48
|
+
async execute(descriptor, options = {}) {
|
|
49
|
+
const document = renderSingleDocument(descriptor);
|
|
50
|
+
const payload = {
|
|
51
|
+
query: document.query,
|
|
52
|
+
variables: document.variables,
|
|
53
|
+
operationName: descriptor.operation
|
|
54
|
+
};
|
|
55
|
+
const raw = (await this.fetchGraphQL(payload, options))?.[descriptor.field];
|
|
56
|
+
return descriptor.transform ? descriptor.transform(raw) : raw;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Raw GraphQL escape hatch. Caller writes the query/mutation document and
|
|
60
|
+
* receives the typed `data` payload. Variables are sent as-is. Errors are
|
|
61
|
+
* still mapped to LoopwiseError.
|
|
62
|
+
*/
|
|
63
|
+
async raw(document, variables = {}, operationName, options = {}) {
|
|
64
|
+
return await this.fetchGraphQL({
|
|
65
|
+
query: document,
|
|
66
|
+
variables,
|
|
67
|
+
operationName
|
|
68
|
+
}, options);
|
|
69
|
+
}
|
|
70
|
+
async batch(builders, options = {}) {
|
|
71
|
+
const entries = Object.entries(builders);
|
|
72
|
+
if (entries.length === 0) return {};
|
|
73
|
+
for (const [alias, builder] of entries) {
|
|
74
|
+
const aliasStr = String(alias);
|
|
75
|
+
if (!GRAPHQL_NAME_RE.test(aliasStr)) throw new LoopwiseError("CONFIG", `batch() alias "${aliasStr}" is not a valid GraphQL Name`);
|
|
76
|
+
const builderOptions = builder.fetchOptions;
|
|
77
|
+
if (builderOptions && Object.keys(builderOptions).length > 0) throw new LoopwiseError("CONFIG", `batch() builder "${aliasStr}" has per-call FetchOptions; pass them to batch(builders, options) instead so they apply to the whole request.`);
|
|
78
|
+
}
|
|
79
|
+
const document = renderBatchDocument(entries.map(([alias, b]) => ({
|
|
80
|
+
alias: String(alias),
|
|
81
|
+
descriptor: b.descriptor
|
|
82
|
+
})));
|
|
83
|
+
const data = await this.fetchGraphQL({
|
|
84
|
+
query: document.query,
|
|
85
|
+
variables: document.variables,
|
|
86
|
+
operationName: "BatchQuery"
|
|
87
|
+
}, options);
|
|
88
|
+
const result = {};
|
|
89
|
+
for (const [alias, builder] of entries) {
|
|
90
|
+
const raw = data?.[String(alias)];
|
|
91
|
+
result[alias] = builder.descriptor.transform ? builder.descriptor.transform(raw) : raw;
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
async fetchGraphQL(payload, options = {}) {
|
|
96
|
+
const headers = { accept: "application/graphql-response+json, application/json" };
|
|
97
|
+
const apiKey = this.resolveApiKey();
|
|
98
|
+
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
|
99
|
+
const useGet = this.shouldUseGet(payload, options);
|
|
100
|
+
if (typeof options.cache === "number" && options.cache > 0) headers["x-loopwise-cache"] = String(options.cache);
|
|
101
|
+
let response;
|
|
102
|
+
try {
|
|
103
|
+
if (useGet) response = await fetch(this.buildGetUrl(payload), {
|
|
104
|
+
method: "GET",
|
|
105
|
+
credentials: "omit",
|
|
106
|
+
headers
|
|
107
|
+
});
|
|
108
|
+
else response = await fetch(this.endpoint, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
credentials: detectRuntime() === "browser" ? "same-origin" : "omit",
|
|
111
|
+
headers: {
|
|
112
|
+
...headers,
|
|
113
|
+
"content-type": "application/json"
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify(payload)
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
throw new LoopwiseError("NETWORK", `Network error: ${err.message}`, {
|
|
119
|
+
tenant: this.config.tenant,
|
|
120
|
+
endpoint: this.endpoint
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (response.status === 401 || response.status === 403) throw new LoopwiseError("AUTH", `Auth failed (${response.status})`, {
|
|
124
|
+
tenant: this.config.tenant,
|
|
125
|
+
endpoint: this.endpoint,
|
|
126
|
+
requestId: response.headers.get("x-request-id") ?? void 0
|
|
127
|
+
});
|
|
128
|
+
if (response.status === 429) throw new LoopwiseError("RATE_LIMIT", "Rate limited", {
|
|
129
|
+
tenant: this.config.tenant,
|
|
130
|
+
endpoint: this.endpoint
|
|
131
|
+
});
|
|
132
|
+
if (!response.ok) throw new LoopwiseError("NETWORK", `Upstream ${response.status}`, {
|
|
133
|
+
tenant: this.config.tenant,
|
|
134
|
+
endpoint: this.endpoint
|
|
135
|
+
});
|
|
136
|
+
const body = await response.json();
|
|
137
|
+
if (body.errors?.length) throw new LoopwiseError("GRAPHQL", body.errors[0]?.message ?? "GraphQL error", {
|
|
138
|
+
tenant: this.config.tenant,
|
|
139
|
+
endpoint: this.endpoint,
|
|
140
|
+
graphqlErrors: body.errors
|
|
141
|
+
});
|
|
142
|
+
return body.data ?? null;
|
|
143
|
+
}
|
|
144
|
+
shouldUseGet(payload, options) {
|
|
145
|
+
if (typeof options.cache !== "number" || options.cache <= 0) return false;
|
|
146
|
+
if (detectRuntime() !== "browser" || this.resolveApiKey()) return false;
|
|
147
|
+
const op = firstOperationKeyword(payload.query);
|
|
148
|
+
if (op === "mutation" || op === "subscription") return false;
|
|
149
|
+
if (containsNonQueryOperation(payload.query)) return false;
|
|
150
|
+
return this.buildGetUrl(payload).length <= GET_URL_MAX_LENGTH;
|
|
151
|
+
}
|
|
152
|
+
buildGetUrl(payload) {
|
|
153
|
+
const params = new URLSearchParams();
|
|
154
|
+
params.set("query", payload.query);
|
|
155
|
+
if (payload.variables && Object.keys(payload.variables).length > 0) params.set("variables", JSON.stringify(payload.variables));
|
|
156
|
+
if (payload.operationName) params.set("operationName", payload.operationName);
|
|
157
|
+
const sep = this.endpoint.includes("?") ? "&" : "?";
|
|
158
|
+
return `${this.endpoint}${sep}${params.toString()}`;
|
|
159
|
+
}
|
|
160
|
+
resolveApiKey() {
|
|
161
|
+
if (detectRuntime() === "browser") return void 0;
|
|
162
|
+
if (this.config.apiKey) return this.config.apiKey;
|
|
163
|
+
return globalThis.process?.env?.LOOPWISE_API_KEY;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
function firstOperationKeyword(document) {
|
|
167
|
+
let i = 0;
|
|
168
|
+
if (document.charCodeAt(0) === 65279) i = 1;
|
|
169
|
+
while (i < document.length) {
|
|
170
|
+
const ch = document[i];
|
|
171
|
+
if (ch === " " || ch === " " || ch === "\r" || ch === "\n" || ch === ",") {
|
|
172
|
+
i++;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (ch === "#") {
|
|
176
|
+
while (i < document.length && document[i] !== "\n") i++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
const rest = document.slice(i);
|
|
182
|
+
if (/^mutation\b/.test(rest)) return "mutation";
|
|
183
|
+
if (/^subscription\b/.test(rest)) return "subscription";
|
|
184
|
+
if (/^query\b/.test(rest) || rest.startsWith("{")) return "query";
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
function containsNonQueryOperation(document) {
|
|
188
|
+
const stripped = document.replace(/#[^\n]*/g, "").replace(/"""[\s\S]*?"""/g, "\"\"").replace(/"(?:\\.|[^"\\])*"/g, "\"\"");
|
|
189
|
+
return /\b(mutation|subscription)\b\s*[A-Za-z_][\w]*?\s*[({@]?[\s\S]{0,256}?\{/m.test(stripped) || /\b(mutation|subscription)\b\s*\{/m.test(stripped);
|
|
190
|
+
}
|
|
191
|
+
function composeFieldCall(descriptor) {
|
|
192
|
+
const inline = stringifyArgs(descriptor.args);
|
|
193
|
+
const allArgs = [Object.keys(descriptor.vars ?? {}).map((name) => `${name}: $${name}`).join(", "), inline].filter(Boolean).join(", ");
|
|
194
|
+
return allArgs ? `${descriptor.field}(${allArgs})` : descriptor.field;
|
|
195
|
+
}
|
|
196
|
+
function renderSingleDocument(descriptor) {
|
|
197
|
+
const fieldCall = composeFieldCall(descriptor);
|
|
198
|
+
const varDecls = Object.entries(descriptor.vars ?? {}).map(([name, { type }]) => `$${name}: ${type}`).join(", ");
|
|
199
|
+
const query = `query ${varDecls ? `${descriptor.operation}(${varDecls})` : descriptor.operation} { ${fieldCall} { ${descriptor.selectionSet} } }`;
|
|
200
|
+
const variables = {};
|
|
201
|
+
for (const [name, { value }] of Object.entries(descriptor.vars ?? {})) variables[name] = value;
|
|
202
|
+
return {
|
|
203
|
+
query,
|
|
204
|
+
variables
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function renderBatchDocument(entries) {
|
|
208
|
+
const mergedVars = {};
|
|
209
|
+
for (const { descriptor } of entries) for (const [name, def] of Object.entries(descriptor.vars ?? {})) mergedVars[name] = def;
|
|
210
|
+
const varDecls = Object.entries(mergedVars).map(([name, { type }]) => `$${name}: ${type}`).join(", ");
|
|
211
|
+
const opSig = varDecls ? `BatchQuery(${varDecls})` : "BatchQuery";
|
|
212
|
+
const fields = entries.map(({ alias, descriptor }) => `${alias}: ${composeFieldCall(descriptor)} { ${descriptor.selectionSet} }`);
|
|
213
|
+
const variables = {};
|
|
214
|
+
for (const [name, { value }] of Object.entries(mergedVars)) variables[name] = value;
|
|
215
|
+
return {
|
|
216
|
+
query: `query ${opSig} { ${fields.join(" ")} }`,
|
|
217
|
+
variables
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function stringifyArgs(args) {
|
|
221
|
+
const parts = [];
|
|
222
|
+
for (const [k, v] of Object.entries(args)) {
|
|
223
|
+
if (v === void 0) continue;
|
|
224
|
+
parts.push(`${k}: ${literal(v)}`);
|
|
225
|
+
}
|
|
226
|
+
return parts.join(", ");
|
|
227
|
+
}
|
|
228
|
+
function literal(value) {
|
|
229
|
+
if (value === null) return "null";
|
|
230
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
231
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
232
|
+
if (Array.isArray(value)) return `[${value.map(literal).join(", ")}]`;
|
|
233
|
+
if (typeof value === "object") return `{ ${Object.entries(value).filter(([, v]) => v !== void 0).map(([k, v]) => `${k}: ${literal(v)}`).join(", ")} }`;
|
|
234
|
+
throw new LoopwiseError("CONFIG", `Unsupported argument type for GraphQL literal: ${typeof value}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region src/query.ts
|
|
239
|
+
var QueryBuilder = class {
|
|
240
|
+
descriptor;
|
|
241
|
+
fetchOptions;
|
|
242
|
+
fetcher;
|
|
243
|
+
constructor(fetcher, descriptor, fetchOptions = {}) {
|
|
244
|
+
this.fetcher = fetcher;
|
|
245
|
+
this.descriptor = descriptor;
|
|
246
|
+
this.fetchOptions = fetchOptions;
|
|
247
|
+
}
|
|
248
|
+
then(onFulfilled, onRejected) {
|
|
249
|
+
return this.fetcher.execute(this.descriptor, this.fetchOptions).then(onFulfilled, onRejected);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/schema/courses.ts
|
|
255
|
+
/** Map SDK Course selection -> schema field selection set. */
|
|
256
|
+
function buildCourseSelection(select) {
|
|
257
|
+
const parts = ["id", "slug"];
|
|
258
|
+
const sel = select;
|
|
259
|
+
if (sel.title) parts.push("name");
|
|
260
|
+
if (sel.description) parts.push("description");
|
|
261
|
+
if (sel.coverImage) parts.push("image");
|
|
262
|
+
if (sel.createdAt) parts.push("createdAt");
|
|
263
|
+
if (sel.lecturer && typeof sel.lecturer === "object") {
|
|
264
|
+
const lec = sel.lecturer;
|
|
265
|
+
const lecParts = ["id"];
|
|
266
|
+
if (lec.name) lecParts.push("name");
|
|
267
|
+
if (lec.avatar) lecParts.push("avatar");
|
|
268
|
+
parts.push(`lecturer { ${lecParts.join(" ")} }`);
|
|
269
|
+
}
|
|
270
|
+
return parts.join(" ");
|
|
271
|
+
}
|
|
272
|
+
function mapCourse(raw) {
|
|
273
|
+
const out = {
|
|
274
|
+
id: raw.id,
|
|
275
|
+
slug: raw.slug
|
|
276
|
+
};
|
|
277
|
+
if (raw.name !== void 0) out.title = raw.name;
|
|
278
|
+
if (raw.description !== void 0) out.description = raw.description ?? null;
|
|
279
|
+
if (raw.image !== void 0) out.coverImage = raw.image ?? null;
|
|
280
|
+
if (raw.createdAt !== void 0) out.createdAt = raw.createdAt;
|
|
281
|
+
if (raw.lecturer !== void 0) out.lecturer = raw.lecturer ? mapLecturer(raw.lecturer) : null;
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
function mapLecturer(raw) {
|
|
285
|
+
const out = { id: raw.id };
|
|
286
|
+
if (raw.name !== void 0) out.name = raw.name;
|
|
287
|
+
if (raw.avatar !== void 0) out.avatar = raw.avatar ?? null;
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
function mapCoursePage(raw) {
|
|
291
|
+
return {
|
|
292
|
+
items: raw.nodes.map(mapCourse),
|
|
293
|
+
pageInfo: {
|
|
294
|
+
page: raw.currentPage,
|
|
295
|
+
totalPages: raw.totalPages,
|
|
296
|
+
totalCount: raw.nodesCount,
|
|
297
|
+
hasNext: raw.hasNextPage,
|
|
298
|
+
hasPrev: raw.hasPreviousPage
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/resources/courses.ts
|
|
305
|
+
const DEFAULT_LIST_FIELDS = {
|
|
306
|
+
id: true,
|
|
307
|
+
slug: true,
|
|
308
|
+
title: true,
|
|
309
|
+
coverImage: true
|
|
310
|
+
};
|
|
311
|
+
const DEFAULT_GET_FIELDS = {
|
|
312
|
+
id: true,
|
|
313
|
+
slug: true,
|
|
314
|
+
title: true,
|
|
315
|
+
description: true,
|
|
316
|
+
coverImage: true,
|
|
317
|
+
lecturer: {
|
|
318
|
+
id: true,
|
|
319
|
+
name: true,
|
|
320
|
+
avatar: true
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
function tenantVars(client) {
|
|
324
|
+
return { subdomain: {
|
|
325
|
+
type: "String!",
|
|
326
|
+
value: client.config.tenant ?? ""
|
|
327
|
+
} };
|
|
328
|
+
}
|
|
329
|
+
var CoursesResource = class {
|
|
330
|
+
constructor(client) {
|
|
331
|
+
this.client = client;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* List courses for the current tenant.
|
|
335
|
+
* @example
|
|
336
|
+
* const { items, pageInfo } = await loopwise.courses.list({ limit: 20 })
|
|
337
|
+
* @example
|
|
338
|
+
* // Opt into GET + Cache-Control hint (60s):
|
|
339
|
+
* await loopwise.courses.list({ limit: 20 }, { cache: 60 })
|
|
340
|
+
*/
|
|
341
|
+
list(args = {}, options = {}) {
|
|
342
|
+
const selectionSet = `nodes { ${buildCourseSelection(args.select ?? DEFAULT_LIST_FIELDS)} } currentPage totalPages nodesCount hasNextPage hasPreviousPage`;
|
|
343
|
+
return new QueryBuilder(this.client, {
|
|
344
|
+
operation: "CourseList",
|
|
345
|
+
field: "courses",
|
|
346
|
+
selectionSet,
|
|
347
|
+
args: {
|
|
348
|
+
page: args.page,
|
|
349
|
+
perPage: args.limit
|
|
350
|
+
},
|
|
351
|
+
vars: tenantVars(this.client),
|
|
352
|
+
transform: (raw) => mapCoursePage(raw)
|
|
353
|
+
}, options);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Get a single course by slug. Returns `null` if not found.
|
|
357
|
+
* @example
|
|
358
|
+
* const course = await loopwise.courses.get({ slug: 'finance' })
|
|
359
|
+
* if (!course) return Astro.redirect('/404')
|
|
360
|
+
*/
|
|
361
|
+
get(args, options = {}) {
|
|
362
|
+
const sel = args.select ?? DEFAULT_GET_FIELDS;
|
|
363
|
+
return new QueryBuilder(this.client, {
|
|
364
|
+
operation: "CourseGet",
|
|
365
|
+
field: "course",
|
|
366
|
+
selectionSet: buildCourseSelection(sel),
|
|
367
|
+
args: { slug: args.slug },
|
|
368
|
+
vars: tenantVars(this.client),
|
|
369
|
+
transform: (raw) => raw == null ? null : mapCourse(raw)
|
|
370
|
+
}, options);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/index.ts
|
|
376
|
+
function createClient(config) {
|
|
377
|
+
const client = new LoopwiseClient(config);
|
|
378
|
+
return {
|
|
379
|
+
courses: new CoursesResource(client),
|
|
380
|
+
batch(builders, options) {
|
|
381
|
+
return client.batch(builders, options);
|
|
382
|
+
},
|
|
383
|
+
query(document, variables, operationName, options) {
|
|
384
|
+
return client.raw(document, variables, operationName, options);
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
//#endregion
|
|
390
|
+
export { LoopwiseError, QueryBuilder, createClient };
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@loopwise/sdk",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Loopwise platform SDK — agent-friendly headless API client",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/kaikhq/manekineko.git",
|
|
8
|
+
"directory": "packages/sdk"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/kaikhq/manekineko/tree/develop/packages/sdk#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/kaikhq/manekineko/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"loopwise",
|
|
16
|
+
"teachify",
|
|
17
|
+
"graphql",
|
|
18
|
+
"headless",
|
|
19
|
+
"storefront",
|
|
20
|
+
"sdk"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"main": "./dist/index.mjs",
|
|
25
|
+
"module": "./dist/index.mjs",
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.mts",
|
|
30
|
+
"import": "./dist/index.mjs"
|
|
31
|
+
},
|
|
32
|
+
"./config": {
|
|
33
|
+
"types": "./dist/config.d.mts",
|
|
34
|
+
"import": "./dist/config.mjs"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist/*.mjs",
|
|
39
|
+
"dist/*.d.mts",
|
|
40
|
+
"README.md"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsdown",
|
|
44
|
+
"dev": "tsdown --watch",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"tsconfig": "workspace:*",
|
|
50
|
+
"tsdown": "0.20.3",
|
|
51
|
+
"typescript": "5.9.3",
|
|
52
|
+
"vitest": "4.1.0"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
}
|
|
57
|
+
}
|