@rudra-js/core 0.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +189 -0
  3. package/dist/component-generator.d.ts +84 -0
  4. package/dist/component-generator.d.ts.map +1 -0
  5. package/dist/component-generator.js +331 -0
  6. package/dist/component-generator.js.map +1 -0
  7. package/dist/component-spec.d.ts +426 -0
  8. package/dist/component-spec.d.ts.map +1 -0
  9. package/dist/component-spec.js +170 -0
  10. package/dist/component-spec.js.map +1 -0
  11. package/dist/fallback-component.d.ts +11 -0
  12. package/dist/fallback-component.d.ts.map +1 -0
  13. package/dist/fallback-component.js +69 -0
  14. package/dist/fallback-component.js.map +1 -0
  15. package/dist/fit-to-shopper.d.ts +4 -0
  16. package/dist/fit-to-shopper.d.ts.map +1 -0
  17. package/dist/fit-to-shopper.js +36 -0
  18. package/dist/fit-to-shopper.js.map +1 -0
  19. package/dist/index.d.ts +19 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +19 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/model-prompt.d.ts +29 -0
  24. package/dist/model-prompt.d.ts.map +1 -0
  25. package/dist/model-prompt.js +227 -0
  26. package/dist/model-prompt.js.map +1 -0
  27. package/dist/product-selection.d.ts +33 -0
  28. package/dist/product-selection.d.ts.map +1 -0
  29. package/dist/product-selection.js +102 -0
  30. package/dist/product-selection.js.map +1 -0
  31. package/dist/provider.d.ts +80 -0
  32. package/dist/provider.d.ts.map +1 -0
  33. package/dist/provider.js +23 -0
  34. package/dist/provider.js.map +1 -0
  35. package/dist/reconciliation.d.ts +49 -0
  36. package/dist/reconciliation.d.ts.map +1 -0
  37. package/dist/reconciliation.js +564 -0
  38. package/dist/reconciliation.js.map +1 -0
  39. package/dist/signal-digest.d.ts +66 -0
  40. package/dist/signal-digest.d.ts.map +1 -0
  41. package/dist/signal-digest.js +224 -0
  42. package/dist/signal-digest.js.map +1 -0
  43. package/dist/spec-cache.d.ts +88 -0
  44. package/dist/spec-cache.d.ts.map +1 -0
  45. package/dist/spec-cache.js +152 -0
  46. package/dist/spec-cache.js.map +1 -0
  47. package/dist/tracking-input.d.ts +258 -0
  48. package/dist/tracking-input.d.ts.map +1 -0
  49. package/dist/tracking-input.js +241 -0
  50. package/dist/tracking-input.js.map +1 -0
  51. package/package.json +60 -0
  52. package/src/component-generator.ts +521 -0
  53. package/src/component-spec.ts +243 -0
  54. package/src/fallback-component.ts +77 -0
  55. package/src/fit-to-shopper.ts +45 -0
  56. package/src/index.ts +102 -0
  57. package/src/model-prompt.ts +258 -0
  58. package/src/product-selection.ts +153 -0
  59. package/src/provider.ts +98 -0
  60. package/src/reconciliation.ts +675 -0
  61. package/src/signal-digest.ts +335 -0
  62. package/src/spec-cache.ts +223 -0
  63. package/src/tracking-input.ts +300 -0
