@edpire/sdk 0.6.10 → 0.6.12

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.
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Edpire SDK — Vite + Express example</title>
6
+ <title>Edpire Vite and Express example</title>
7
7
  <style>
8
8
  * { box-sizing: border-box; }
9
9
  body { margin: 0; }
@@ -5,19 +5,17 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "concurrently -k -n SERVER,VITE -c cyan,magenta \"tsx server.ts\" \"vite\"",
8
- "build": "vite build && tsc -p tsconfig.server.json",
8
+ "build": "vite build && tsc --noEmit -p tsconfig.server.json",
9
9
  "preview": "tsx server.ts",
10
- "type-check": "tsc --noEmit"
10
+ "type-check": "tsc --noEmit && tsc --noEmit -p tsconfig.server.json"
11
11
  },
12
12
  "dependencies": {
13
- "@edpire/sdk": "^0.6.0",
14
- "cors": "^2.8.5",
13
+ "@edpire/sdk": "^0.6.10",
15
14
  "express": "^4.18.2",
16
15
  "react": "^19.0.0",
17
16
  "react-dom": "^19.0.0"
18
17
  },
19
18
  "devDependencies": {
20
- "@types/cors": "^2.8.17",
21
19
  "@types/express": "^4.17.21",
22
20
  "@types/node": "^20.0.0",
23
21
  "@types/react": "^19.0.0",
@@ -27,6 +25,6 @@
27
25
  "dotenv": "^16.3.1",
28
26
  "tsx": "^4.7.0",
29
27
  "typescript": "^5.0.0",
30
- "vite": "^5.0.0"
28
+ "vite": "^6.0.0"
31
29
  }
32
30
  }
