Skip to main content
jg-packages

Search

    Patterns for Node.js apps that keep their files whole through crashes, and read files from users within a size limit.

    A config file that’s never half-written

    Use this for a file the app reads at startup and rewrites while it runs, such as settings changed from an admin page. A plain write that’s cut short, by a crash or a full disk, leaves a truncated file that won’t parse at the next start. writeJsonFile writes a temporary file next to it, flushes it to disk, and renames it over the old one, so the file holds either the old config or the new one:

    ts
    import { hasErrorCode } from 'jg-exception';
    import { ERRORS, readJsonFile, writeJsonFile } from 'jg-fs';
    
    // the config's type, from your schema
    type IConfig = ReturnType<typeof Config.parse>;
    
    const CONFIG_PATH = 'data/config.json';
    
    // what the app runs with until someone saves a change
    const DEFAULT_CONFIG: IConfig = { port: 8080 };
    
    export const loadConfig = async (): Promise<IConfig> => {
      try {
        // Config is your schema, such as a Zod object, so a file edited by hand into a wrong shape
        // fails here, at startup, instead of somewhere later
        return await readJsonFile(CONFIG_PATH, { schema: Config });
      } catch (error) {
        // nothing was saved yet
        if (hasErrorCode(error, ERRORS.NOT_A_FILE)) {
          return DEFAULT_CONFIG;
        }
        throw error;
      }
    };
    
    // the update in progress, which the next one waits for
    let pendingUpdate: Promise<unknown> = Promise.resolve();
    
    // reads, changes, and saves the config one update at a time, so two updates never start from
    // the same version and lose one of the changes
    export const updateConfig = (change: (config: IConfig) => IConfig): Promise<IConfig> => {
      const update = pendingUpdate.then(async () => {
        const config = change(await loadConfig());
        await writeJsonFile(CONFIG_PATH, config);
        return config;
      });
    
      // a failed update rejects for its caller, and the next one still runs
      pendingUpdate = update.catch(() => undefined);
      return update;
    };
    
    await updateConfig((config) => ({ ...config, port: 3000 }));

    writeFileAtomic writes any other content the same way, and both try again for a few seconds while Windows refuses the rename because a scanner holds the file. A symbolic link at the path keeps pointing at the config. Any error other than a missing file propagates, such as FILE_CONTENT_IS_EMPTY_OR_INVALID for a file that isn’t JSON, or the schema’s own error, so the app never starts from a config it can’t trust. The atomic write keeps each save whole, but doesn’t order saves: the queue does that inside one process, and when several processes write the file, give it one owner. A crash during a save can leave a temporary .<random>.tmp file next to the config, which is safe to delete.

    jg-fs/sync has the same functions without promises, for code that can’t await, such as an exit handler or a short CLI command. An explicit mode keeps a secret private from the first byte written:

    ts
    import { homedir } from 'node:os';
    import { join } from 'node:path';
    import { writeFileAtomic } from 'jg-fs/sync';
    
    // the token your CLI's login command received
    declare const token: string;
    
    // kept outside any project, so it's never committed with one
    const credentialsPath = join(homedir(), '.my-cli', 'credentials.json');
    
    // on POSIX, only the owner can read it, and the temporary file is never more widely readable
    writeFileAtomic(credentialsPath, JSON.stringify({ token }), 0o600);

    Without a mode, a replaced file keeps its permission bits, so a config someone restricted with chmod stays restricted after the app saves it.

    Reading uploads with a size limit

    Use this when a request names a file that someone uploaded earlier, such as an import or an attachment, and the app reads it into memory. The name comes from a client, and the file can be larger than you expect, so the read checks three things: the path stays inside the uploads directory, the file is a regular file and not a symbolic link, and its size is within a limit:

    ts
    import { join, resolve } from 'node:path';
    import { Exception, hasErrorCode } from 'jg-exception';
    import { ERRORS, isPathWithin, readBoundedFile } from 'jg-fs';
    
    // where your upload handler saves files, a directory that only the app writes to
    const UPLOADS_DIRECTORY = resolve('uploads');
    
    // the largest upload the app loads into memory
    const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
    
    // reads an upload by the name a request gave, such as 'report.csv'. The messages name the upload
    // and not the server's path, and the cause keeps jg-fs's error for the logs
    export const readUpload = async (name: string): Promise<Buffer> => {
      // a name such as '../.env' leads outside the directory, which counts as not found
      const path = join(UPLOADS_DIRECTORY, name);
      if (!isPathWithin(UPLOADS_DIRECTORY, path)) {
        throw new Exception('The upload was not found.', {
          code: 'UPLOAD_NOT_FOUND',
          data: { name },
        });
      }
      try {
        return await readBoundedFile(path, MAX_UPLOAD_BYTES);
      } catch (error) {
        // a missing file, a directory, or a symbolic link someone planted
        if (hasErrorCode(error, ERRORS.NOT_A_FILE)) {
          throw new Exception('The upload was not found.', {
            code: 'UPLOAD_NOT_FOUND',
            data: { name },
            cause: error,
          });
        }
        if (hasErrorCode(error, ERRORS.FILE_TOO_LARGE)) {
          throw new Exception('The upload is too large to read.', {
            code: 'UPLOAD_TOO_LARGE',
            data: { name, maxBytes: MAX_UPLOAD_BYTES },
            cause: error,
          });
        }
        throw error;
      }
    };

    readBoundedFile checks the size before it reads, and reads at most one byte past the limit, so a file that grows during the read can’t make it load more. It refuses a symbolic link as the last part of the path, but follows links in the directories above it, which is why the uploads go in a directory that only the app writes to. A request handler can answer UPLOAD_NOT_FOUND with a 404, and jg-fs/sync has the same readBoundedFile for code that can’t await.