@@ -0,0 +1,300 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * The tracking-input contract — the boundary between the host application and
5
+ * rudra-js.
6
+ *
7
+ * rudra-js does not collect, store, or aggregate anything. The host owns its
8
+ * tracking pipeline (an event stream, a CDP, a warehouse) and hands the
9
+ * framework one JSON object per render. This module is that contract: one
10
+ * schema, validated at the edge, so a malformed payload fails loudly here
11
+ * rather than quietly producing a bad prompt several layers later.
12
+ *
13
+ * Every fixed-shape object below is a `strictObject`, so an unrecognised field
14
+ * is an error rather than being dropped. That matters more than it looks: with
15
+ * a lenient object, a host that misspells `recentSearches` gets a shopper who
16
+ * silently looks like a first-time visitor, and nothing anywhere reports it.
17
+ * The only dynamic shape is `interaction.meta`, which is a record by design.
18
+ */
19
+
20
+ /**
21
+ * Every free-text field and every array is length-capped.
22
+ *
23
+ * These caps are not cosmetic. Host-supplied strings end up inside the prompt
24
+ * we send to a language model, and a model is billed per token — so an
25
+ * unbounded string is an unbounded bill, and an unbounded array of candidates
26
+ * is the same problem multiplied.
27
+ *
28
+ * What this buys is that no single field is unbounded. It is deliberately not
29
+ * an aggregate budget: the caps multiply out to far more than any context
30
+ * window, because rejecting a large-but-legitimate payload is the wrong
31
+ * response to one. Fitting a payload into a prompt is `digest`'s job, and it
32
+ * trims rather than throws.
33
+ */
34
+ export const FIELD_LIMITS = {
35
+ identifier: 128,
36
+ shortText: 200,
37
+ searchQuery: 200,
38
+ tag: 64,
39
+ tagsPerProduct: 20,
40
+ metaEntries: 50,
41
+ signalsPerCategory: 500,
42
+ candidates: 200,
43
+ productsPerBundle: 5,
44
+ bundles: 20,
45
+ } as const;
46
+
47
+ /** Assigning this as an object key mutates the prototype instead of the object. */
48
+ const RESERVED_META_KEY = '__proto__';
49
+
50
+ /** Upper bound on any timestamp: 2100-01-01. */
51
+ const MAX_EPOCH_MS = Date.UTC(2100, 0, 1);
52
+
53
+ const identifier = () => z.string().min(1).max(FIELD_LIMITS.identifier);
54
+ const optionalIdentifier = () => z.string().min(1).max(FIELD_LIMITS.identifier).optional();
55
+
56
+ /**
57
+ * Epoch milliseconds, used only for recency ordering. Bounded because a
58
+ * negative or year-3000 timestamp does not fail anywhere downstream — it just
59
+ * sorts to one end and silently reorders the shopper's history.
60
+ */
61
+ const epochMs = () => z.number().int().min(0).max(MAX_EPOCH_MS).optional();
62
+
63
+ /**
64
+ * An absolute http(s) URL, or a root-relative path such as `/images/tr-102.png`.
65
+ * Anything else — a bare word, a `javascript:` URI, a protocol-relative `//host`
66
+ * — is rejected before it can reach an `<img src>`.
67
+ */
68
+ const imageReference = () =>
69
+ z
70
+ .string()
71
+ .max(FIELD_LIMITS.shortText)
72
+ .refine(
73
+ (value) =>
74
+ value.startsWith('/') && !value.startsWith('//')
75
+ ? true
76
+ : z.url({ protocol: /^https?$/ }).safeParse(value).success,
77
+ { message: 'expected an http(s) URL or a root-relative path' },
78
+ );
79
+
80
+ /** A product the generated component is permitted to place. */
81
+ export const productSchema = z.strictObject({
82
+ sku: identifier(),
83
+ title: z.string().min(1).max(FIELD_LIMITS.shortText),
84
+ category: identifier(),
85
+ price: z.number().nonnegative(),
86
+ currency: z
87
+ .string()
88
+ .regex(/^[A-Z]{3}$/, 'expected a three-letter ISO 4217 code')
89
+ .default('USD'),
90
+ // This lands in an `<img src>` the host did not write, so the scheme matters:
91
+ // a bare capped string would accept '' and 'javascript:'. Root-relative paths
92
+ // are allowed because most catalogs store images that way, and they carry no
93
+ // scheme to abuse.
94
+ imageUrl: imageReference().optional(),
95
+ rating: z.number().min(0).max(5).optional(),
96
+ isInStock: z.boolean().default(true),
97
+ tags: z
98
+ .array(z.string().min(1).max(FIELD_LIMITS.tag))
99
+ .max(FIELD_LIMITS.tagsPerProduct)
100
+ .default([]),
101
+ });
102
+ export type Product = z.infer<typeof productSchema>;
103
+
104
+ /** Base shape for any signal that points at a single SKU. */
105
+ export const skuSignalSchema = z.strictObject({
106
+ sku: identifier(),
107
+ category: optionalIdentifier(),
108
+ at: epochMs(),
109
+ /** Caller-supplied strength, 0..1. Defaults to 1 when absent. */
110
+ weight: z.number().min(0).max(1).optional(),
111
+ });
112
+ export type SkuSignal = z.infer<typeof skuSignalSchema>;
113
+
114
+ export const viewSignalSchema = skuSignalSchema.extend({
115
+ views: z.number().int().positive().default(1),
116
+ dwellMs: z.number().nonnegative().optional(),
117
+ });
118
+ export type ViewSignal = z.infer<typeof viewSignalSchema>;
119
+
120
+ export const purchaseSignalSchema = skuSignalSchema.extend({
121
+ quantity: z.number().int().positive().default(1),
122
+ price: z.number().nonnegative().optional(),
123
+ });
124
+ export type PurchaseSignal = z.infer<typeof purchaseSignalSchema>;
125
+
126
+ /**
127
+ * `meta` is the one dynamic shape in the contract, so its key is checked before
128
+ * the record is parsed rather than after. Zod builds its result by assigning
129
+ * each key, and assigning `__proto__` sets the prototype instead of adding a
130
+ * key — so the entry would vanish from the parsed output with no error, which
131
+ * is the silent drop this module exists to prevent.
132
+ *
133
+ * `__proto__` is the only key that behaves this way. `constructor` and
134
+ * `prototype` are ordinary own properties: assigning either shadows it on that
135
+ * one object and leaves the prototype alone, so both survive a parse intact and
136
+ * are accepted. `meta` is a host-defined vocabulary, and a shop with a facet,
137
+ * filter, or CMS field by either name should get a component, not a throw.
138
+ */
139
+ const metaKeysAreSafe = z.custom<Record<string, string | number | boolean>>(
140
+ (value) =>
141
+ typeof value === 'object' && value !== null && !Object.hasOwn(value, RESERVED_META_KEY),
142
+ { message: `meta may not use the reserved key ${RESERVED_META_KEY}` },
143
+ );
144
+
145
+ // `z.record` bounds key and value shape but not how many entries a record may
146
+ // carry, so the count is checked separately.
147
+ const metaSchema = metaKeysAreSafe.pipe(
148
+ z
149
+ .record(
150
+ z.string().min(1).max(FIELD_LIMITS.identifier),
151
+ z.union([z.string().max(FIELD_LIMITS.shortText), z.number(), z.boolean()]),
152
+ )
153
+ .refine((entries) => Object.keys(entries).length <= FIELD_LIMITS.metaEntries, {
154
+ message: `meta may carry at most ${FIELD_LIMITS.metaEntries} entries`,
155
+ }),
156
+ );
157
+
158
+ /**
159
+ * Catch-all for everything else the shopper did. `type` is an open vocabulary
160
+ * on purpose — 'scroll_depth', 'wishlist', 'filter_applied', whatever the host
161
+ * already emits — so hosts do not have to map their events onto ours.
162
+ */
163
+ export const interactionSchema = z.strictObject({
164
+ type: identifier(),
165
+ sku: optionalIdentifier(),
166
+ category: optionalIdentifier(),
167
+ at: epochMs(),
168
+ value: z.union([z.string().max(FIELD_LIMITS.shortText), z.number(), z.boolean()]).optional(),
169
+ meta: metaSchema.optional(),
170
+ });
171
+ export type Interaction = z.infer<typeof interactionSchema>;
172
+
173
+ /** Where on the site this component is being rendered. */
174
+ export const renderContextSchema = z.strictObject({
175
+ /** 'pdp', 'home', 'cart', 'search', or any host-defined surface. */
176
+ surface: identifier(),
177
+ /** Named placement, e.g. 'below-fold-recommendations'. */
178
+ slot: z.string().min(1).max(FIELD_LIMITS.identifier).default('recommendations'),
179
+ currentSku: optionalIdentifier(),
180
+ currentCategory: optionalIdentifier(),
181
+ searchQuery: z.string().max(FIELD_LIMITS.searchQuery).optional(),
182
+ locale: z.string().min(2).max(35).default('en-US'),
183
+ /** Upper bound on products across the whole generated component. */
184
+ maxItems: z.number().int().min(1).max(12).default(4),
185
+ });
186
+ export type RenderContext = z.infer<typeof renderContextSchema>;
187
+
188
+ const signalArray = <Schema extends z.ZodType>(schema: Schema) =>
189
+ z.array(schema).max(FIELD_LIMITS.signalsPerCategory).default([]);
190
+
191
+ export const trackingSignalsSchema = z.strictObject({
192
+ likes: signalArray(skuSignalSchema),
193
+ dislikes: signalArray(skuSignalSchema),
194
+ mostViewed: signalArray(viewSignalSchema),
195
+ lastPurchased: signalArray(purchaseSignalSchema),
196
+ cart: signalArray(skuSignalSchema),
197
+ recentSearches: signalArray(z.string().min(1).max(FIELD_LIMITS.searchQuery)),
198
+ interactions: signalArray(interactionSchema),
199
+ });
200
+ export type TrackingSignals = z.infer<typeof trackingSignalsSchema>;
201
+
202
+ /** A set the shop sells together. The model never picks or invents one. */
203
+ export const bundleSchema = z.strictObject({
204
+ id: identifier(),
205
+ skus: z
206
+ .array(identifier())
207
+ .min(2)
208
+ .max(FIELD_LIMITS.productsPerBundle)
209
+ .refine((skus) => new Set(skus).size === skus.length, {
210
+ message: 'a bundle must not list the same product twice',
211
+ }),
212
+ // The shop's price for the set — not derived from the parts, so the set says
213
+ // which money it is in rather than borrowing it from a member.
214
+ price: z.number().nonnegative(),
215
+ currency: z
216
+ .string()
217
+ .regex(/^[A-Z]{3}$/, 'expected a three-letter ISO 4217 code')
218
+ .default('USD'),
219
+ label: z.string().min(1).max(FIELD_LIMITS.shortText).optional(),
220
+ });
221
+ export type Bundle = z.infer<typeof bundleSchema>;
222
+
223
+ export const trackingInputSchema = z
224
+ .strictObject({
225
+ schemaVersion: z.literal('1').default('1'),
226
+ user: z.strictObject({
227
+ id: identifier(),
228
+ segment: optionalIdentifier(),
229
+ isReturning: z.boolean().optional(),
230
+ }),
231
+ context: renderContextSchema,
232
+ // A payload with no `signals` block at all is the cold-start case, not an
233
+ // error. Every category defaults to empty, so a first-time visitor needs no
234
+ // special handling from the host.
235
+ signals: trackingSignalsSchema.prefault({}),
236
+ /**
237
+ * The only products the generated component may place. Merchandising rules
238
+ * belong here: whatever the host leaves out cannot be recommended, which is
239
+ * what makes it impossible to surface a product that does not exist or is not
240
+ * merchandised for this shopper.
241
+ *
242
+ * SKUs must be unique — a duplicate is a host bug that spends prompt budget
243
+ * twice and invites the same product in two slots.
244
+ */
245
+ candidates: z
246
+ .array(productSchema)
247
+ .min(1)
248
+ .max(FIELD_LIMITS.candidates)
249
+ .refine(
250
+ (products) => new Set(products.map((product) => product.sku)).size === products.length,
251
+ {
252
+ message: 'candidates must have unique SKUs',
253
+ },
254
+ ),
255
+ // Ids must be unique — the renderer looks a bundle up by id alone, so a
256
+ // duplicate would let it draw the wrong set at the wrong price.
257
+ bundles: z
258
+ .array(bundleSchema)
259
+ .max(FIELD_LIMITS.bundles)
260
+ .refine((bundles) => new Set(bundles.map((bundle) => bundle.id)).size === bundles.length, {
261
+ message: 'bundles must have unique ids',
262
+ })
263
+ .default([]),
264
+ })
265
+ .refine(
266
+ (input) => {
267
+ const candidateSkus = new Set(input.candidates.map((product) => product.sku));
268
+ for (const bundle of input.bundles) {
269
+ for (const sku of bundle.skus) {
270
+ if (!candidateSkus.has(sku)) return false;
271
+ }
272
+ }
273
+ return true;
274
+ },
275
+ { message: 'every product in a bundle must also be a candidate' },
276
+ );
277
+
278
+ export type TrackingInput = z.infer<typeof trackingInputSchema>;
279
+
280
+ /** The shape a caller passes in, before defaults are applied. */
281
+ export type TrackingInputDraft = z.input<typeof trackingInputSchema>;
282
+
283
+ /**
284
+ * The result of a non-throwing parse. Exported so a consumer can type a
285
+ * validation failure without depending on zod directly.
286
+ */
287
+ export type TrackingInputResult = z.ZodSafeParseResult<TrackingInput>;
288
+
289
+ /**
290
+ * Validates a payload, throwing a `ZodError` if it does not satisfy the
291
+ * contract. An invalid payload is a caller bug, and it should be loud.
292
+ */
293
+ export function parseTrackingInput(value: unknown): TrackingInput {
294
+ return trackingInputSchema.parse(value);
295
+ }
296
+
297
+ /** Non-throwing variant, for callers that want to inspect the failure. */
298
+ export function safeParseTrackingInput(value: unknown): TrackingInputResult {
299
+ return trackingInputSchema.safeParse(value);
300
+ }