heygen-com/hyperframes · error

[deploySite] projectDir is not a directory: ${opts.projectDi

Error message

[deploySite] projectDir is not a directory: ${opts.projectDir}

What it means

Thrown by deploySite when statSync(opts.projectDir).isDirectory() returns false. Because statSync (not lstat) is called without an existence guard, a non-existent path produces a raw ENOENT from statSync first; this specific message fires only when the path EXISTS but is not a directory (a regular file, a FIFO, etc.). deploySite then tars and uploads the directory, so a file path would corrupt the upload.

Source

Thrown at packages/aws-lambda/src/sdk/deploySite.ts:65

  projectS3Uri: string;
  /** Tarball size in bytes; useful for "did we actually skip the upload?" assertions. */
  bytes: number;
  /** ISO timestamp of the most recent upload OR the existing object the short-circuit found. */
  uploadedAt: string;
  /** `false` if the object already existed and we skipped the PUT. */
  uploaded: boolean;
}

/**
 * Upload `projectDir` to `s3://bucketName/sites/<siteId>/project.tar.gz`.
 *
 * Short-circuits when an object with the same key already exists in the
 * bucket — `siteId` derives from the project's content hash, so the same
 * bytes produce the same key, and re-uploading would be redundant.
 */
export async function deploySite(opts: DeploySiteOptions): Promise<SiteHandle> {
  if (!statSync(opts.projectDir).isDirectory()) {
    throw new Error(`[deploySite] projectDir is not a directory: ${opts.projectDir}`);
  }

  const siteId = opts.siteId ?? hashProjectDir(opts.projectDir);
  const key = `sites/${siteId}/project.tar.gz`;
  const projectS3Uri = formatS3Uri({ bucket: opts.bucketName, key });
  const s3 = opts.s3 ?? new S3Client({ region: opts.region });

  // HeadObject short-circuit. Adopters re-rendering the same project on
  // a tight inner loop (CI smoke, demo flows) save the tar+gzip+PUT pass
  // on every iteration.
  const existing = await headObject(s3, opts.bucketName, key);
  if (existing) {
    return {
      siteId,
      bucketName: opts.bucketName,
      projectS3Uri,
      bytes: existing.bytes,
      uploadedAt: existing.lastModified,

View on GitHub (pinned to c2996c8626)

Solutions

  1. Point projectDir at the directory CONTAINING index.html, not the file itself.
  2. Add a pre-check: if (!statSync(p).isDirectory()) throw a clear message before calling deploySite.
  3. If the path may not exist, guard with existsSync first to give a clearer 'does not exist' message than statSync's ENOENT.
  4. Resolve symlinks with realpathSync to confirm the target is a directory.

Example fix

// before
await deploySite({ projectDir: './dist/index.html', bucketName });

// after
await deploySite({ projectDir: './dist', bucketName });
Defensive patterns

Strategy: validation

Validate before calling

import { existsSync, statSync } from 'node:fs';
function assertProjectDir(projectDir: string): void {
  if (!existsSync(projectDir)) throw new Error(`projectDir does not exist: ${projectDir}`);
  if (!statSync(projectDir).isDirectory()) throw new Error(`projectDir is not a directory: ${projectDir}`);
}

Type guard

import { statSync } from 'node:fs';
const isValidProjectDir = (p: string): boolean => {
  try { return statSync(p).isDirectory(); } catch { return false; }
};

Prevention

When it happens

Trigger: Passing projectDir that resolves to a file (e.g. the index.html itself instead of its parent directory), a symlink pointing to a file, or a path that exists but is a socket/device. The ENOENT-from-statSync case (path missing entirely) is a separate unguarded throw.

Common situations: User points --project-dir at ./dist/index.html instead of ./dist; a config that resolves projectDir to a build output file; symlink-to-file; passing a tarball path instead of the unpacked project root.

Related errors


AI-assisted analysis of heygen-com/hyperframes@c2996c8626 (2026-08-12). Data as JSON: /api/errors/3bf80f0ac2131ed1. Report an issue: GitHub.