@gravito/ion 3.0.1 → 3.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.
package/dist/index.cjs CHANGED
@@ -20,133 +20,364 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ InertiaConfigError: () => InertiaConfigError,
24
+ InertiaDataError: () => InertiaDataError,
25
+ InertiaError: () => InertiaError,
23
26
  InertiaService: () => InertiaService,
27
+ InertiaTemplateError: () => InertiaTemplateError,
24
28
  OrbitIon: () => OrbitIon,
25
29
  default: () => index_default
26
30
  });
27
31
  module.exports = __toCommonJS(index_exports);
28
32
 
33
+ // src/errors.ts
34
+ var InertiaError = class extends Error {
35
+ constructor(code, httpStatus = 500, details) {
36
+ super(code);
37
+ this.code = code;
38
+ this.httpStatus = httpStatus;
39
+ this.details = details;
40
+ this.name = "InertiaError";
41
+ Object.setPrototypeOf(this, new.target.prototype);
42
+ Error.captureStackTrace?.(this, this.constructor);
43
+ }
44
+ toJSON() {
45
+ return {
46
+ name: this.name,
47
+ code: this.code,
48
+ httpStatus: this.httpStatus,
49
+ message: this.message,
50
+ details: this.details
51
+ };
52
+ }
53
+ };
54
+ var InertiaConfigError = class extends InertiaError {
55
+ constructor(message, details) {
56
+ super("CONFIG_ERROR", 500, {
57
+ ...details,
58
+ message,
59
+ hint: details?.hint ?? "Check your OrbitIon configuration and ensure dependencies are loaded."
60
+ });
61
+ this.name = "InertiaConfigError";
62
+ }
63
+ };
64
+ var InertiaDataError = class extends InertiaError {
65
+ constructor(message, details) {
66
+ super("DATA_ERROR", 500, {
67
+ ...details,
68
+ message,
69
+ hint: details?.hint ?? "Ensure all props are JSON-serializable (no circular refs, BigInt, or functions)."
70
+ });
71
+ this.name = "InertiaDataError";
72
+ }
73
+ };
74
+ var InertiaTemplateError = class extends InertiaError {
75
+ constructor(message, details) {
76
+ super("TEMPLATE_ERROR", 500, {
77
+ ...details,
78
+ message,
79
+ hint: details?.hint ?? "Check if the root view template exists and is properly configured."
80
+ });
81
+ this.name = "InertiaTemplateError";
82
+ }
83
+ };
84
+
29
85
  // src/InertiaService.ts
