@superbuilders/primer-tives 0.3.2 → 0.5.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 +447 -150
- package/dist/choice-state.d.ts.map +1 -1
- package/dist/content.d.ts +7 -3
- package/dist/content.d.ts.map +1 -1
- package/dist/extended-text-state.d.ts.map +1 -1
- package/dist/feedback-state.d.ts +2 -2
- package/dist/feedback-state.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +298 -144
- package/dist/index.js.map +12 -12
- package/dist/match-state.d.ts.map +1 -1
- package/dist/order-state.d.ts +3 -10
- package/dist/order-state.d.ts.map +1 -1
- package/dist/pci-state.d.ts.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/text-entry-state.d.ts.map +1 -1
- package/dist/transport.d.ts +2 -2
- package/dist/transport.d.ts.map +1 -1
- package/dist/types.d.ts +61 -28
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
# @superbuilders/primer-tives
|
|
2
2
|
|
|
3
|
-
Client SDK for the Primer adaptive learning engine.
|
|
3
|
+
Client SDK for the Primer adaptive learning engine.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
This package turns the Primer HTTP protocol into a strongly typed, server-driven state machine. Your app renders the current frame, calls the method that is valid for that frame, and the server decides what comes next.
|
|
6
|
+
|
|
7
|
+
The SDK does **not** know about routines, curricula, storage schemas, or routing rules. It only knows how to:
|
|
8
|
+
|
|
9
|
+
- start a student session
|
|
10
|
+
- submit answers or timeouts
|
|
11
|
+
- normalize transport failures into sentinel errors plus `retriable` semantics
|
|
12
|
+
- expose the current step as an ergonomic TypeScript union
|
|
13
|
+
- expose interaction-native review data for feedback rendering
|
|
14
|
+
- preserve full type safety for portable custom interactions (PCIs)
|
|
6
15
|
|
|
7
16
|
## Install
|
|
8
17
|
|
|
@@ -12,19 +21,37 @@ bun add @superbuilders/primer-tives
|
|
|
12
21
|
|
|
13
22
|
Dependency: `@superbuilders/errors` is installed automatically.
|
|
14
23
|
|
|
24
|
+
## What you get
|
|
25
|
+
|
|
26
|
+
- **Single entry point**: `create(config)`
|
|
27
|
+
- **Single wire endpoint**: `POST ${origin}/api/v0/advance`
|
|
28
|
+
- **Single runtime model**: `PrimerState`
|
|
29
|
+
- **Six phases/kinds to handle**:
|
|
30
|
+
- phases: `observation`, `interaction`, `feedback`, `completed`, `errored`, `fatal`
|
|
31
|
+
- interaction kinds: `choice`, `text-entry`, `extended-text`, `order`, `match`, `portable-custom`
|
|
32
|
+
- **Typed PCI submissions** driven by the `PciRegistry`
|
|
33
|
+
- **Live in-memory state objects** with action methods like `advance()`, `submitChoice()`, `submitOrder()`, `submitMatch()`, `submit()`, `timeout()`, and `retry()`
|
|
34
|
+
|
|
15
35
|
## Quick start
|
|
16
36
|
|
|
17
37
|
```ts
|
|
18
|
-
import { create, ErrRateLimited } from "@superbuilders/primer-tives"
|
|
19
38
|
import * as errors from "@superbuilders/errors"
|
|
39
|
+
import {
|
|
40
|
+
create,
|
|
41
|
+
ErrRateLimited,
|
|
42
|
+
type PrimerState,
|
|
43
|
+
} from "@superbuilders/primer-tives"
|
|
20
44
|
|
|
21
45
|
const client = create({
|
|
22
46
|
publishableKey: "pk_live_abc123",
|
|
23
47
|
origin: "https://sb-primer.vercel.app",
|
|
24
|
-
supportedPcis: [
|
|
48
|
+
supportedPcis: [
|
|
49
|
+
"urn:primer:pci:division-remainder",
|
|
50
|
+
"urn:primer:pci:fraction-addition",
|
|
51
|
+
],
|
|
25
52
|
})
|
|
26
53
|
|
|
27
|
-
let state = await client.start("student-uuid")
|
|
54
|
+
let state: PrimerState = await client.start("student-uuid")
|
|
28
55
|
|
|
29
56
|
while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
30
57
|
switch (state.phase) {
|
|
@@ -34,32 +61,64 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
|
34
61
|
break
|
|
35
62
|
|
|
36
63
|
case "interaction":
|
|
64
|
+
renderStimulus(state.stimulus)
|
|
65
|
+
|
|
37
66
|
switch (state.kind) {
|
|
38
67
|
case "choice":
|
|
39
68
|
state = await state.submitChoice(["option-a"])
|
|
40
69
|
break
|
|
70
|
+
|
|
41
71
|
case "text-entry":
|
|
42
72
|
state = await state.submitText("42")
|
|
43
73
|
break
|
|
74
|
+
|
|
44
75
|
case "extended-text":
|
|
45
76
|
if (state.cardinality === "single") {
|
|
46
77
|
state = await state.submitText("answer")
|
|
47
78
|
} else {
|
|
48
|
-
state = await state.submitTexts(["
|
|
79
|
+
state = await state.submitTexts(["first", "second"])
|
|
49
80
|
}
|
|
50
81
|
break
|
|
82
|
+
|
|
83
|
+
case "order":
|
|
84
|
+
state = await state.submitOrder(["choice-1", "choice-2", "choice-3"])
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
case "match":
|
|
88
|
+
state = await state.submitMatch([
|
|
89
|
+
{ source: "left-1", target: "right-2" },
|
|
90
|
+
{ source: "left-2", target: "right-1" },
|
|
91
|
+
])
|
|
92
|
+
break
|
|
93
|
+
|
|
51
94
|
case "portable-custom":
|
|
52
|
-
|
|
95
|
+
switch (state.pciId) {
|
|
96
|
+
case "urn:primer:pci:division-remainder":
|
|
97
|
+
state = await state.submit({ quotient: "3", remainder: "1" })
|
|
98
|
+
break
|
|
99
|
+
case "urn:primer:pci:fraction-addition":
|
|
100
|
+
state = await state.submit({ numerator: "5", denominator: "6" })
|
|
101
|
+
break
|
|
102
|
+
}
|
|
53
103
|
break
|
|
54
104
|
}
|
|
55
105
|
break
|
|
56
106
|
|
|
57
107
|
case "feedback":
|
|
58
|
-
renderFeedback(
|
|
108
|
+
renderFeedback({
|
|
109
|
+
correct: state.isCorrect,
|
|
110
|
+
content: state.feedbackContent,
|
|
111
|
+
review: state.review,
|
|
112
|
+
interaction: state.interaction,
|
|
113
|
+
submission: state.submission,
|
|
114
|
+
})
|
|
59
115
|
state = await state.advance()
|
|
60
116
|
break
|
|
61
117
|
|
|
62
118
|
case "errored":
|
|
119
|
+
if (!state.retriable) {
|
|
120
|
+
throw state.error
|
|
121
|
+
}
|
|
63
122
|
if (errors.is(state.error, ErrRateLimited)) {
|
|
64
123
|
await delay(1000)
|
|
65
124
|
}
|
|
@@ -69,27 +128,18 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
|
69
128
|
}
|
|
70
129
|
```
|
|
71
130
|
|
|
72
|
-
Both `state.phase` and `state.kind` are discriminated unions
|
|
131
|
+
Both `state.phase` and `state.kind` are discriminated unions, so TypeScript narrows automatically inside each branch.
|
|
73
132
|
|
|
74
|
-
##
|
|
133
|
+
## Mental model
|
|
75
134
|
|
|
76
|
-
|
|
135
|
+
Primer is **server-authored**.
|
|
77
136
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
```
|
|
137
|
+
- The client sends an **intent**: advance, submit, or timeout.
|
|
138
|
+
- The server evaluates that intent and returns the next frame.
|
|
139
|
+
- The SDK wraps that frame in a typed state object.
|
|
140
|
+
- Your UI renders the state and calls the next valid method.
|
|
83
141
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
The server identifies the student, determines which content to serve based on the frontend's assigned courses, evaluates submissions, and returns the next frame. The client sends:
|
|
87
|
-
|
|
88
|
-
- `studentId` — who is being advanced
|
|
89
|
-
- `supportedPcis` — which custom interaction types the renderer can handle
|
|
90
|
-
- `intent` — `{ kind: "observation" }`, `{ kind: "interaction", submission: ... }`, or `{ kind: "timeout" }`
|
|
91
|
-
|
|
92
|
-
Course resolution is entirely server-side. The publishable key identifies the frontend, and the server looks up which courses are assigned to that frontend. The client has no knowledge of courses, subjects, or form factors.
|
|
142
|
+
In other words, your code does not calculate progression locally. The server owns progression; the SDK owns transport, typing, and ergonomics.
|
|
93
143
|
|
|
94
144
|
## Configuration
|
|
95
145
|
|
|
@@ -106,208 +156,374 @@ interface Config<Pcis extends PciId = PciId> {
|
|
|
106
156
|
|
|
107
157
|
| Field | Required | Description |
|
|
108
158
|
|---|---|---|
|
|
109
|
-
| `publishableKey` | yes | Must start with `pk_`. Sent as `Authorization: Bearer pk_...`
|
|
110
|
-
| `supportedPcis` | yes | PCI URNs the renderer
|
|
111
|
-
| `origin` | yes |
|
|
112
|
-
| `fetch` | no | Custom fetch. Defaults to `globalThis.fetch` |
|
|
113
|
-
| `abort` | no | AbortController
|
|
114
|
-
| `logger` | no | Structured logger
|
|
159
|
+
| `publishableKey` | yes | Must start with `pk_`. Sent as `Authorization: Bearer pk_...` |
|
|
160
|
+
| `supportedPcis` | yes | PCI URNs the renderer can handle. Use `[]` if none |
|
|
161
|
+
| `origin` | yes | Full Primer API base URL, for example `https://sb-primer.vercel.app` |
|
|
162
|
+
| `fetch` | no | Custom fetch implementation. Defaults to `globalThis.fetch` |
|
|
163
|
+
| `abort` | no | `AbortController` whose `signal` is passed to every request |
|
|
164
|
+
| `logger` | no | Structured logger with `debug`, `info`, `warn`, and `error` methods |
|
|
165
|
+
|
|
166
|
+
`create()` validates the publishable key prefix immediately and throws `ErrMalformedPublishableKey` if it does not start with `pk_`.
|
|
167
|
+
|
|
168
|
+
### Logger interface
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
interface PrimerLogger {
|
|
172
|
+
debug(message: string, attributes?: Record<string, unknown>): void
|
|
173
|
+
info(message: string, attributes?: Record<string, unknown>): void
|
|
174
|
+
warn(message: string, attributes?: Record<string, unknown>): void
|
|
175
|
+
error(message: string, attributes?: Record<string, unknown>): void
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Wire protocol
|
|
180
|
+
|
|
181
|
+
Every request is a `POST` to:
|
|
182
|
+
|
|
183
|
+
```txt
|
|
184
|
+
${origin}/api/v0/advance
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The path constant is exported as:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
const ADVANCE_PATH = "/api/v0/advance"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Headers
|
|
194
|
+
|
|
195
|
+
```txt
|
|
196
|
+
Authorization: Bearer pk_...
|
|
197
|
+
Content-Type: application/json
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Request body
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
interface WireRequestBody<Pcis extends PciId = PciId> {
|
|
204
|
+
studentId: string
|
|
205
|
+
supportedPcis: readonly PciId[]
|
|
206
|
+
intent: WireIntent<Pcis>
|
|
207
|
+
}
|
|
208
|
+
```
|
|
115
209
|
|
|
116
|
-
|
|
210
|
+
```ts
|
|
211
|
+
type WireIntent<Pcis extends PciId = PciId> =
|
|
212
|
+
| { kind: "observation" }
|
|
213
|
+
| { kind: "interaction"; submission: RendererSubmission<Pcis> }
|
|
214
|
+
| { kind: "timeout" }
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Response outcomes
|
|
218
|
+
|
|
219
|
+
Conceptually, the server returns one of three outcomes:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
type WireResult<Pcis extends PciId = PciId> =
|
|
223
|
+
| {
|
|
224
|
+
outcome: "advanced"
|
|
225
|
+
stimulus: RendererStimulus | null
|
|
226
|
+
interaction: RendererInteraction<Pcis> | null
|
|
227
|
+
}
|
|
228
|
+
| {
|
|
229
|
+
outcome: "submitted"
|
|
230
|
+
stimulus: RendererStimulus | null
|
|
231
|
+
interaction: RendererInteraction<Pcis>
|
|
232
|
+
submission: RendererSubmission<Pcis>
|
|
233
|
+
isCorrect: boolean
|
|
234
|
+
feedbackContent: ContentInline[]
|
|
235
|
+
review: InteractionReview<Pcis> | null
|
|
236
|
+
}
|
|
237
|
+
| { outcome: "completed" }
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Important notes
|
|
241
|
+
|
|
242
|
+
- `origin` must be the **full base URL**. The SDK constructs requests as `${origin}${ADVANCE_PATH}`.
|
|
243
|
+
- `supportedPcis` is sent on every request so the server knows which portable custom interactions the client can render.
|
|
244
|
+
- The client also uses `supportedPcis` as an inbound safety check: if the server returns a PCI frame the client did not advertise support for, the SDK returns a fatal `ErrUnsupportedPci` state.
|
|
117
245
|
|
|
118
246
|
## State machine
|
|
119
247
|
|
|
120
248
|
`PrimerState` is a discriminated union on `phase`:
|
|
121
249
|
|
|
122
|
-
```
|
|
123
|
-
observation
|
|
250
|
+
```txt
|
|
251
|
+
observation -> interaction -> feedback -> observation -> ... -> completed
|
|
124
252
|
| \ | \ | \
|
|
125
|
-
|
|
253
|
+
v \ v \ v \
|
|
126
254
|
errored fatal errored fatal errored fatal
|
|
127
255
|
|
|
128
|
-
errored
|
|
129
|
-
interaction timeout
|
|
256
|
+
errored -> retry() -> replays exact failed action
|
|
257
|
+
interaction timeout -> same transport path, intent kind "timeout"
|
|
130
258
|
```
|
|
131
259
|
|
|
132
|
-
Any action (`advance()`, `submitChoice()`, `timeout()`, etc.) can fail. Transient failures (network, timeout, 5xx) become `errored` with `retry()`. Permanent failures (401, 403, 404) become `fatal` — session is dead, no recovery.
|
|
133
|
-
|
|
134
|
-
Calling an action twice (e.g., double-clicking `advance()`) returns the same promise — the SDK memoizes in-flight requests internally. No special handling needed.
|
|
135
|
-
|
|
136
260
|
### `observation`
|
|
137
261
|
|
|
138
|
-
Display-only frame.
|
|
262
|
+
Display-only frame. Render `state.stimulus`, then call `advance()`.
|
|
139
263
|
|
|
140
264
|
```ts
|
|
265
|
+
state.phase === "observation"
|
|
141
266
|
state.stimulus // RendererStimulus | null
|
|
142
267
|
state.advance() // Promise<PrimerState>
|
|
143
268
|
```
|
|
144
269
|
|
|
145
|
-
Stimulus is a discriminated union on `type`:
|
|
146
|
-
- `BodyStimulus` — `{ type: "body", body: ContentBlock[] }` — text-only
|
|
147
|
-
- `ImageStimulus` — `{ type: "image", description: ContentInline[], src: string }` — image-backed
|
|
148
|
-
- `null` — no stimulus
|
|
149
|
-
|
|
150
270
|
### `interaction`
|
|
151
271
|
|
|
152
|
-
Student must respond. Discriminate on `state.kind
|
|
272
|
+
Student must respond. Discriminate on `state.kind`.
|
|
153
273
|
|
|
154
|
-
|
|
155
|
-
|---|---|---|
|
|
156
|
-
| `"choice"` | `submitChoice(keys: string[])` | `options`, `maxChoices`, `minChoices` |
|
|
157
|
-
| `"text-entry"` | `submitText(value: string)` | — |
|
|
158
|
-
| `"extended-text"` (single) | `submitText(value: string)` | `cardinality: "single"` |
|
|
159
|
-
| `"extended-text"` (multiple) | `submitTexts(values: string[])` | `cardinality: "multiple"`, `maxStrings`, `minStrings` |
|
|
160
|
-
| `"portable-custom"` | `submit(value: PciValue<K>)` | `pciId`, `properties` |
|
|
274
|
+
All interaction states also expose:
|
|
161
275
|
|
|
162
|
-
|
|
276
|
+
- `state.stimulus`
|
|
277
|
+
- `state.interaction`
|
|
278
|
+
- `timeout()`
|
|
163
279
|
|
|
164
|
-
|
|
280
|
+
#### Interaction kinds
|
|
165
281
|
|
|
166
|
-
|
|
282
|
+
| `state.kind` | Submit method | Key fields |
|
|
283
|
+
|---|---|---|
|
|
284
|
+
| `"choice"` | `submitChoice(selectedKeys: string[])` | `options`, `minChoices`, `maxChoices` |
|
|
285
|
+
| `"text-entry"` | `submitText(value: string)` | `interaction.base`, `interaction.expectedLength?`, `interaction.patternMask?`, `interaction.placeholderText?` |
|
|
286
|
+
| `"extended-text"` + `cardinality: "single"` | `submitText(value: string)` | `interaction.format`, `interaction.expectedLength?`, `interaction.expectedLines?`, `interaction.patternMask?`, `interaction.placeholderText?` |
|
|
287
|
+
| `"extended-text"` + `cardinality: "multiple"` | `submitTexts(values: string[])` | `minStrings`, `maxStrings`, plus the same metadata as single-cardinality extended text |
|
|
288
|
+
| `"order"` | `submitOrder(orderedKeys: string[])` | `choices`, `minChoices`, `maxChoices`, `interaction.shuffle` |
|
|
289
|
+
| `"match"` | `submitMatch(pairs: Array<{ source: string; target: string }>)` | `sourceChoices`, `targetChoices`, `minAssociations`, `maxAssociations`, `interaction.shuffle` |
|
|
290
|
+
| `"portable-custom"` | `submit(value: PciValue<K>)` | `pciId`, `properties` |
|
|
167
291
|
|
|
168
292
|
### `feedback`
|
|
169
293
|
|
|
170
|
-
|
|
294
|
+
The server has evaluated the submission. Render feedback, then call `advance()`.
|
|
171
295
|
|
|
172
296
|
```ts
|
|
297
|
+
state.phase === "feedback"
|
|
173
298
|
state.stimulus // RendererStimulus | null
|
|
174
|
-
state.interaction //
|
|
175
|
-
state.submission //
|
|
299
|
+
state.interaction // RendererInteraction<Pcis>
|
|
300
|
+
state.submission // RendererSubmission<Pcis>
|
|
176
301
|
state.isCorrect // boolean
|
|
177
302
|
state.feedbackContent // ContentInline[]
|
|
178
|
-
state.
|
|
303
|
+
state.review // InteractionReview<Pcis> | null
|
|
179
304
|
state.advance() // Promise<PrimerState>
|
|
180
305
|
```
|
|
181
306
|
|
|
182
|
-
`
|
|
307
|
+
`review` is interaction-native feedback data when available:
|
|
183
308
|
|
|
184
309
|
```ts
|
|
185
|
-
type
|
|
186
|
-
| {
|
|
187
|
-
| {
|
|
188
|
-
| {
|
|
189
|
-
| {
|
|
190
|
-
|
|
191
|
-
type RendererCorrectAnswer =
|
|
192
|
-
| { kind: "single"; value: RendererCorrectScalarValue | null }
|
|
193
|
-
| { kind: "multiple"; values: RendererCorrectScalarValue[] }
|
|
310
|
+
type InteractionReview<Pcis extends PciId = PciId> =
|
|
311
|
+
| { type: "choice"; correctKeys: string[] }
|
|
312
|
+
| { type: "text-entry"; correctValue: ReviewScalarValue | null }
|
|
313
|
+
| { type: "extended-text"; correctValues: ReviewScalarValue[] }
|
|
314
|
+
| { type: "order"; correctOrder: string[] }
|
|
315
|
+
| { type: "match"; correctPairs: Array<{ source: string; target: string }> }
|
|
194
316
|
| {
|
|
195
|
-
|
|
317
|
+
type: "portable-custom"
|
|
318
|
+
pciId: Pcis
|
|
196
319
|
fields: Array<{
|
|
197
320
|
fieldIdentifier: string
|
|
198
|
-
baseType: "identifier" | "string" | "integer" | "float"
|
|
199
|
-
value:
|
|
321
|
+
baseType: "identifier" | "string" | "integer" | "float" | "pair"
|
|
322
|
+
value: ReviewScalarValue | null
|
|
200
323
|
}>
|
|
201
324
|
}
|
|
325
|
+
|
|
326
|
+
type ReviewScalarValue =
|
|
327
|
+
| { kind: "identifier"; value: string }
|
|
328
|
+
| { kind: "string"; value: string }
|
|
329
|
+
| { kind: "integer"; value: number }
|
|
330
|
+
| { kind: "float"; value: number }
|
|
331
|
+
| { kind: "pair"; source: string; target: string }
|
|
202
332
|
```
|
|
203
333
|
|
|
204
|
-
|
|
334
|
+
Notes:
|
|
335
|
+
|
|
336
|
+
- `null` means the server did not provide review data.
|
|
337
|
+
- order review preserves ordering directly.
|
|
338
|
+
- match review preserves directional `{ source, target }` pairs directly.
|
|
339
|
+
- PCI review uses record fields and can carry `pair` values without flattening them into strings.
|
|
340
|
+
- `feedback.advance()` uses the same observation intent as `observation.advance()`.
|
|
205
341
|
|
|
206
342
|
### `completed`
|
|
207
343
|
|
|
208
|
-
Session finished. No further actions.
|
|
344
|
+
Session finished successfully. No further actions.
|
|
209
345
|
|
|
210
346
|
### `errored`
|
|
211
347
|
|
|
212
|
-
|
|
348
|
+
Recoverable or user-correctable failure.
|
|
213
349
|
|
|
214
350
|
```ts
|
|
215
|
-
state.
|
|
216
|
-
state.
|
|
217
|
-
state.
|
|
351
|
+
state.phase === "errored"
|
|
352
|
+
state.error // Error sentinel
|
|
353
|
+
state.retriable // boolean
|
|
354
|
+
state.retry() // Promise<PrimerState>
|
|
218
355
|
```
|
|
219
356
|
|
|
220
|
-
|
|
357
|
+
Caller guidance:
|
|
358
|
+
|
|
359
|
+
- switch on sentinel identity with `errors.is(...)`
|
|
360
|
+
- check `state.retriable` before calling `retry()`
|
|
361
|
+
- non-retriable errored states keep the original sentinel but do not replay the failed action
|
|
221
362
|
|
|
222
363
|
### `fatal`
|
|
223
364
|
|
|
224
|
-
Permanent failure. Session cannot recover.
|
|
365
|
+
Permanent failure. Session cannot recover.
|
|
225
366
|
|
|
226
367
|
```ts
|
|
227
|
-
state.
|
|
368
|
+
state.phase === "fatal"
|
|
369
|
+
state.error // Error sentinel
|
|
228
370
|
```
|
|
229
371
|
|
|
230
|
-
|
|
231
|
-
- Invalid publishable key (401 — `ErrInvalidPublishableKey`)
|
|
232
|
-
- Origin not allowed (403 — `ErrForbidden`)
|
|
233
|
-
- Resource not found (404 — `ErrNotFound`)
|
|
234
|
-
- Malformed request (400 — `ErrBadRequest`)
|
|
235
|
-
- Unsupported PCI (422 — `ErrUnsupportedPci`)
|
|
372
|
+
Fatal states can come from:
|
|
236
373
|
|
|
237
|
-
|
|
374
|
+
- malformed request semantics on the server (`400`)
|
|
375
|
+
- invalid publishable key (`401`)
|
|
376
|
+
- forbidden origin (`403`)
|
|
377
|
+
- missing resource (`404`)
|
|
378
|
+
- unsupported PCI (`422`)
|
|
379
|
+
- a successful response that still contains a `portable-custom` frame whose `pciId` is not in `supportedPcis`
|
|
380
|
+
|
|
381
|
+
## Interaction payloads
|
|
238
382
|
|
|
239
|
-
|
|
383
|
+
```ts
|
|
384
|
+
type PciSubmission<Pcis extends PciId = PciId> = {
|
|
385
|
+
[K in Pcis]: {
|
|
386
|
+
type: "portable-custom"
|
|
387
|
+
pciId: K
|
|
388
|
+
value: PciValue<K>
|
|
389
|
+
}
|
|
390
|
+
}[Pcis]
|
|
240
391
|
|
|
241
|
-
|
|
392
|
+
type RendererSubmission<Pcis extends PciId = PciId> =
|
|
393
|
+
| { type: "choice"; selectedKeys: string[] }
|
|
394
|
+
| { type: "text-entry"; value: string }
|
|
395
|
+
| { type: "extended-text"; values: string[] }
|
|
396
|
+
| { type: "order"; orderedKeys: string[] }
|
|
397
|
+
| { type: "match"; pairs: Array<{ source: string; target: string }> }
|
|
398
|
+
| PciSubmission<Pcis>
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Notes:
|
|
242
402
|
|
|
243
|
-
|
|
403
|
+
- single-cardinality extended text is still submitted as `type: "extended-text"` with a one-element `values` array
|
|
404
|
+
- match submissions are directional `{ source, target }` pairs
|
|
405
|
+
- portable custom submissions carry both `pciId` and a typed PCI `value`
|
|
406
|
+
|
|
407
|
+
## Client-side validation
|
|
244
408
|
|
|
245
|
-
|
|
409
|
+
The SDK performs **selective** client-side validation before sending some submissions.
|
|
410
|
+
|
|
411
|
+
| Interaction kind | Validation performed |
|
|
412
|
+
|---|---|
|
|
413
|
+
| `choice` | min selections, max selections, duplicate keys, unknown option identifiers |
|
|
414
|
+
| `text-entry` | none |
|
|
415
|
+
| `extended-text` + `single` | none |
|
|
416
|
+
| `extended-text` + `multiple` | `minStrings`, `maxStrings` |
|
|
417
|
+
| `order` | min selections, max selections, duplicate keys, unknown choice identifiers |
|
|
418
|
+
| `match` | `minAssociations`, `maxAssociations`, unknown source/target identifiers, and per-choice `matchMin` / `matchMax` caps on both sides |
|
|
419
|
+
| `portable-custom` | no outbound value validation |
|
|
420
|
+
|
|
421
|
+
If validation fails:
|
|
422
|
+
|
|
423
|
+
- the submit call resolves to an `errored` state
|
|
424
|
+
- `state.error` will match `ErrInvalidSubmission`
|
|
425
|
+
- `state.retriable` will be `false`
|
|
426
|
+
- the original interaction object is still usable for a corrected resubmission
|
|
427
|
+
- `retry()` returns the same errored state instead of replaying the invalid payload
|
|
246
428
|
|
|
247
429
|
## Error sentinels
|
|
248
430
|
|
|
249
431
|
```ts
|
|
250
432
|
import * as errors from "@superbuilders/errors"
|
|
251
|
-
import {
|
|
252
|
-
|
|
253
|
-
|
|
433
|
+
import {
|
|
434
|
+
ErrInvalidSubmission,
|
|
435
|
+
ErrNetwork,
|
|
436
|
+
ErrRateLimited,
|
|
437
|
+
ErrUnsupportedPci,
|
|
438
|
+
} from "@superbuilders/primer-tives"
|
|
439
|
+
|
|
440
|
+
if (errors.is(state.error, ErrNetwork)) {
|
|
441
|
+
// handle offline / DNS / CORS / fetch failure
|
|
442
|
+
}
|
|
254
443
|
```
|
|
255
444
|
|
|
256
|
-
|
|
445
|
+
### Surfaced as `errored`
|
|
257
446
|
|
|
258
|
-
| Sentinel |
|
|
259
|
-
|
|
260
|
-
| `ErrNetwork` |
|
|
261
|
-
| `ErrTimeout` |
|
|
262
|
-
| `ErrRateLimited` | 429 |
|
|
263
|
-
| `
|
|
264
|
-
| `
|
|
265
|
-
| `ErrJsonParse` |
|
|
266
|
-
| `ErrConflict` | 409
|
|
267
|
-
| `ErrInvalidSubmission` |
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
| Sentinel |
|
|
447
|
+
| Sentinel | Meaning | `state.retriable` |
|
|
448
|
+
|---|---|---|
|
|
449
|
+
| `ErrNetwork` | fetch failed before a response was received | `true` |
|
|
450
|
+
| `ErrTimeout` | request was aborted or timed out | `true` |
|
|
451
|
+
| `ErrRateLimited` | `429` | `true` |
|
|
452
|
+
| `ErrServiceUnavailable` | `502`, `503`, or `504` | `true` |
|
|
453
|
+
| `ErrServerError` | any other unhandled HTTP error status | `true` |
|
|
454
|
+
| `ErrJsonParse` | response body was not valid JSON | `true` |
|
|
455
|
+
| `ErrConflict` | `409`, or a local conflicting in-flight action | `true` |
|
|
456
|
+
| `ErrInvalidSubmission` | client-side validation failed | `false` |
|
|
457
|
+
|
|
458
|
+
### Permanent: surfaced as `fatal`
|
|
459
|
+
|
|
460
|
+
| Sentinel | Meaning |
|
|
272
461
|
|---|---|
|
|
273
|
-
| `ErrBadRequest` | 400
|
|
274
|
-
| `ErrInvalidPublishableKey` | 401
|
|
275
|
-
| `ErrForbidden` | 403
|
|
276
|
-
| `ErrNotFound` | 404
|
|
277
|
-
| `ErrUnsupportedPci` | 422
|
|
462
|
+
| `ErrBadRequest` | `400` |
|
|
463
|
+
| `ErrInvalidPublishableKey` | `401` |
|
|
464
|
+
| `ErrForbidden` | `403` |
|
|
465
|
+
| `ErrNotFound` | `404` |
|
|
466
|
+
| `ErrUnsupportedPci` | server rejected the request with `422`, or the server returned a PCI frame the client did not advertise in `supportedPcis` |
|
|
278
467
|
|
|
279
|
-
|
|
468
|
+
### Thrown directly
|
|
280
469
|
|
|
281
|
-
| Sentinel |
|
|
470
|
+
| Sentinel | Meaning |
|
|
282
471
|
|---|---|
|
|
283
|
-
| `ErrMalformedPublishableKey` |
|
|
284
|
-
| `ErrNotSerializable` |
|
|
472
|
+
| `ErrMalformedPublishableKey` | `create()` received a key that does not start with `pk_` |
|
|
473
|
+
| `ErrNotSerializable` | code attempted to serialize a live `PrimerState` |
|
|
285
474
|
|
|
286
|
-
##
|
|
475
|
+
## Content format
|
|
287
476
|
|
|
288
477
|
```ts
|
|
289
|
-
type
|
|
290
|
-
| { type: "
|
|
291
|
-
| { type: "
|
|
292
|
-
|
|
293
|
-
|
|
478
|
+
type ContentSpan =
|
|
479
|
+
| { type: "text"; value: string }
|
|
480
|
+
| { type: "italic"; value: string }
|
|
481
|
+
|
|
482
|
+
type ContentInline =
|
|
483
|
+
| ContentSpan
|
|
484
|
+
| { type: "latex"; value: string }
|
|
485
|
+
|
|
486
|
+
type ContentBlock = { type: "paragraph"; children: ContentInline[] }
|
|
294
487
|
```
|
|
295
488
|
|
|
296
|
-
|
|
489
|
+
### Helpers
|
|
297
490
|
|
|
298
491
|
```ts
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
492
|
+
inlinesToPlainText(nodes: ContentInline[]): string
|
|
493
|
+
blocksToPlainText(blocks: ContentBlock[]): string
|
|
494
|
+
```
|
|
302
495
|
|
|
303
|
-
|
|
496
|
+
- `inlinesToPlainText()` strips formatting and concatenates inline values
|
|
497
|
+
- `blocksToPlainText()` flattens block content with newlines between paragraphs
|
|
498
|
+
|
|
499
|
+
Typical use cases:
|
|
500
|
+
|
|
501
|
+
- accessibility labels
|
|
502
|
+
- plain-text fallbacks
|
|
503
|
+
- analytics or logging output that should ignore inline formatting
|
|
504
|
+
|
|
505
|
+
## Stimulus model
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
interface BodyStimulus {
|
|
509
|
+
type: "body"
|
|
510
|
+
body: ContentBlock[]
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
interface ImageStimulus {
|
|
514
|
+
type: "image"
|
|
515
|
+
description: ContentInline[]
|
|
516
|
+
src: string
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
type RendererStimulus = BodyStimulus | ImageStimulus
|
|
304
520
|
```
|
|
305
521
|
|
|
306
|
-
|
|
522
|
+
A frame can also have `stimulus: null`.
|
|
307
523
|
|
|
308
524
|
## PCI system
|
|
309
525
|
|
|
310
|
-
Portable Custom Interactions
|
|
526
|
+
Portable Custom Interactions let Primer serve domain-specific input types while preserving compile-time type safety.
|
|
311
527
|
|
|
312
528
|
### Registry
|
|
313
529
|
|
|
@@ -330,31 +546,112 @@ interface PciRegistry {
|
|
|
330
546
|
### Type helpers
|
|
331
547
|
|
|
332
548
|
```ts
|
|
549
|
+
type PciUrn = `urn:primer:pci:${string}`
|
|
333
550
|
type PciId = keyof PciRegistry & string
|
|
334
|
-
type PciProps<K extends PciId>
|
|
335
|
-
type PciValue<K extends PciId>
|
|
551
|
+
type PciProps<K extends PciId> = PciRegistry[K]["props"]
|
|
552
|
+
type PciValue<K extends PciId> = PciRegistry[K]["value"]
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### PCI renderer props
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
type PciRenderProps<K extends PciId> =
|
|
559
|
+
| {
|
|
560
|
+
mode: "pending"
|
|
561
|
+
properties: PciProps<K>
|
|
562
|
+
onValueChange: (value: PciValue<K> | null) => void
|
|
563
|
+
}
|
|
564
|
+
| {
|
|
565
|
+
mode: "submitted"
|
|
566
|
+
properties: PciProps<K>
|
|
567
|
+
submission: PciValue<K>
|
|
568
|
+
review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
|
|
569
|
+
}
|
|
336
570
|
```
|
|
337
571
|
|
|
338
572
|
### Adding a PCI
|
|
339
573
|
|
|
340
|
-
1. Add entry to `PciRegistry` in `src/pci.ts`
|
|
341
|
-
2. URN format
|
|
342
|
-
3.
|
|
343
|
-
4.
|
|
574
|
+
1. Add a new entry to `PciRegistry` in `src/pci.ts`
|
|
575
|
+
2. Use the URN format `urn:primer:pci:<name>`
|
|
576
|
+
3. Add that PCI URN to `supportedPcis` when creating the client
|
|
577
|
+
4. Make sure your renderer knows how to render and collect a `PciValue` for that PCI
|
|
344
578
|
|
|
345
|
-
## Import
|
|
579
|
+
## Import guide
|
|
346
580
|
|
|
347
581
|
Everything is exported from the package root:
|
|
348
582
|
|
|
349
583
|
```ts
|
|
350
|
-
import {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
584
|
+
import {
|
|
585
|
+
ADVANCE_PATH,
|
|
586
|
+
ErrConflict,
|
|
587
|
+
ErrInvalidSubmission,
|
|
588
|
+
ErrMalformedPublishableKey,
|
|
589
|
+
ErrNetwork,
|
|
590
|
+
ErrTimeout,
|
|
591
|
+
ErrUnsupportedPci,
|
|
592
|
+
blocksToPlainText,
|
|
593
|
+
create,
|
|
594
|
+
inlinesToPlainText,
|
|
595
|
+
} from "@superbuilders/primer-tives"
|
|
596
|
+
|
|
597
|
+
import type {
|
|
598
|
+
ChoiceState,
|
|
599
|
+
Client,
|
|
600
|
+
Config,
|
|
601
|
+
ContentBlock,
|
|
602
|
+
ContentInline,
|
|
603
|
+
FeedbackState,
|
|
604
|
+
MatchState,
|
|
605
|
+
ObservationState,
|
|
606
|
+
OrderState,
|
|
607
|
+
PciId,
|
|
608
|
+
PciProps,
|
|
609
|
+
PciRenderProps,
|
|
610
|
+
PciValue,
|
|
611
|
+
PrimerLogger,
|
|
612
|
+
PrimerState,
|
|
613
|
+
RendererInteraction,
|
|
614
|
+
RendererStimulus,
|
|
615
|
+
RendererSubmission,
|
|
616
|
+
} from "@superbuilders/primer-tives"
|
|
356
617
|
```
|
|
357
618
|
|
|
358
|
-
##
|
|
619
|
+
## Behavioral notes
|
|
620
|
+
|
|
621
|
+
### States are not serializable
|
|
622
|
+
|
|
623
|
+
`PrimerState` is live in-memory state that contains closures. Every state object has a poisoned `toJSON()` that throws `ErrNotSerializable`.
|
|
624
|
+
|
|
625
|
+
Do **not**:
|
|
626
|
+
|
|
627
|
+
- `JSON.stringify(state)`
|
|
628
|
+
- persist a state object to storage
|
|
629
|
+
- send a state object over the network
|
|
630
|
+
- treat a state object as a durable snapshot
|
|
631
|
+
|
|
632
|
+
Instead, keep it in memory and render it immediately.
|
|
633
|
+
|
|
634
|
+
### Repeated actions are deduplicated narrowly
|
|
635
|
+
|
|
636
|
+
State objects memoize only true re-entry of the same in-flight action.
|
|
359
637
|
|
|
360
|
-
|
|
638
|
+
- calling `advance()` twice on the same observation state returns the same promise
|
|
639
|
+
- calling the same submit method twice with the same payload returns the same promise
|
|
640
|
+
- calling `retry()` twice on the same errored state returns the same promise
|
|
641
|
+
- submit and timeout do **not** alias to each other anymore
|
|
642
|
+
- conflicting in-flight actions surface `ErrConflict` instead of silently reusing the wrong promise
|
|
643
|
+
|
|
644
|
+
### `start()` is memoized per client instance
|
|
645
|
+
|
|
646
|
+
A `Client` instance memoizes the first `start()` call and returns the same promise on later calls. In practice, create a fresh client instance per independently managed session.
|
|
647
|
+
|
|
648
|
+
## Build and publish
|
|
649
|
+
|
|
650
|
+
The package is built as browser-targeted ESM from `src/index.ts`, emits declaration files, and publishes `dist/` plus this README.
|
|
651
|
+
|
|
652
|
+
Useful package scripts:
|
|
653
|
+
|
|
654
|
+
```sh
|
|
655
|
+
bun run build
|
|
656
|
+
bun run typecheck
|
|
657
|
+
```
|