@@ -1,96 +1,102 @@
1
- /**
2
- * Express server — serves the token endpoint + static files in production.
3
- *
4
- * In development: Vite dev server runs separately (port 5173) and proxies
5
- * /api requests here (port 3001). Run `npm run dev` to start both.
6
- *
7
- * In production: this server serves the Vite build output (dist/) and the
8
- * /api/edpire/token endpoint. Run `npm run build` then `npm run preview`.
9
- */
10
- import "dotenv/config"
11
- import express from "express"
12
- import cors from "cors"
13
- import path from "node:path"
14
- import { fileURLToPath } from "node:url"
15
- import { createEdpireTokenHandler, toNodeHandler } from "@edpire/sdk/client"
16
-
17
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
18
- const PORT = Number(process.env.PORT ?? 3001)
19
- const isDev = process.env.NODE_ENV !== "production"
20
-
21
- const app = express()
22
-
23
- // In dev, the Vite dev server (port 5173) calls this server for /api requests.
24
- // Allow cross-origin requests from localhost in development.
25
- if (isDev) {
26
- app.use(cors({ origin: /localhost/ }))
27
- }
28
-
29
- // ── Token endpoint ─────────────────────────────────────────────────────────────
30
- //
31
- // The browser POSTs { assessmentId } here and receives { token }.
32
- // The learner ID is resolved from the server session — never from the body.
33
- //
34
- // toNodeHandler() adapts the Web-standard createEdpireTokenHandler() response
35
- // to Express's (req, res) signature.
36
- const edpireTokenHandler = toNodeHandler(
37
- createEdpireTokenHandler({
38
- apiKey: process.env.EDPIRE_API_KEY!,
39
-
40
- /**
41
- * Resolve the learner ID from YOUR session.
42
- *
43
- * This example reads a `userId` cookie set by your login flow.
44
- * Replace with your real session middleware in production:
45
- *
46
- * IMPORTANT: `req` is a Web-standard Request, not an Express request.
47
- * `req.session` and `req.cookies` do NOT exist here — read the raw header
48
- * and do your own lookup, as below.
49
- *
50
- * @example session store
51
- * resolveLearner: async (req) => {
52
- * const sid = parseCookie(req.headers.get("cookie"), "sid")
53
- * return sid ? (await sessionStore.get(sid))?.userId ?? null : null
54
- * }
55
- *
56
- * @example JWT
57
- * resolveLearner: (req) => {
58
- * const token = parseCookie(req.headers.get("cookie"), "token")
59
- * return token ? jwt.verify(token, JWT_SECRET).userId ?? null : null
60
- * }
61
- *
62
- * Returning null → 401 (no token minted).
63
- */
64
- resolveLearner: (req) => {
65
- // In development, use a fixed test ID so the player works out of the box.
66
- // Replace this with your real session logic before going to production —
67
- // always derived from the cookie header, never from the request body.
68
- if (isDev) return "dev-user-001"
69
-
70
- const cookie = req.headers.get("cookie") ?? ""
71
- const match = /(?:^|;\s*)userId=([^;]+)/.exec(cookie)
72
- return match ? decodeURIComponent(match[1]) : null
73
- },
74
- }),
75
- )
76
-
77
- app.post("/api/edpire/token", edpireTokenHandler)
78
-
79
- // ── Static files (production only) ────────────────────────────────────────────
80
- if (!isDev) {
81
- const distPath = path.join(__dirname, "dist")
82
- app.use(express.static(distPath))
83
- // SPA fallback — send index.html for all non-API routes
84
- app.get(/^(?!\/api).*$/, (_req, res) => {
85
- res.sendFile(path.join(distPath, "index.html"))
86
- })
87
- }
88
-
89
- app.listen(PORT, () => {
90
- if (isDev) {
91
- console.log(`[server] Token endpoint ready at http://localhost:${PORT}/api/edpire/token`)
92
- console.log(`[server] Start Vite dev server with: npm run dev`)
93
- } else {
94
- console.log(`[server] Production server at http://localhost:${PORT}`)
95
- }
96
- })
1
+ /**
2
+ * The Express side: two routes, plus static files in production.
3
+ *
4
+ * GET /api/edpire/assessments what is published, so the page can list it
5
+ * POST /api/edpire/token a short-lived token for one learner
6
+ *
7
+ * In development Vite serves the page on 5173 and proxies /api here, so the
8
+ * browser only ever talks to one origin and no CORS is involved.
9
+ *
10
+ * npm run dev both, together
11
+ * npm run build && npm run preview
12
+ */
13
+ import "dotenv/config"
14
+ import express from "express"
15
+ import path from "node:path"
16
+ import { fileURLToPath } from "node:url"
17
+ import { createEdpireTokenHandler, toNodeHandler, EdpireClient } from "@edpire/sdk/client"
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
20
+ const PORT = Number(process.env.PORT ?? 3001)
21
+ const isDev = process.env.NODE_ENV !== "production"
22
+
23
+ const app = express()
24
+ const edpire = new EdpireClient({ apiKey: process.env.EDPIRE_API_KEY! })
25
+
26
+ /**
27
+ * The assessments this learner can play.
28
+ *
29
+ * Your API key is used here, on the server. The browser receives only titles
30
+ * and IDs, neither of which is secret. `status=published` matters: a draft
31
+ * cannot be played.
32
+ */
33
+ app.get("/api/edpire/assessments", async (_req, res) => {
34
+ try {
35
+ const { items } = await edpire.getAssessments({ status: "published", limit: 5 })
36
+ res.json({
37
+ assessments: items.map((a) => ({
38
+ id: a.id,
39
+ title: a.title,
40
+ exerciseCount: a.exercise_count ?? null,
41
+ })),
42
+ })
43
+ } catch (err) {
44
+ // "fetch failed" on its own tells the reader nothing, so name the two
45
+ // things it is almost always caused by.
46
+ res.status(502).json({
47
+ error:
48
+ `Could not list assessments: ${err instanceof Error ? err.message : err}. ` +
49
+ "Check EDPIRE_API_KEY, and that this machine can reach edpire.com.",
50
+ })
51
+ }
52
+ })
53
+
54
+ /**
55
+ * toNodeHandler adapts the Web-standard handler to Express's (req, res).
56
+ * Without it the request hangs until the browser times out.
57
+ *
58
+ * Mount it BEFORE any body parser. The handler reads the request stream
59
+ * itself, and a parser that already drained it makes this hang too.
60
+ */
61
+ app.post(
62
+ "/api/edpire/token",
63
+ toNodeHandler(
64
+ createEdpireTokenHandler({
65
+ apiKey: process.env.EDPIRE_API_KEY!,
66
+
67
+ /**
68
+ * ─────────────────────────────────────────────────────────────────────
69
+ * REPLACE THIS. It decides whose results get recorded.
70
+ * ─────────────────────────────────────────────────────────────────────
71
+ *
72
+ * `req` is a Web-standard Request rebuilt from the Node request, so
73
+ * Express extras like `req.session` and `req.cookies` do NOT exist here.
74
+ * Read the raw header and do your own lookup:
75
+ *
76
+ * const sid = parseCookie(req.headers.get("cookie"), "sid")
77
+ * return sid ? (await sessionStore.get(sid))?.userId ?? null : null
78
+ *
79
+ * Returning null rejects the request with 401 and mints nothing.
80
+ *
81
+ * Never read the learner from the request body. If the browser can name
82
+ * the learner, any learner can submit results as any other.
83
+ */
84
+ resolveLearner: async () => "demo-learner-001",
85
+ }),
86
+ ),
87
+ )
88
+
89
+ // Production only: serve the built page, with an SPA fallback for client routes.
90
+ if (!isDev) {
91
+ const dist = path.join(__dirname, "dist")
92
+ app.use(express.static(dist))
93
+ app.get(/^(?!\/api).*$/, (_req, res) => res.sendFile(path.join(dist, "index.html")))
94
+ }
95
+
96
+ app.listen(PORT, () => {
97
+ console.log(
98
+ isDev
99
+ ? `[server] ready on :${PORT}. Vite serves the page on :5173 and proxies /api here.`
100
+ : `[server] ready on http://localhost:${PORT}`,
101
+ )
102
+ })
@@ -1,56 +1,128 @@
1
- /**
2
- * Edpire SDK — Vite + Express example
3
- *
4
- * How it works:
5
- * 1. App.tsx renders <EdpireAssessmentPlayer> which POSTs { assessmentId }
6
- * to /api/edpire/token (proxied to the Express server in dev).
7
- * 2. The Express server (server.ts) resolves the learner from the session,
8
- * mints a token via EdpireClient, and returns { token }.
9
- * 3. The player fetches assessment content from Edpire using the token and
10
- * renders the full UI.
11
- *
12
- * The secret EDPIRE_API_KEY never touches the browser.
13
- */
14
- import { EdpireAssessmentPlayer } from "@edpire/sdk/react"
15
-
16
- // VITE_ASSESSMENT_ID is safe to expose in the browser (it's a public ID, not a secret).
17
- const ASSESSMENT_ID = import.meta.env.VITE_ASSESSMENT_ID as string | undefined
18
-
19
- export default function App() {
20
- if (!ASSESSMENT_ID) {
21
- return (
22
- <div style={{ padding: 32, fontFamily: "sans-serif" }}>
23
- <strong>Set VITE_ASSESSMENT_ID in .env</strong>
24
- <p>Copy .env.example → .env and fill in your assessment ID.</p>
25
- <p>
26
- <em>Note: EDPIRE_API_KEY must NOT have the VITE_ prefix — that would leak it to the
27
- browser. Only the assessment ID is prefixed.</em>
28
- </p>
29
- </div>
30
- )
31
- }
32
-
33
- return (
34
- <EdpireAssessmentPlayer
35
- tokenEndpoint="/api/edpire/token"
36
- assessmentId={ASSESSMENT_ID}
37
- onComplete={(result) => {
38
- console.log("Assessment complete!", {
39
- score: result.score,
40
- max: result.max_score,
41
- passed: result.passed,
42
- })
43
- // TODO: redirect to results page, update progress, etc.
44
- }}
45
- onError={(err) => {
46
- console.error("Embed error:", err.code, err.message)
47
- // Common errors:
48
- // ORIGIN_NOT_ALLOWED -> add localhost to *Allowed Embed Origins* (Integrations -> Security)
49
- // TOKEN_INVALID → check EDPIRE_API_KEY in .env
50
- // ASSESSMENT_NOT_FOUND → check VITE_ASSESSMENT_ID in .env
51
- }}
52
- // The player fills its container. Set a height here or via CSS.
53
- style={{ width: "100%", height: "100vh" }}
54
- />
55
- )
56
- }
1
+ /**
2
+ * The whole integration, in two steps.
3
+ *
4
+ * 1. Ask your own server what is published, and show the titles.
5
+ * 2. Render <EdpireAssessmentPlayer> for whichever the learner picks. It fetches
6
+ * a token from /api/edpire/token, loads the assessment, grades the
7
+ * submission and shows per-question feedback.
8
+ *
9
+ * Your API key never reaches the browser.
10
+ */
11
+ import { useEffect, useState } from "react"
12
+ import { EdpireAssessmentPlayer } from "@edpire/sdk/react"
13
+
14
+ type Assessment = { id: string; title: string; exerciseCount: number | null }
15
+
16
+ export default function App() {
17
+ const [assessments, setAssessments] = useState<Assessment[] | null>(null)
18
+ const [error, setError] = useState<string | null>(null)
19
+ const [playing, setPlaying] = useState<string | null>(null)
20
+
21
+ function load() {
22
+ setError(null)
23
+ setAssessments(null)
24
+ fetch("/api/edpire/assessments")
25
+ .then((r) => r.json())
26
+ .then((data) => (data.error ? setError(data.error) : setAssessments(data.assessments)))
27
+ .catch((e) => setError(String(e)))
28
+ }
29
+
30
+ useEffect(load, [])
31
+
32
+ if (playing) {
33
+ return (
34
+ <EdpireAssessmentPlayer
35
+ tokenEndpoint="/api/edpire/token"
36
+ assessmentId={playing}
37
+ onComplete={(result) => {
38
+ // A UI hint. Record the authoritative result server-side from the
39
+ // submission.graded webhook.
40
+ console.log("complete", result.score, "/", result.max_score)
41
+ }}
42
+ onError={(err) => setError(`${err.code}: ${err.message}`)}
43
+ // The player fills its container, so the container needs a real height.
44
+ // Without one it collapses and the player renders invisibly.
45
+ style={{ width: "100%", height: "100vh" }}
46
+ />
47
+ )
48
+ }
49
+
50
+ return (
51
+ <main style={styles.page}>
52
+ <h1 style={styles.title}>Edpire example</h1>
53
+
54
+ {error ? (
55
+ <>
56
+ <p style={styles.error}>{error}</p>
57
+ <button onClick={load} style={styles.retry}>Try again</button>
58
+ </>
59
+ ) : assessments === null ? (
60
+ <p style={styles.muted}>Loading...</p>
61
+ ) : assessments.length === 0 ? (
62
+ <Empty />
63
+ ) : (
64
+ <>
65
+ <p style={styles.muted}>Published in your organisation. Choose one to play it.</p>
66
+ {assessments.map((a) => (
67
+ <button key={a.id} onClick={() => setPlaying(a.id)} style={styles.card}>
68
+ <span style={styles.cardTitle}>{a.title}</span>
69
+ {a.exerciseCount !== null && (
70
+ <span style={styles.cardMeta}>{a.exerciseCount} exercise(s)</span>
71
+ )}
72
+ </button>
73
+ ))}
74
+ </>
75
+ )}
76
+ </main>
77
+ )
78
+ }
79
+
80
+ /** A new organisation has nothing published, and the fix is in Edpire. */
81
+ function Empty() {
82
+ return (
83
+ <div style={styles.empty}>
84
+ <p>
85
+ Nothing is published in this organisation yet, so there is nothing to open. Create an
86
+ assessment in Edpire, publish it, then reload. Drafts do not appear here.
87
+ </p>
88
+ <a href="https://docs.edpire.com/quickstart" style={styles.link}>
89
+ docs.edpire.com/quickstart
90
+ </a>
91
+ </div>
92
+ )
93
+ }
94
+
95
+ const styles: Record<string, React.CSSProperties> = {
96
+ page: { maxWidth: 640, margin: "0 auto", padding: 32, fontFamily: "system-ui, sans-serif" },
97
+ title: { fontSize: 24, marginBottom: 4 },
98
+ muted: { color: "#565b66", fontSize: 14 },
99
+ error: { color: "#d23b2b", fontSize: 14, whiteSpace: "pre-wrap" },
100
+ card: {
101
+ display: "flex",
102
+ flexDirection: "column",
103
+ alignItems: "flex-start",
104
+ gap: 4,
105
+ width: "100%",
106
+ padding: 16,
107
+ marginTop: 10,
108
+ border: "1px solid #e7e5e0",
109
+ borderRadius: 12,
110
+ background: "#fff",
111
+ cursor: "pointer",
112
+ textAlign: "left",
113
+ font: "inherit",
114
+ },
115
+ cardTitle: { fontSize: 15, fontWeight: 600 },
116
+ cardMeta: { fontSize: 12, color: "#8d93a0" },
117
+ empty: { marginTop: 12, color: "#565b66", fontSize: 14, lineHeight: 1.6 },
118
+ retry: {
119
+ marginTop: 12,
120
+ padding: "8px 18px",
121
+ border: "1px solid #e7e5e0",
122
+ borderRadius: 999,
123
+ background: "#fff",
124
+ cursor: "pointer",
125
+ font: "inherit",
126
+ },
127
+ link: { color: "#d23b2b" },
128
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
+ // tsconfig.json only includes src/, so server.ts would otherwise never be
3
+ // type-checked. The server is run by tsx, which strips types without
4
+ // checking them, so nothing else would catch a mistake in it.
2
5
  "extends": "./tsconfig.json",
3
6
  "compilerOptions": {
4
- "noEmit": false,
5
- "outDir": "dist-server",
6
- "module": "ESNext",
7
- "moduleResolution": "Bundler",
8
- "target": "node18",
9
- "allowImportingTsExtensions": false
7
+ "lib": ["ES2022"],
8
+ "target": "ES2022",
9
+ "types": ["node"]
10
10
  },
11
11
  "include": ["server.ts"]
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edpire/sdk",
3
- "version": "0.6.10",
3
+ "version": "0.6.12",
4
4
  "description": "Embeddable JS SDK for Edpire assessments",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://docs.edpire.com/developer/sdk/overview",
@@ -1,9 +0,0 @@
1
- /* Reset body margin so the player fills the viewport without gaps. */
2
- * {
3
- box-sizing: border-box;
4
- }
5
-
6
- body {
7
- margin: 0;
8
- padding: 0;
9
- }
File without changes