All files / integrations/src/fastify coherent-fastify.js

82.35% Statements 28/34
100% Branches 19/19
85.71% Functions 6/7
81.81% Lines 27/33

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156                                                              11x 1x                   10x     10x 2x 1x   1x 1x       10x       5x     5x 5x               2x     3x 3x                               10x 1x 2x 1x 1x 1x 1x   1x       10x                             4x                   4x                   1x                                    
/**
 * Fastify integration for Coherent.js
 * Provides plugins and utilities for using Coherent.js with Fastify.
 *
 * The plugin must be wrapped with fastify-plugin (fp). Without fp, every
 * `fastify.register(...)` boundary creates a fresh encapsulated context,
 * and the `preSerialization` hook + `isCoherentObject` decorator only
 * apply to routes registered INSIDE that context. The user's root-level
 * routes would never see them, and responses would JSON-serialize the
 * raw component object instead of rendering it.
 */
 
import fp from 'fastify-plugin';
import {
  renderWithTemplate,
  renderComponentFactory
} from '@coherent.js/core';
 
/**
 * Fastify plugin implementation. Not exported directly — use the fp-wrapped
 * `coherentFastify` (or its alias `setupCoherent`) instead.
 *
 * @param {Object} fastify - Fastify instance
 * @param {Object} options - Plugin options
 * @param {boolean} [options.enablePerformanceMonitoring] - Enable performance monitoring
 * @param {string} [options.template] - HTML template with {{content}} placeholder
 * @param {boolean} [options.autoRender] - Render component-shaped handler return values
 *   (see the preSerialization hook below for why this is opt-in)
 * @param {Function} done - Callback to signal plugin registration completion
 */
function coherentFastifyImpl(fastify, options = {}, done) {
  if (typeof done !== 'function') {
    throw new TypeError(
      'coherentFastify/setupCoherent is a Fastify plugin and cannot be called directly: ' +
      'use `await fastify.register(setupCoherent, options)`.'
    );
  }
 
  const {
    enablePerformanceMonitoring = false,
    template = '<!DOCTYPE html>\n{{content}}',
    autoRender = false
  } = options;
 
  // Add decorator to check if an object is a Coherent.js component
  fastify.decorateReply('isCoherentObject', (obj) => {
    if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
      return false;
    }
    const keys = Object.keys(obj);
    return keys.length === 1;
  });
 
  // Add decorator for explicit rendering: reply.coherent(component, opts?)
  fastify.decorateReply('coherent', function(component, renderOptions = {}) {
    const {
      enablePerformanceMonitoring: renderPerformanceMonitoring = enablePerformanceMonitoring,
      template: renderTemplate = template
    } = renderOptions;
 
    let finalHtml;
    try {
      finalHtml = renderWithTemplate(component, {
        enablePerformanceMonitoring: renderPerformanceMonitoring,
        template: renderTemplate
      });
    } catch (_error) {
      // Sending an Error runs Fastify's error pipeline (onError hooks, the
      // app's setErrorHandler, its logger) instead of answering with the raw
      // message.
      return this.send(_error);
    }
 
    this.header('Content-Type', 'text/html; charset=utf-8');
    return this.send(finalHtml);
  });
 
  // Auto-render (opt-in): if a handler returns a Coherent.js component
  // object, intercept before serialization and replace the payload with HTML.
  //
  // Off by default because detection is a heuristic -- every single-key
  // object qualifies, so `return { ok: true }` or a 401 `{ error: '...' }`
  // would be rendered as `<ok>` / `<error>` HTML even with a JSON response
  // schema. Use `reply.coherent(component)` for explicit rendering instead.
  //
  // - `onSend` runs after JSON serialization (payload is already a string),
  //   so the component object would never be detected there.
  // - `preSerialization` runs before serialization. We render to HTML and
  //   install an identity serializer for this reply, so Fastify doesn't
  //   JSON-stringify the HTML string we just produced.
  if (autoRender) {
    fastify.addHook('preSerialization', async (request, reply, payload) => {
      if (reply.isCoherentObject?.(payload)) {
        const finalHtml = renderWithTemplate(payload, { enablePerformanceMonitoring, template });
        reply.header('Content-Type', 'text/html; charset=utf-8');
        reply.serializer((p) => p);
        return finalHtml;
      }
      return payload;
    });
  }
 
  done();
}
 
/**
 * Fastify plugin for Coherent.js — wrapped with fastify-plugin so decorators
 * and hooks apply to the parent (root) context. Register at the top of your
 * app, then render components explicitly with `reply.coherent()`:
 *
 *   await fastify.register(coherentFastify, { template: APP_HTML_TEMPLATE });
 *   fastify.get('/', async (request, reply) => reply.coherent(HomePage({})));
 *
 * Pass `autoRender: true` to also render component objects returned from
 * handlers (`fastify.get('/', async () => HomePage({}))`); see the
 * preSerialization hook above for why that is not the default.
 */
export const coherentFastify = fp(coherentFastifyImpl, {
  name: 'coherent-fastify',
  fastify: '>=4.0.0'
});
 
/**
 * Alias for `coherentFastify`. Preserved for backward compatibility with
 * scaffolds and examples that use `setupCoherent`. Behaves identically:
 * `await fastify.register(setupCoherent, options)`.
 */
export const setupCoherent = coherentFastify;
 
/**
 * Create a Fastify route handler for Coherent.js components.
 *
 * @param {Function} componentFactory - Function that returns a Coherent.js component
 * @param {Object} options - Handler options
 * @returns {Function} Fastify route handler
 */
export function createHandler(componentFactory, options = {}) {
  return async (request, reply) => {
    try {
      const finalHtml = await renderComponentFactory(
        componentFactory,
        [request, reply],
        options
      );
      reply.header('Content-Type', 'text/html; charset=utf-8');
      return finalHtml;
    } catch (_error) {
      console.error('Coherent.js handler error:', _error);
      throw _error;
    }
  };
}
 
// Default export = the plugin, for `fastify.register(import('@coherent.js/integrations/fastify'))`.
export default coherentFastify;