@use-voltra/server 2.0.0-rc.4 → 2.1.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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/src/index.ts +240 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@use-voltra/server",
3
- "version": "2.0.0-rc.4",
3
+ "version": "2.1.0",
4
4
  "description": "Shared server rendering foundation for Voltra",
5
5
  "main": "build/cjs/index.js",
6
6
  "module": "build/esm/index.js",
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "files": [
18
18
  "build",
19
+ "src",
19
20
  "README.md"
20
21
  ],
21
22
  "keywords": [
package/src/index.ts ADDED
@@ -0,0 +1,240 @@
1
+ /// <reference types="node" />
2
+
3
+ import type { IncomingMessage, ServerResponse } from 'node:http'
4
+
5
+ /**
6
+ * The platform that sent the widget update request.
7
+ * Read from the `platform` query parameter.
8
+ */
9
+ export type WidgetPlatform = 'ios' | 'android'
10
+
11
+ /**
12
+ * The system color scheme reported by the native widget.
13
+ * Read from the `theme` query parameter.
14
+ */
15
+ export type WidgetTheme = 'light' | 'dark'
16
+
17
+ type NodeLikeRequest = IncomingMessage & {
18
+ url?: string
19
+ method?: string
20
+ headers: Record<string, string | string[] | undefined>
21
+ socket?: {
22
+ encrypted?: boolean
23
+ }
24
+ }
25
+
26
+ type NodeLikeResponse = ServerResponse
27
+
28
+ export type WidgetUpdateHandler = (request: Request) => Promise<Response>
29
+ export type WidgetUpdateNodeHandler = (req: NodeLikeRequest, res: NodeLikeResponse) => Promise<void>
30
+ export type WidgetUpdateExpressHandler = WidgetUpdateNodeHandler
31
+ export type WidgetRenderResult = Promise<string | null> | string | null
32
+ export type WidgetRenderer = (request: WidgetRenderRequest) => WidgetRenderResult
33
+
34
+ /**
35
+ * Request context provided to the widget render handler.
36
+ * Contains the widget ID, family, and any auth headers from the request.
37
+ */
38
+ export interface WidgetRenderRequest {
39
+ /** Parsed request URL, including all query parameters. */
40
+ url: URL
41
+ /** The widget ID requesting an update */
42
+ widgetId: string
43
+ /** The platform the request is coming from */
44
+ platform: WidgetPlatform
45
+ /** The system color scheme (`light` or `dark`). Defaults to `light` when not provided. */
46
+ theme: WidgetTheme
47
+ /** The widget family/size (iOS only: "systemSmall", "systemMedium", etc.) */
48
+ family?: string
49
+ /** The authorization token from the request (if present) */
50
+ token?: string
51
+ /** All request headers */
52
+ headers: Record<string, string | string[] | undefined>
53
+ }
54
+
55
+ /**
56
+ * Options for creating the widget update handler.
57
+ */
58
+ export interface WidgetUpdateHandlerOptions {
59
+ /**
60
+ * Render function that returns a serialized iOS widget payload for a given request.
61
+ * If not provided, iOS requests will receive a 404 response.
62
+ */
63
+ renderIos?: WidgetRenderer
64
+
65
+ /**
66
+ * Render function that returns a serialized Android widget payload for a given request.
67
+ * If not provided, Android requests will receive a 404 response.
68
+ */
69
+ renderAndroid?: WidgetRenderer
70
+
71
+ /**
72
+ * Optional validation function for auth tokens.
73
+ * Return true if the token is valid, false to reject with 401.
74
+ * If not provided, all requests are accepted.
75
+ */
76
+ validateToken?: (token: string) => Promise<boolean> | boolean
77
+ }
78
+
79
+ function isWidgetPlatform(value: string | null): value is WidgetPlatform {
80
+ return value === 'ios' || value === 'android'
81
+ }
82
+
83
+ function isWidgetTheme(value: string | null): value is WidgetTheme {
84
+ return value === 'light' || value === 'dark'
85
+ }
86
+
87
+ function jsonResponse(status: number, body: Record<string, string>): Response {
88
+ return new Response(JSON.stringify(body), {
89
+ status,
90
+ headers: {
91
+ 'Content-Type': 'application/json',
92
+ },
93
+ })
94
+ }
95
+
96
+ function normalizeHeaders(headers: Headers): Record<string, string> {
97
+ const result: Record<string, string> = {}
98
+
99
+ headers.forEach((value, key) => {
100
+ result[key] = value
101
+ })
102
+
103
+ return result
104
+ }
105
+
106
+ function getNodeRequestUrl(req: NodeLikeRequest): string {
107
+ const protocol = req.socket?.encrypted ? 'https' : 'http'
108
+ const host = req.headers.host || 'localhost'
109
+ return new URL(req.url || '/', `${protocol}://${host}`).toString()
110
+ }
111
+
112
+ function createFetchRequestFromNode(req: NodeLikeRequest): Request {
113
+ const headers = new Headers()
114
+
115
+ for (const [key, value] of Object.entries(req.headers)) {
116
+ if (Array.isArray(value)) {
117
+ for (const item of value) {
118
+ headers.append(key, item)
119
+ }
120
+ continue
121
+ }
122
+
123
+ if (typeof value === 'string') {
124
+ headers.set(key, value)
125
+ }
126
+ }
127
+
128
+ return new Request(getNodeRequestUrl(req), {
129
+ method: req.method || 'GET',
130
+ headers,
131
+ })
132
+ }
133
+
134
+ async function writeFetchResponseToNode(response: Response, res: NodeLikeResponse): Promise<void> {
135
+ const headers: Record<string, string> = {}
136
+
137
+ response.headers.forEach((value, key) => {
138
+ headers[key] = value
139
+ })
140
+
141
+ res.writeHead(response.status, headers)
142
+ res.end(await response.text())
143
+ }
144
+
145
+ /**
146
+ * Creates a Fetch API request handler for serving widget updates.
147
+ */
148
+ export function createWidgetUpdateHandler(options: WidgetUpdateHandlerOptions): WidgetUpdateHandler {
149
+ const { renderIos, renderAndroid, validateToken } = options
150
+
151
+ return async (request: Request): Promise<Response> => {
152
+ try {
153
+ const url = new URL(request.url)
154
+ const widgetId = url.searchParams.get('widgetId')
155
+ const family = url.searchParams.get('family') || undefined
156
+ const platformParam = url.searchParams.get('platform')
157
+
158
+ if (!widgetId) {
159
+ return jsonResponse(400, { error: 'Missing required query parameter: widgetId' })
160
+ }
161
+
162
+ if (!isWidgetPlatform(platformParam)) {
163
+ return jsonResponse(400, { error: 'Missing or invalid required query parameter: platform' })
164
+ }
165
+
166
+ const platform: WidgetPlatform = platformParam
167
+ const themeParam = url.searchParams.get('theme')
168
+ const theme: WidgetTheme = isWidgetTheme(themeParam) ? themeParam : 'light'
169
+
170
+ const authHeader = request.headers.get('authorization')
171
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined
172
+
173
+ if (validateToken) {
174
+ if (!token) {
175
+ return jsonResponse(401, { error: 'Authorization required' })
176
+ }
177
+
178
+ const isValid = await validateToken(token)
179
+ if (!isValid) {
180
+ return jsonResponse(401, { error: 'Invalid token' })
181
+ }
182
+ }
183
+
184
+ const renderRequest: WidgetRenderRequest = {
185
+ url,
186
+ widgetId,
187
+ platform,
188
+ theme,
189
+ family,
190
+ token,
191
+ headers: normalizeHeaders(request.headers),
192
+ }
193
+
194
+ const renderer = platform === 'android' ? renderAndroid : renderIos
195
+ if (!renderer) {
196
+ const platformName = platform === 'android' ? 'Android' : 'iOS'
197
+ return jsonResponse(404, { error: `No ${platformName} render handler configured for widget: ${widgetId}` })
198
+ }
199
+
200
+ const jsonPayload = await renderer(renderRequest)
201
+ if (!jsonPayload) {
202
+ const errorMessage =
203
+ platform === 'android' ? `No content for Android widget: ${widgetId}` : `No content for widget: ${widgetId}`
204
+
205
+ return jsonResponse(404, { error: errorMessage })
206
+ }
207
+
208
+ return new Response(jsonPayload, {
209
+ status: 200,
210
+ headers: {
211
+ 'Content-Type': 'application/json',
212
+ 'Cache-Control': 'no-cache',
213
+ },
214
+ })
215
+ } catch (error) {
216
+ console.error('[Voltra] Widget update handler error:', error)
217
+ return jsonResponse(500, { error: 'Internal server error' })
218
+ }
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Creates a Node.js HTTP request handler for serving widget updates.
224
+ */
225
+ export function createWidgetUpdateNodeHandler(options: WidgetUpdateHandlerOptions): WidgetUpdateNodeHandler {
226
+ const handler = createWidgetUpdateHandler(options)
227
+
228
+ return async (req: NodeLikeRequest, res: NodeLikeResponse): Promise<void> => {
229
+ const request = createFetchRequestFromNode(req)
230
+ const response = await handler(request)
231
+ await writeFetchResponseToNode(response, res)
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Creates an Express-compatible request handler for serving widget updates.
237
+ */
238
+ export function createWidgetUpdateExpressHandler(options: WidgetUpdateHandlerOptions): WidgetUpdateExpressHandler {
239
+ return createWidgetUpdateNodeHandler(options)
240
+ }