30
86
  var InertiaService = class {
31
87
  /**
32
- * Create a new InertiaService instance
88
+ * Initializes a new instance of the Inertia service.
33
89
  *
34
- * @param context - The Gravito request context
35
- * @param config - Optional configuration
90
+ * @param context - The current Gravito request context.
91
+ * @param config - Instance configuration options.
36
92
  */
37
93
  constructor(context, config = {}) {
38
94
  this.context = context;
39
95
  this.config = config;
96
+ this.logLevel = config.logLevel ?? "info";
97
+ this.onRenderCallback = config.onRender;
40
98
  }
41
99
  sharedProps = {};
100
+ logLevel;
101
+ onRenderCallback;
42
102
  /**
43
- * Escape a string for safe use in HTML attributes
103
+ * Internal logging helper that respects the configured log level.
104
+ *
105
+ * Routes logs to the Gravito context logger if available.
44
106
  *
45
- * Strategy: JSON.stringify already escapes special characters including
46
- * quotes as \". We need to escape these for HTML attributes, but we must
47
- * be careful not to break JSON escape sequences.
107
+ * @param level - Log severity level.
108
+ * @param message - Descriptive message.
109
+ * @param data - Optional metadata for the log.
110
+ */
111
+ log(level, message, data) {
112
+ const levels = ["debug", "info", "warn", "error", "silent"];
113
+ const currentLevelIndex = levels.indexOf(this.logLevel);
114
+ const messageLevelIndex = levels.indexOf(level);
115
+ if (this.logLevel === "silent" || messageLevelIndex < currentLevelIndex) {
116
+ return;
117
+ }
118
+ const logger = typeof this.context.get === "function" ? this.context.get("logger") : void 0;
119
+ if (logger && typeof logger[level] === "function") {
120
+ logger[level](message, data);
121
+ }
122
+ }
123
+ /**
124
+ * Escapes a string for safe embedding into a single-quoted HTML attribute.
48
125
  *
49
- * The solution: Escape backslash-quote sequences (\" from JSON.stringify)
50
- * as \\&quot; so they become \\&quot; in HTML, which the browser decodes
51
- * to \\" (valid JSON), not \&quot; (invalid JSON).
126
+ * This ensures that JSON strings can be safely passed to the frontend
127
+ * via the `data-page` attribute without breaking the HTML structure or
128
+ * introducing XSS vulnerabilities.
52
129
  *
53
- * @param value - The string to escape.
54
- * @returns The escaped string.
130
+ * @param value - Raw JSON or text string.
131
+ * @returns Safely escaped HTML attribute value.
55
132
  */
56
133
  escapeForSingleQuotedHtmlAttribute(value) {
57
134
  return value.replace(/&/g, "&amp;").replace(/\\"/g, "\\&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/'/g, "&#039;");
58
135
  }
59
136
  /**
60
- * Render an Inertia component
137
+ * Renders an Inertia component and returns the appropriate HTTP response.
138
+ *
139
+ * This method handles the entire Inertia lifecycle:
140
+ * - Detects if the request is an Inertia AJAX request.
141
+ * - Validates asset versions.
142
+ * - Resolves props (executes functions, handles partial reloads).
143
+ * - Performs SSR if configured.
144
+ * - Wraps the result in a JSON response or the root HTML template.
61
145
  *
62
- * @param component - The component name to render
63
- * @param props - Props to pass to the component
64
- * @param rootVars - Additional variables for the root template
65
- * @returns HTTP Response
146
+ * @param component - Frontend component name (e.g., 'Users/Profile').
147
+ * @param props - Data passed to the component. Can include functions for lazy evaluation.
148
+ * @param rootVars - Variables passed to the root HTML template (only used on initial load).
149
+ * @param status - Optional HTTP status code.
150
+ * @returns A promise that resolves to a Gravito HTTP Response.
151
+ *
152
+ * @throws {@link InertiaError}
153
+ * Thrown if:
154
+ * - The `ViewService` is missing from the context (required for initial load).
155
+ * - Prop serialization fails due to circular references or non-serializable data.
66
156
  *
67
157
  * @example
68
158
  * ```typescript
69
- * return inertia.render('Users/Index', {
70
- * users: await User.all(),
71
- * filters: { search: ctx.req.query('search') }
72
- * })
159
+ * // Standard render
160
+ * return await inertia.render('Welcome', { name: 'Guest' });
161
+ *
162
+ * // Partial reload support (only 'stats' will be resolved if requested)
163
+ * return await inertia.render('Dashboard', {
164
+ * user: auth.user,
165
+ * stats: () => db.getStats()
166
+ * });
73
167
  * ```
74
168
  */
75
- render(component, props = {}, rootVars = {}) {
76
- let pageUrl;
169
+ async render(component, props, rootVars = {}, status) {
170
+ const startTime = performance.now();
171
+ const isInertiaRequest = Boolean(this.context.req.header("X-Inertia"));
77
172
  try {
78
- const reqUrl = new URL(this.context.req.url, "http://localhost");
79
- pageUrl = reqUrl.pathname + reqUrl.search;
80
- } catch {
81
- pageUrl = this.context.req.url;
82
- }
83
- const resolveProps = (p) => {
84
- const resolved = {};
85
- for (const [key, value] of Object.entries(p)) {
86
- resolved[key] = typeof value === "function" ? value() : value;
173
+ this.log("debug", "[InertiaService] Starting render", {
174
+ component,
175
+ isInertiaRequest,
176
+ propsCount: props ? Object.keys(props).length : 0
177
+ });
178
+ let pageUrl;
179
+ try {
180
+ const reqUrl = new URL(this.context.req.url, "http://localhost");
181
+ pageUrl = reqUrl.pathname + reqUrl.search;
182
+ } catch {
183
+ pageUrl = this.context.req.url;
87
184
  }
88
- return resolved;
89
- };
90
- const page = {
91
- component,
92
- props: resolveProps({ ...this.sharedProps, ...props }),
93
- url: pageUrl,
94
- version: this.config.version
95
- };
96
- if (this.context.req.header("X-Inertia")) {
97
- this.context.header("X-Inertia", "true");
98
- this.context.header("Vary", "Accept");
99
- return this.context.json(page);
100
- }
101
- const view = this.context.get("view");
102
- const rootView = this.config.rootView ?? "app";
103
- if (!view) {
104
- throw new Error("OrbitPrism is required for the initial page load in OrbitIon");
185
+ const resolveProps = async (p) => {
186
+ const partialData = this.context.req.header("X-Inertia-Partial-Data");
187
+ const partialExcept = this.context.req.header("X-Inertia-Partial-Except");
188
+ const partialComponent = this.context.req.header("X-Inertia-Partial-Component");
189
+ const isPartial = partialComponent === component;
190
+ const only = partialData && isPartial ? partialData.split(",") : [];
191
+ const except = partialExcept && isPartial ? partialExcept.split(",") : [];
192
+ const resolved = {};
193
+ for (const [key, value] of Object.entries(p)) {
194
+ if (only.length > 0 && !only.includes(key)) {
195
+ continue;
196
+ }
197
+ if (except.length > 0 && except.includes(key)) {
198
+ continue;
199
+ }
200
+ if (typeof value === "function") {
201
+ const propStart = performance.now();
202
+ try {
203
+ resolved[key] = await value();
204
+ this.log("debug", `[InertiaService] Resolved lazy prop: ${key}`, {
205
+ duration: `${(performance.now() - propStart).toFixed(2)}ms`
206
+ });
207
+ } catch (err) {
208
+ this.log("error", `[InertiaService] Failed to resolve lazy prop: ${key}`, { err });
209
+ throw new InertiaDataError(`Failed to resolve lazy prop: ${key}`, { cause: err });
210
+ }
211
+ } else {
212
+ resolved[key] = value;
213
+ }
214
+ }
215
+ return resolved;
216
+ };
217
+ const resolveVersion = async () => {
218
+ if (typeof this.config.version === "function") {
219
+ return await this.config.version();
220
+ }
221
+ return this.config.version;
222
+ };
223
+ const version = await resolveVersion();
224
+ const getMergedProps = () => {
225
+ const propsToMerge = props ?? {};
226
+ if (Object.keys(propsToMerge).length === 0) {
227
+ return this.sharedProps;
228
+ }
229
+ return { ...this.sharedProps, ...propsToMerge };
230
+ };
231
+ const page = {
232
+ component,
233
+ props: await resolveProps(getMergedProps()),
234
+ url: pageUrl,
235
+ version
236
+ };
237
+ let pageJson;
238
+ try {
239
+ pageJson = JSON.stringify(page);
240
+ } catch (error) {
241
+ throw new InertiaDataError(
242
+ `Inertia page serialization failed for component: ${component}`,
243
+ {
244
+ cause: error
245
+ }
246
+ );
247
+ }
248
+ let response;
249
+ if (isInertiaRequest) {
250
+ const clientVersion = this.context.req.header("X-Inertia-Version");
251
+ if (version && clientVersion && clientVersion !== version) {
252
+ this.context.header("X-Inertia-Location", pageUrl);
253
+ return new Response("", { status: 409 });
254
+ }
255
+ this.context.header("X-Inertia", "true");
256
+ this.context.header("Vary", "Accept");
257
+ response = this.context.json(page, status);
258
+ } else {
259
+ const view = this.context.get("view");
260
+ const rootView = this.config.rootView ?? "app";
261
+ if (!view) {
262
+ throw new InertiaConfigError(
263
+ "ViewService (OrbitPrism) is required for initial page load but was not found in context.",
264
+ { hint: "Ensure OrbitPrism is loaded before OrbitIon in your orbit configuration" }
265
+ );
266
+ }
267
+ const isDev = process.env.NODE_ENV !== "production";
268
+ let ssrData = { head: [], body: "" };
269
+ if (this.config.ssr?.enabled && this.config.ssr.render) {
270
+ try {
271
+ ssrData = await this.config.ssr.render(page);
272
+ } catch (error) {
273
+ this.log("error", "[InertiaService] SSR rendering failed", { error });
274
+ }
275
+ }
276
+ response = this.context.html(
277
+ view.render(
278
+ rootView,
279
+ {
280
+ ...rootVars,
281
+ page: this.escapeForSingleQuotedHtmlAttribute(pageJson),
282
+ isDev,
283
+ ssrHead: ssrData.head.join("\n"),
284
+ ssrBody: ssrData.body
285
+ },
286
+ { layout: "" }
287
+ ),
288
+ status
289
+ );
290
+ }
291
+ const duration = performance.now() - startTime;
292
+ this.log("info", "[InertiaService] Render complete", {
293
+ component,
294
+ duration: `${duration.toFixed(2)}ms`,
295
+ isInertiaRequest,
296
+ status: status ?? 200
297
+ });
298
+ if (this.onRenderCallback) {
299
+ this.onRenderCallback({
300
+ component,
301
+ duration,
302
+ isInertiaRequest,
303
+ propsCount: props ? Object.keys(props).length : 0,
304
+ timestamp: Date.now(),
305
+ status
306
+ });
307
+ }
308
+ return response;
309
+ } catch (error) {
310
+ const duration = performance.now() - startTime;
311
+ if (error instanceof InertiaError) {
312
+ this.log("error", "[InertiaService] Render failed", {
313
+ component,
314
+ duration: `${duration.toFixed(2)}ms`,
315
+ errorCode: error.code,
316
+ errorDetails: error.details
317
+ });
318
+ throw error;
319
+ }
320
+ this.log("error", "[InertiaService] Unexpected render error", {
321
+ component,
322
+ duration: `${duration.toFixed(2)}ms`,
323
+ error
324
+ });
325
+ return new Response("Inertia Render Error", { status: 500 });
105
326
  }
106
- const isDev = process.env.NODE_ENV !== "production";
107
- return this.context.html(
108
- view.render(
109
- rootView,
110
- {
111
- ...rootVars,
112
- page: this.escapeForSingleQuotedHtmlAttribute(JSON.stringify(page)),
113
- isDev
114
- },
115
- { layout: "" }
116
- )
117
- );
118
327
  }
119
- // ...
120
328
  /**
121
- * Share data with all Inertia responses
329
+ * Registers a piece of data to be shared with all Inertia responses for the current request.
122
330
  *
123
- * Shared props are merged with component-specific props on every render.
331
+ * Shared props are automatically merged with component-specific props during the render phase.
332
+ * This is the primary mechanism for providing global state like authentication details,
333
+ * flash messages, or configuration to the frontend.
124
334
  *
125
- * @param key - The prop key
126
- * @param value - The prop value
335
+ * @param key - Identifier for the shared prop.
336
+ * @param value - Value to share. Must be JSON serializable.
127
337
  *
128
338
  * @example
129
339
  * ```typescript
130
- * // In middleware
131
- * inertia.share('auth', { user: ctx.get('auth')?.user() })
132
- * inertia.share('flash', ctx.get('session')?.getFlash('message'))
340
+ * inertia.share('appName', 'Gravito Store');
341
+ * inertia.share('auth', { user: ctx.get('user') });
133
342
  * ```
134
343
  */
135
344
  share(key, value) {
136
345
  this.sharedProps[key] = value;
137
346
  }
138
347
  /**
139
- * Share multiple props at once
348
+ * Shares multiple props in a single operation.
349
+ *
350
+ * Merges the provided object into the existing shared props state. Existing keys
351
+ * with the same name will be overwritten.
140
352
  *
141
- * @param props - Object of props to share
353
+ * @param props - Object containing key-value pairs to merge into the shared state.
354
+ *
355
+ * @example
356
+ * ```typescript
357
+ * inertia.shareAll({
358
+ * version: '1.2.0',
359
+ * environment: 'production',
360
+ * features: ['chat', 'search']
361
+ * });
362
+ * ```
142
363
  */
143
364
  shareAll(props) {
144
365
  Object.assign(this.sharedProps, props);
145
366
  }
146
367
  /**
147
- * Get all shared props
368
+ * Returns a shallow copy of the current shared props.
369
+ *
370
+ * Useful for inspecting the shared state or for manual prop merging in advanced scenarios.
148
371
  *
149
- * @returns A shallow copy of the shared props object.
372
+ * @returns A dictionary containing all currently registered shared props.
373
+ *
374
+ * @example
375
+ * ```typescript
376
+ * const shared = inertia.getSharedProps();
377
+ * if (!shared.auth) {
378
+ * console.warn('Authentication data is missing from shared props');
379
+ * }
380
+ * ```
150
381
  */
151
382
  getSharedProps() {
152
383
  return { ...this.sharedProps };
@@ -155,11 +386,28 @@ var InertiaService = class {
155
386
 
156
387
  // src/index.ts
157
388
  var OrbitIon = class {
389
+ /**
390
+ * Initializes the Orbit with custom configuration.
391
+ *
392
+ * @param options - Configuration overrides for the Inertia service.
393
+ */
158
394
  constructor(options = {}) {
159
395
  this.options = options;
160
396
  }
161
397
  /**
162
- * Install the inertia orbit into PlanetCore
398
+ * Registers the Inertia middleware and service factory into PlanetCore.
399
+ *
400
+ * This method is called automatically by Gravito during the boot process.
401
+ * It sets up the `InertiaService` for each request and attaches
402
+ * the `InertiaHelper` proxy to the context.
403
+ *
404
+ * @param core - The Gravito micro-kernel instance.
405
+ *
406
+ * @example
407
+ * ```typescript
408
+ * const ion = new OrbitIon({ version: '1.0' });
409
+ * ion.install(core);
410
+ * ```
163
411
  */
164
412
  install(core) {
165
413
  core.logger.info("\u{1F6F0}\uFE0F Orbit Inertia installed (Callable Interface)");
@@ -167,20 +415,19 @@ var OrbitIon = class {
167
415
  const rootView = this.options.rootView ?? "app";
168
416
  core.adapter.use("*", async (c, next) => {
169
417
  const service = new InertiaService(c, {
170
- version: String(appVersion),
171
- rootView
418
+ version: appVersion,
419
+ rootView,
420
+ ssr: this.options.ssr
172
421
  });
173
- const inertiaProxy = (component, props = {}, rootVars = {}) => {
174
- return service.render(component, props, rootVars);
422
+ const inertiaProxy = async (component, props = {}, rootVars = {}, status) => {
423
+ return await service.render(component, props, rootVars, status);
175
424
  };
176
425
  Object.assign(inertiaProxy, {
177
426
  share: service.share.bind(service),
178
427
  shareAll: service.shareAll.bind(service),
179
428
  getSharedProps: service.getSharedProps.bind(service),
180
429
  render: service.render.bind(service),
181
- // Also allow .render()
182
430
  service
183
- // Access to the raw service instance
184
431
  });
185
432
  c.set("inertia", inertiaProxy);
186
433
  return await next();
@@ -190,6 +437,10 @@ var OrbitIon = class {
190
437
  var index_default = OrbitIon;
191
438
  // Annotate the CommonJS export names for ESM import in node:
192
439
  0 && (module.exports = {
440
+ InertiaConfigError,
441
+ InertiaDataError,
442
+ InertiaError,
193
443
  InertiaService,
444
+ InertiaTemplateError,
194
445
  OrbitIon
195
446
  });