Ctrl K
ComunidadGuías…

scripts/check-sidecar-shipped-routes.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * Shipped sidecar route check (#9906).
 *
 * The desktop app ships `api/` as a Tauri resource, without `node_modules`,
 * and the sidecar imports every route module under it on demand. A route that
 * imports an npm package, or a file the resources leave out, cannot load on
 * desktop. The sidecar catches ERR_MODULE_NOT_FOUND, logs it once and sends
 * the request to the cloud, so nothing visibly breaks while the user is online.
 * v2.11.6 shipped 13 such routes. CI never saw them: the sidecar suite tests
 * fixture handlers, and `unit` only checks that the handler bundles exist.
 *
 * This check copies the resources listed in src-tauri/tauri.conf.json into a
 * temp directory with the layout Tauri gives them (`../api` becomes
 * `_up_/api`), then imports every route the sidecar's route table would load.
 * It copies rather than symlinks: Node resolves a symlinked module through its
 * real path, which would find the repository's node_modules and hide the bug.
 *
 * Build the handler bundles first (build-sidecar-sebuf, build-sidecar-handlers).
 * Run it with the Node the desktop ships, since that is what loads the routes.
 *
 * Run: node scripts/check-sidecar-shipped-routes.mjs [--root <repo-root>]
 */

import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

import { isMainModule } from './lib/main-module.mjs';

// The bundled runtime is large and loads nothing from api/.
const SKIPPED_RESOURCES = new Set(['sidecar/node']);

// Routes allowed to fail to load on desktop, keyed by route module. Each must
// stay cloud-preferred in the sidecar, which the check verifies, so the
// desktop only tries it locally when the cloud cannot answer either.
export const CLOUD_ONLY_ROUTES = new Map([
  // Serves seeded Redis snapshots the desktop does not hold, and imports
  // @vercel/functions and aws4fetch for its R2 shadow read.
  ['bootstrap.js', '/api/bootstrap'],
]);

/** Tauri places a resource path's leading `../` segments under `_up_/`. */
export function shippedResourcePath(entry) {
  return entry.replace(/^(\.\.\/)+/, (ups) => '_up_/'.repeat(ups.length / 3));
}

export function stageShippedResources(root, stageDir) {
  const tauriDir = path.join(root, 'src-tauri');
  const config = JSON.parse(readFileSync(path.join(tauriDir, 'tauri.conf.json'), 'utf8'));
  const resources = config.bundle?.resources;
  if (!Array.isArray(resources) || resources.length === 0) {
    throw new Error('src-tauri/tauri.conf.json has no bundle.resources list');
  }
  for (const entry of resources) {
    if (SKIPPED_RESOURCES.has(entry)) continue;
    const source = path.resolve(tauriDir, entry);
    if (!existsSync(source)) throw new Error(`bundle resource ${entry} does not exist (${source})`);
    cpSync(source, path.join(stageDir, shippedResourcePath(entry)), { recursive: true, dereference: true });
  }
}

/** Mirrors buildRouteTable() in src-tauri/sidecar/local-api-server.mjs. */
export function listRouteModules(apiDir) {
  const files = [];
  const walk = (dir) => {
    for (const entry of readdirSync(dir, { withFileTypes: true })) {
      const absolute = path.join(dir, entry.name);
      if (entry.isDirectory()) walk(absolute);
      else if (entry.name.endsWith('.js') && !entry.name.startsWith('_')) files.push(absolute);
    }
  };
  walk(apiDir);
  return files.sort();
}

/** Every api/{domain}/v1/[rpc].ts must have its bundle, or the check proves nothing for it. */
export function findMissingBundles(apiDir) {
  return readdirSync(apiDir, { withFileTypes: true })
    .filter((d) => d.isDirectory() && existsSync(path.join(apiDir, d.name, 'v1', '[rpc].ts')))
    .map((d) => d.name)
    .filter((domain) => !existsSync(path.join(apiDir, domain, 'v1', '[rpc].js')));
}

/**
 * @param {string} root
 * @param {{ isCloudPreferred: (pathname: string) => boolean, cloudOnlyRoutes?: Map<string, string> }} options
 */
export async function checkShippedRoutes(root, { isCloudPreferred, cloudOnlyRoutes = CLOUD_ONLY_ROUTES }) {
  const stageDir = mkdtempSync(path.join(os.tmpdir(), 'wm-sidecar-shipped-'));
  try {
    stageShippedResources(root, stageDir);
    const apiDir = [path.join(stageDir, 'api'), path.join(stageDir, '_up_', 'api')].find((dir) => existsSync(dir));
    if (!apiDir) throw new Error('the bundle resources ship no api/ directory');
    const missingBundles = findMissingBundles(apiDir);
    const routes = listRouteModules(apiDir);
    const failures = [];
    for (const [route, pathname] of cloudOnlyRoutes) {
      if (!isCloudPreferred(pathname)) {
        failures.push({ route, reason: `exempt as cloud-only, but the sidecar does not treat ${pathname} as cloud-preferred` });
      }
    }
    for (const modulePath of routes) {
      const route = path.relative(apiDir, modulePath).split(path.sep).join('/');
      if (cloudOnlyRoutes.has(route)) continue;
      try {
        const mod = await import(pathToFileURL(modulePath).href);
        if (typeof mod.default !== 'function') failures.push({ route, reason: `default export is ${typeof mod.default}` });
      } catch (error) {
        const reason = String(error?.message ?? error).split('\n')[0].split(stageDir).join('<resources>');
        failures.push({ route, reason });
      }
    }
    return { routes: routes.length, missingBundles, failures };
  } finally {
    rmSync(stageDir, { recursive: true, force: true });
  }
}

function parseArgs(argv) {
  let root = process.cwd();
  for (let i = 0; i < argv.length; i++) {
    if (argv[i] === '--root' && argv[i + 1]) root = path.resolve(argv[++i]);
    else throw new Error(`unknown argument: ${argv[i]}`);
  }
  return { root };
}

if (isMainModule(import.meta.url, process.argv[1])) {
  const { root } = parseArgs(process.argv.slice(2));
  const { __testing__ } = await import(pathToFileURL(path.join(root, 'src-tauri/sidecar/local-api-server.mjs')).href);
  const { routes, missingBundles, failures } = await checkShippedRoutes(root, { isCloudPreferred: __testing__.isCloudPreferred });
  let failed = false;
  if (missingBundles.length > 0) {
    console.error(`::error::sidecar handler bundles not built for: ${missingBundles.join(', ')}. Run scripts/build-sidecar-handlers.mjs first.`);
    failed = true;
  }
  if (routes === 0) {
    console.error('::error::found no sidecar route modules in the shipped api/ directory');
    failed = true;
  }
  for (const { route, reason } of failures) {
    console.error(`::error::shipped sidecar route api/${route} does not load on Node ${process.version}: ${reason}`);
  }
  if (failures.length > 0) failed = true;
  console.log(`shipped sidecar routes: ${failures.length} failed of ${routes} (${CLOUD_ONLY_ROUTES.size} exempt as cloud-only) on Node ${process.version}`);
  // Route modules can leave timers or sockets open; the verdict is already known.
  process.exit(failed ? 1 : 0);
}