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