Web Scratch 2024-10-06T23:25:19+09:00 https://efcl.info/ azu BunでNode.jsのツールをSingle-file executable binaryにしてバイナリを配布する 2024-10-06T20:25:00+09:00 https://efcl.info/2024/10/06/bun-single-file-executable-binary <![CDATA[

Secretlint v8.3で、単体のバイナリファイルとしてsecretlintコマンドを配布するようにしました。

どういうことができるようになるかというか、Node.jsをインストールしなくてもsecretlintコマンドを使えるようになります。 次のようにCurlでダウンロードして実行するだけで、機密情報の検出ができるようになります。

#!/usr/bin/env bash
set -euo pipefail
SECRETLINT_VERSION="8.3.3" # secretlintのバージョン
ARCH=$(uname -m)
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
# Map architecture to the expected format
case "$ARCH" in
    x86_64)
        ARCH="x64"
        ;;
    aarch64)
        ARCH="arm64"
        ;;
    arm64)
        ARCH="arm64"
        ;;
    *)
        echo "Unsupported architecture: $ARCH"
        exit 1
        ;;
esac

# Download the binary
curl -sSL "https://github.com/secretlint/secretlint/releases/download/$(SECRETLINT_VERSION)/secretlint-$(SECRETLINT_VERSION)-$(OS)-$(ARCH)" -o secretlint
chmod +x ./secretlint
# Create .secretlintrc.json
./secretlint --init
# Run secretlint
./secretlint "**/*"

SecretlintDockerも対応していますが、コマンドラインツールの場合は単体のバイナリとして使えると何かと便利です。

npm経由と比べて、Node.jsを必要としないのとダウンロードが1つのファイルだけなのでシンプルです。 Docker経由に比べて、Dockerを必要としないのと実行までの時間が早いはずです。 デメリットとしては、単体のバイナリはルールも同梱しないと配布できないので、ルールの追加が難しい点です。 (一応 node_modules 以下にルールがあれば参照できますが、それは実質npmに依存してるので素直にnpm使ったほうが良いです)

この secretlint バイナリは、Bunを使ってランタイムも含んだSingle-file executable binaryとして配布しています。 この記事では、Bunを使ってNode.jsのツールのSingle-file executable binaryを配布する方法を紹介します。

Bunとは

Bunは、Node.js互換のランタイムです。 Bun 1.0が出てから1年ほど経ちますが、Node.jsのコードは結構そのまま動きます。 Secretlintは、特別なBunの対応はすることなく、そのままBunでも実行できました。

BunでSingle-file executable binaryを作る

Bunには、bun build --compileというコードから、Single-file executable binaryを作る機能があります。

次のように、bun build --compileを実行すると、secretlintのSingle-file executable binaryが作成されます。 Bun v1.1.5で、Cross Compileも対応したため、macOSでLinuxのバイナリも作成できます。

bun build --compile --target=bun-linux-x64 ./index.ts --outfile myapp

Secretlintでは、secretlintからCLIのインターフェースを公開しているので、次のようにCLIのインターフェースをラップするだけのindex.tsを作成しました。

import { cli, run } from "secretlint/cli";
run(cli.input, cli.flags).then(
    ({ exitStatus, stderr, stdout }) => {
        if (stdout) {
            console.log(stdout);
        }
        if (stderr) {
            console.error(stderr);
        }
        process.exit(exitStatus);
    },
    (error) => {
        console.error(error);
        process.exit(1);
    }
);

あとは、これをbun build --compileでビルドするだけ、secretlintのSingle-file executable binaryが作成できます。

bun build --compile --target=bun-linux-x64 ./index.ts --outfile dist/secretlint

しかし、Secretlintはデフォルトではルールを同梱してないので、バイナリにルールを含める必要があります。 ルールは、基本的に.secretlintrc.jsonに定義したものを、動的にロード(Dynamic Import)することで読み込むことで利用できます。 ただ、bun build --compileは、外からパッケージ名を指定してロードするようなものをはそのままではバイナリに含めることができません。

そのため、SecretlintやESLint、textlintなどの動的なパッケージロードを含むツールをSingle-file executable binaryにするには工夫が必要です。

動的なパッケージロードを含むツールをSingle-file executable binaryにする

Secretlintには、@secretlint/secretlint-rule-preset-recommendという推奨ルールセットがあります。 多くのケースでは、このプリセットルールがあればある程度使えるので、単体のバイナリに含めることにしました。

しかし、bun build --compileは、import(name)のようなDynamic Importの引数が変数の場合には対応していません。

具体的には、次のようなコードがあった場合、loadDynamicModule("rambda")のようなコードはビルド済みのバイナリでは実行に失敗します。

import "rambda"; // ← これはOK
export const loadDynamicModuleHard = async () => {
  const { head } = await import("rambda"); // ← これはOK
  console.log(head("XLL"));
}
export const loadDynamicModule = async (packageName: string) => {
  const { head } = await import(packageName); // ← これはNG
  console.log(head("XLL"));
}
loadDynamicModule("rambda");

📝 Deno Compileの場合は、パッケージ名でキャッシュされているので、事前にimportしておくだけでloadDynamicModule(packageName)のようなコードは動作します。

この問題を回避するには、事前にstatic import(import文)で必要なパッケージをimportしておき、Dynamic ImportをHookしてモックするような処理が必要です。 Secretlintでは、@secretlint/resolverという内部的なresolver/import hooksを実装しました。

@secretlint/resolverは、require.resolve(specifier)import(specifier)をラップした関数を提供しています。 それと合わせて、registerResolveHookregisterImportHookという関数を提供して、resolveやimportするときの処理に対してHookを登録できるようにしています。

import { createRequire } from "node:module";
import * as url from "node:url";
import path from "node:path";

const require = createRequire(import.meta.url);
export type ResolverContext = {
    parentImportMeta: ImportMeta;
    parentModule: "config-loader" | "formatter";
};
type ResolverSkipResult = undefined;
/**
 * Resolve Hook
 */
export type ResolveHook = (
    specifier: string,
    context: ResolverContext
) =>
    | {
          url: string | undefined;
      }
    | ResolverSkipResult;
/**
 * dynamic import() hook
 */
export type ImportHook = (
    specifier: string,
    context: ResolverContext
) => Promise<
    | {
          exports: Record<string, unknown>;
      }
    | ResolverSkipResult
>;

const resolveHooks: ResolveHook[] = [];
const importHooks: ImportHook[] = [];
/**
 * Register Resolver Hook
 * Hook can return resolved URL
 * if hooks pass through, it should return `undefined` instead of object
 * @param hook
 */
export const registerResolveHook = (hook: ResolveHook) => {
    resolveHooks.push(hook);
};

/**
 * Try to resolve package name
 * if `packageName` is found, return resolved absolute path.
 * if `packageName` is not found, return `undefined`
 * @param packageName
 * @param context
 */
export const tryResolve = (packageName: string, context: ResolverContext): string | undefined => {
    try {
        for (const hook of resolveHooks) {
            const result = hook(packageName, context);
            // Skip if hook return undefined from hook
            if (!result) {
                continue;
            }
            if (result?.url) {
                return result.url;
            }
        }

        // TODO: import.meta.resolve is not supported in Node.js 18
        // We will change to import.meta.resolve(packageName)
        return require.resolve(packageName);
    } catch {
        return undefined;
    }
};

/**
 * Register Import Hook
 * @param hook
 */
export const registerImportHook = (hook: ImportHook) => {
    importHooks.push(hook);
};

// Windows's path require to convert file://
// https://github.com/secretlint/secretlint/issues/205
const convertToFileUrl = (filePath: string) => {
    if (filePath.startsWith("file://")) {
        return filePath;
    }
    return url.pathToFileURL(filePath).href;
};
/**
 * dynamic import() with hooks
 * @param specifier file path or package name
 * @param context
 */
export const dynamicImport = async (
    specifier: string,
    context: ResolverContext
): Promise<{
    exports: Record<string, unknown> | undefined;
}> => {
    for (const hook of importHooks) {
        const result = await hook(specifier, context);
        if (result) {
            return result;
        }
    }
    // if the `specifier` is not absolute path, it should be package name
    if (!path.isAbsolute(specifier)) {
        return {
            exports: await import(specifier),
        };
    }
    return {
        exports: await import(convertToFileUrl(specifier)),
    };
};

bun build --compileするコードに、@secretlint/resolverを使って、本来は動的にロードされる@secretlint/secretlint-rule-preset-recommendを静的にロードするようなHookを登録しています。 これによって、本来はimport(packageName)というコードで動的にロードされるパッケージを、import文で静的にロードしておくことで、バイナリに動的なパッケージを含めています。

import * as preset from "@secretlint/secretlint-rule-preset-recommend";
import { registerImportHook, registerResolveHook } from "@secretlint/resolver";

const mocks = {
    "@secretlint/secretlint-rule-preset-recommend": preset,
};
const mockNames = Object.keys(mocks);
const mockNameRegex = new RegExp(`^(?:${mockNames.join("|")})$`);
// preserve mocks module name
// "@secretlint/secretlint-rule-preset-recommend" -> "@secretlint/secretlint-rule-preset-recommend"
// By default, secretlint will resolve it into absolute path, but we want to keep it as is.
registerResolveHook((moduleName) => {
    if (mockNames.includes(moduleName)) {
        return {
            url: moduleName
        };
    }
    return undefined; // pass through
});
// import mock module
// "@secretlint/secretlint-rule-preset-recommend" -> { exports: preset }
registerImportHook(async (moduleName) => {
    if (mockNameRegex.test(moduleName)) {
        return {
            exports: mocks[moduleName as keyof typeof mocks]
        };
    }
    return undefined; // pass through
});

あとは、これらのHookを含むコードをbun build --compileでビルドするだけで、Single-file executable binaryが作成できます。

他の実装方法

Secretlintでは、Bunを使ってSingle-file executable binaryを作成しましたが、他にもいくつかの方法があります。 Denoにもdeno compileというコマンドがあり、Denoのコードをバイナリに変換できます。

Denoでの実装

deno compileの場合は、事前に動的にロードされるパッケージをimportしておくだけで、バイナリに含めることができます。

具体的には次のようなコードを書くだけです。

// preload for embedded binary
// TODO: use local file path instead of npm registry
// https://github.com/denoland/deno/issues/18474
import "npm:@secretlint/secretlint-rule-preset-recommend";
import { cli, run } from "npm:secretlint/cli";
run(cli.input, cli.flags).then(
    ({ exitStatus, stderr, stdout }) => {
        if (stdout) {
            console.log(stdout);
        }
        if (stderr) {
            console.error(stderr);
        }
        Deno.exit(exitStatus);
    },
    (error) => {
        console.error(error);
        Deno.exit(1);
    }
);

あとは、deno compileでビルドするだけで、Single-file executable binaryが作成できます。

deno compile --target "x86_64-unknown-linux-gnu" --output dist/secretlint src/entry.ts

最初はDenoで実装していましたが、この方法にはデメリットがあります。 npm: というspecifierを指定してることからもわかるように、npm registryからパッケージをダウンロードしたものをバイナリにしています。

そのため、CIでバイナリをビルドしてそのままテストするには、ローカルのNode.js向けのコードを参照してバイナリにする方法が必要です。 deno compileで、ローカルのNode.js向けのパッケージを参照して、それをバイナリにする方法を試してみましたがよくわかりませんでした。

Denoはnpmのworkspacesもサポートしているので、これを利用するとローカルのNode.js向けのパッケージをDenoで使うことはできます。

しかし、deno compileしたバイナリには、このローカルのNode.js向けのパッケージを含めることができませんでした。

また、Denoには必要なAPIがまだ実装されてなかったのもあり、今回はBunを使うことにしました。

Bun Plugin APIでの実装(失敗)

Secretlintの実装では、@secretlint/resolverというHookするパッケージを実装しています。 これは内部的なコードだから対応できましたが、外部のパッケージが同じような動的ロードをしている場合は、Hookすることが難しいです。

これを回避するためにBunのPlugin APIを使って、require.resolveimport(name)をHookする方法を試しましたが、うまくいきませんでした。

onResolverequire.resolveのパスを書き換えできれば、@secretlint/resolverのようなHookを実装できるかと思いましたが、うまくいきませんでした。 Bun Plugin APIのonResolveは、拡張子がない識別子をrequire.resolveするときに呼ばれないことがあったり、挙動がイマイチよくわからない感じでした。

import { plugin, type PluginBuilder } from "bun";

await plugin({
  name: "bun-test",
  target: "bun",
  setup(build: PluginBuilder): void | Promise<void> {
    build.onResolve({ filter: /.*/ }, async (args) => {
      console.log("onResolve", args);
      return args
    });
    build.onLoad({ filter: /.*/ }, async (args) => {
      console.log("onload", args);
      return {
        contents: "console.log('Hello, world!')",
        loader: "ts",
      };
    });
    build.module("rambda", () => {
      console.log("module load");
      return {
        exports: {},
        loader: "object"
      }
    })
  }
});

// resolve existing module
console.group("resolve - existing")
import.meta.resolve("rambda");
console.groupEnd()

// import existing module
console.group("import - existing")
await import("rambda");
console.groupEnd()

// resolve unknown extension module
console.group("resolve - unknown extension")
import.meta.resolve("rambda.ext");
console.groupEnd()

// import unknown extension module
console.group("import - unknown extension")
try {
  await import("rambda.ext");
} catch {
}
console.groupEnd()

// resolve file path
console.group("resolve - file path")
import.meta.resolve("./file-path.js");
console.groupEnd()

// import file path
console.group("import - file path")
try {
  await import("./file-path.js");
} catch {
}
console.groupEnd()

実行結果は次のようになります。

> bun run entry.ts

resolve - existing
undefined
import - existing
module load
undefined
resolve - unknown extension
onResolve {
  path: "rambda.ext",
  importer: "~/bun-plugin-resolver-test/entry.ts",
}
undefined
import - unknown extension
onResolve {
  path: "rambda.ext",
  importer: "~_bun-plugin-resolver-test/entry.ts",
}
undefined
resolve - file path
undefined
import - file path
onResolve {
  path: "./file-path.js",
  importer: "~/bun-plugin-resolver-test/entry.ts",
}

Bun内部のLoadersもこの仕組みで実装しているそうですが、挙動がイマイチよくわからない感じでした。すでに登録されているものがあると、呼ばれないとかそういうことがあったりするのかなと思いました。

ファイルパスではなく、パッケージのみを対象にする場合は、build.moduleというVirtual Modulesを扱うAPIを使うことで対応できます。 Secretlintだとrequire.resolveしたパスをimportしていたため、この方式は諦めました。

まとめ

Node.jsのツールをSingle-file executable binaryにする方法を紹介しました。 Secretlintでは、Bunを使ってSingle-file executable binaryを作成しています。

CIでSecretlintを実行する場合、起動までの時間が短いとCIのコストも抑えられます。 npmを使ってインストールする方式に比べて、secretlintのバイナリ1つで済むので、CIのセットアップ時間が短くなります。 いくつかのプロジェクトで試していた感じでは、CIのトータル時間は大体半分ぐらいになりました(setup時間の方が基本的に長いです)。

動的なパッケージロードするようなツールはちょっと工夫が必要でしたが、それ以外は特に問題なくバイナリにして動作させることができました。

bluenotiondbでも似た方式でバイナリにして使ってたりするので、Node.jsのツールをバイナリにする選択肢が増えて便利になったと思います。

]]>
JavaScript Primer v6.0.0リリース: ES2024の対応とNode.jsのユースケースを刷新 2024-09-02T09:00:00+09:00 https://efcl.info/2024/09/02/jsprimer-v6 <![CDATA[

JavaScript Primer v6.0.0をリリースしました 🎉

JavaScript Primer v6.0.0では、ECMAScript 2024の対応とNode.jsのユースケースを新たに増えたnode:utilparseArgs関数やnode:testを使うように書き直しています。

JavaScript Primer Sponsors

JavaScript Primer(jsprimer)では、Open Collectiveを通じてプロジェクトの更新に関わる資金を募っています。

今回のv6リリースにおいては、次の方々にご支援いただきました!

JavaScript Primerスポンサー

株式会社コクチョウさんは、Yearly Gold Sponsorとしてご支援いただいています。

ご支援ありがとうございます!

JavaScript Primerスポンサーについては、次のページを参照してください。

また、ご支援いただいた資金は、jsprimerにcontributionしてくれた方へ還元できるような仕組みを設計しました。 次回からは、この仕組みを回していけるようにしたいです。

JavaScript Primer v6.0.0の変更点

JavaScript Primer v6.0.0の変更点について紹介します。

リリースノートは次のページからも確認できます。

更新をメールで受け取りたい方は、次のフォームから登録できます。

JavaScript Primerの更新情報を購読

* indicates required

フォームが表示されない人は https://github.us13.list-manage.com/subscribe/post?u=fc41e11a2b9dc6f05350e0de0&id=7ab1594ae8 から登録できます。

変更のサマリ

ErrorCauseへの対応 by himanoa · Pull Request #1732

概要

ES2022で追加されたErrorcauseオプションは、エラーのスタックトレースを改善するために追加されました。 causeオプションを使うことで、エラーを再度投げ直す場合に、元のエラーのスタックトレースを維持しながら新しいエラーを投げることができます。

変更されたページ

変更内容

  • ErrorCauseオブジェクトの説明を追加
  • ウェブ版のREPLをErrorCauseに対応

改訂2版の電子版へのリンクを追加 by azu · Pull Request #1755

電子版の改訂2版が公開されたので、リンクを追加しました。

Map.groupBy静的メソッドの追加 by azu · Pull Request #1751

概要

ES2024では、配列の要素をグループ分けしたマップを作成するMap.groupBy静的メソッドが追加されています。

Map.groupBy静的メソッドはObject.groupBy静的メソッドとよく似たメソッドです。 Object.groupBy静的メソッドは配列からオブジェクトを作成するのに対して、Map.groupBy静的メソッドは配列からマップを作成します。

Map.groupBy静的メソッドも元々は、Array.prototype.groupToMapメソッドという配列のメソッドとして提案されていましたが、Object.groupBy静的メソッドに合わせる形でMap.groupBy静的メソッドに変更されました。

変更されたページ

変更内容

  • Map.groupBy静的メソッドの説明を追加

関連

Object.groupBy 静的メソッドを追加 by azu · Pull Request #1749

概要

ES2024では、配列の要素をグループ分けしたオブジェクトを作成するObject.groupBy静的メソッドが追加されています。 配列の要素をグループ化を簡潔に書けるようになっています。

Array.prototype.groupByのようなArrayのメソッドではないのは、既存のprototype拡張をしていたライブラリとの競合を避けるためです。 最初は、Array.prototype.groupByメソッドとして提案されていましたが、既存のウェブサイトとの互換性の問題が見つかったため、Object.groupBy静的メソッドに変更されました。

変更されたページ

変更内容

  • Object.groupBy静的メソッドの説明を追加

関連

Stage 2.7を追加 by azu · Pull Request #1743

概要

ECMAScript ProposalのStage 2.7を追加しました

ブラウザが実装しはじめて見つかる問題などによりステージ2と3を行き来してしまうことがあったためです。 そのため、実装の前のテストと検証のためのステージとして2.7が追加されました。

変更されたページ

関連

Node.js CLIのアップデート

Node.jsの標準パッケージがカバーする範囲が広がったため、Node.jsのユースケースを大幅に書き換えました。

commanderパッケージ を node:utilparseArg に変更 by azu · Pull Request #1757

概要

commanderパッケージは使わずに、Node.jsの標準モジュールであるnode:utilparseArg関数を使うように変更しています。

変更されたページ

変更内容

  • Node.jsでHello World
    • “Node.jsプロジェクトのセットアップ “を追加 (package.jsonの作成)
  • コマンドライン引数を処理する
    • commandernode:utilparseArgs関数に変更
    • デフォルト設定を定義する {#declare-default}セクションは削除(defaultオプションでいいため)
    • --gfmの呼び方をフラグに統一(オプションと呼んでる場所があった)
  • MarkdownをHTMLに変換する
    • npm install の説明を移動
    • [コラム] node: prefix を追加
      • 本文は node: に統一したので、逆に本文にはいらなくなった

関連

mochaをnode:testに変更する by windchime-yk · Pull Request #1737

概要

テストをMochaから標準モジュールのnode:testに変更しました

変更されたページ

変更内容

update to marked@14 by azu · Pull Request #1760

概要

markedパッケージを14にアップデートしました。

変更されたページ

変更内容

  • marked@4からmarked@14にアップデート
  • <h1 id=xxx> は付与されなくなったので出力から削除
    • auto linkについては言及してるが、idについては言及してないのでコードのみの変

おわりに

jsprimerでは毎年更新していけるような仕組み作りの一貫としてJavaScript Primer - Open Collectiveでの支援を募集しはじめました。サイト上へのロゴの掲載やリリースノートへのロゴの掲載などの特典を含んでいます。

また、文章の修正やコード的なコミットはいつでも歓迎しています!

大体毎年の1月ぐらいには、次のECMAScriptのリリースに合わせた計画を立て始めています。 この時期になると次のリリースに向けてのIssueが立ち始めるので、興味がある人はリポジトリをWatchしてください!

]]>
Node.jsで型安全な環境変数を扱うスニペット 2024-08-24T13:58:00+09:00 https://efcl.info/2024/08/24/type-safe-env <![CDATA[

Node.js で型安全な環境変数を扱うスニペットを作りました。

next devのようなアプリケーションの起動、Playwright でのテストなどコマンドごとに渡したい環境変数のセットが異なるケースがあります。 この場合に環境変数をまとめたものを定義して、それをコマンドごとに読み込むセットを変えたいことがあります。

次のようにベタ書きしてもいいのですが、渡したい環境変数が増えると管理が大変になります。

NEXT_PUBLIC_LOCALHOST_URL=http://localhost:3000 NEXT_PUBLIC_API_URL=http://localhost:3001 NEXT_PUBLIC_IS_TEST_MODE=false FOO="bar" next dev

そのため、.envのような環境変数をまとめたファイルを使いたくなります。 Node.js は--env-fileフラグで.envファイルを読み込むことができますが、.envファイルは型安全ではありません。

環境変数名の Typoや必須の環境変数が設定されていないなどの問題が発生する可能性があります。 そのため環境変数をの定義自体も TypeScript で型安全に定義したいです。

これをやるための 50 行ほどのスニペットを書いたので、使い方を紹介します。

  • 📝 主な用途はローカルやCIでの開発用で、実際にデプロイするサーバなどには利用しない想定です
  • 📝 ライブラリとかにしてないのは、この仕組み自体が外部パッケージの依存もなく短いスニペットだからです
    • JSからTSを参照する都合上、ライブラリにはちょっとしにくい気がします

サンプルリポジトリ

次の場所にサンプルリポジトリがあります。

使い方

大きく分けて、環境変数の型を定義するdefineEnv関数と、環境変数をセットするsetEnv関数があります。

環境変数の型を定義する

defineEnv関数を次のような受け取りたい環境変数の型定義をするUtilityです。

export type BaseEnvRecord = Record<
    string,
    {
        value: string | undefined;
        required: boolean;
        defaultValue?: string;
    }
>;
export type ReturnTypeOfCreateEnv<T extends BaseEnvRecord> = {
    // If the value is required, it should be a string, otherwise it should be a string or undefined
    [K in keyof T]: T[K]["required"] extends true ? string : string | undefined;
};
/**
 * Define environment variables and create them
 */
export const defineEnv = <T extends BaseEnvRecord>(envs: T): ReturnTypeOfCreateEnv<T> => {
    const result = new Map<string, string | undefined>();
    Object.entries(envs).forEach(([key, { value, required, defaultValue }]) => {
        if (required && !value && !defaultValue) {
            throw new Error(
                `Missing required environment variable: ${key}, value: ${value === undefined ? "undefined" : `"${value}"`}`
            );
        }
        result.set(key, value || defaultValue);
    });
    return Object.fromEntries(result) as ReturnTypeOfCreateEnv<T>;
};
  • env.ts: アプリケーション用の環境変数を定義する

env.tsでは、defineEnv関数を使ってアプリケーションで受け取りたい環境変数の型を定義します。

import { defineEnv } from "./defineEnv";

export const env = defineEnv({
  /**
   * Localhost URL
   */
  LOCALHOST_URL: {
    value: process.env["LOCALHOST_URL"],
    required: true,
    defaultValue: "http://localhost:3000",
  },
  /**
   * Is test mode?
   */
  IS_TEST_MODE: {
    value: process.env["IS_TEST_MODE"],
    required: true,
    defaultValue: "false",
  },
  /**
   * Optional value
   */
  OPTIONAL_VALUE: {
    value: process.env["OPTIONAL_VALUE"],
    required: false,
  },
});

このときに、Node.js ならprocess.envを使って環境変数を取得します。 値ごとにrequiredで必須かどうかの指定や、defaultValueでデフォルト値を指定できます。 具体的には、required: truedefaultValueが指定されていなくて、process.env.*の値がない場合はエラーになります。

この defineEnv関数で定義したenvはアプリケーションが使う環境変数をまとめたものです。

次のようにアプリケーションからはenvを import して使います。 defineEnv関数で定義した環境変数を型安全に使うことができます。

// use env
import { env } from "../env/env";
// type-safe
console.log("localhost url", env.LOCALHOST_URL);
console.log("Is Test Mode", env.IS_TEST_MODE);
console.log("OPTIONAL_VALUE", env.OPTIONAL_VALUE); // string or undefined

一方で、process.envに設定する環境変数自体も型安全に設定したいです。

環境変数をセットする

次のsetEnv関数を使って、プロセスに対して環境変数をセットします。

  • setEnv.js: 環境変数をセットする Utility

例として、次のようなenv.local.jsenv.test.jsのような環境変数をまとめたファイルを作ります。

  • env.local.js: ローカル開発用の環境変数をセットする
import { setEnv } from "./src/env/setEnv";

// local環境用の環境変数をセット
setEnv({
  LOCALHOST_URL: "http://localhost:3500",
  IS_TEST_MODE: "false",
});
  • env.test.js: テスト用の環境変数をセットする
import { setEnv } from "./src/env/setEnv";

// test環境用の環境変数をセット
setEnv({
  LOCALHOST_URL: "http://localhost:3500",
  IS_TEST_MODE: "true",
});

あとは、このenv.*.jsNODE_OPTIONSを使って読み込むことで、環境変数をセットできます。

例えば、env.local.jsを使って開発サーバーを起動する場合は次のようにします。

NODE_OPTIONS='--import ./env.local.js' npm run dev

テストを実行する場合は、env.test.jsを使って次のようにします。

NODE_OPTIONS='--import ./env.test.js' npm test

これで、npm run devnpm testなどのコマンドごとに異なる環境変数をセットできるようになります。

仕組み

defineEnv.tsの方はただのTypeScriptなのであまり問題ないと思います。

setEnv.jsの方は、TypeScriptではなくJavaScriptで書いていますが、checkJsを使って型チェックを行っています。

具体的には、次のようなtsconfig.jsonを使って、env.*.jsを型チェックしています。 allowJsを有効化していますが、通常はなんでも.jstscでは扱いたくないので、env.*.jsだけをincludesに指定しています

{
  "compilerOptions": {
    // ....
    // Type Check for env.*.js
    "allowJs": true,
    "checkJs": true
  },
  "include": [
    "**/*.ts",
    "**/*.tsx",
    // allow to check js files
    "./src/env/setEnv.js",
    "env.*.js"
  ],
  "exclude": [
    ".git",
    "node_modules"
  ]
}

setEnv.jsの方は、JSDocを使って受け取れる環境変数の型を定義しているのでcheckJsが有効になっていると型チェックが行われます。

import process from "node:process";

/**
 * set env util
 * @param {Partial<typeof import("./env").env>} env
 */
export const setEnv = (env) => {
    if (process.env["PRINT_ENV"] === "true") {
        console.table(env);
    }
    Object.entries(env).forEach(([key, value]) => {
        process.env[key] = value;
    });
};

FAQ

.envの代わりにenv.*.jsを使う理由

.envファイルは型安全ではないです。 env.*.jsはTypeScriptのcheckJs機能で型チェックされるため型安全です。

Node.js は--env-fileフラグで.envファイルを読み込むことができます。

しかし、NODE_OPTIONS="--env-file=.env"は許可されていません。

env.*.tsの代わりにenv.*.jsを使う理由

Node.js の--experimental-strip-typesはまだ実験的な機能です。

ts-nodeやtsxなどを使えば.tsでも書けますが、あえてTypeScriptの変換を入れるほどでもないのでenv.*.jsを使っています。

直接--importフラグを使わずにNODE_OPTIONSを使う理由

pnpmのようなパッケージマネージャはパッケージのbinをシェルスクリプトとしてインストールします。

例えば、node_modules/.bin/viteはシェルスクリプトとしてインストールされます。

そのため、nodeコマンドを使ってviteコマンドを実行できません。

node --import=./env.local.js node_modules/.bin/vite
# エラーになる

NODE_OPTIONS=optionsを使うことで、Node.jsプロセスにオプションを渡すことができます。

NODE_OPTIONS='--import ./env.local.js' node_modules/.bin/vite
# これなら動く

まとめ

Node.js で型安全な環境変数を扱うスニペットを作りました。

  • defineEnv関数で環境変数の型を定義
  • setEnv関数で環境変数をセット
  • NODE_OPTIONSenv.*.jsを読み込むことで、コマンドごとに異なる環境変数をセット

50行ほどのスニペットですが、環境変数を型安全に扱うことができるので結構便利でした。

参考

]]>
モバイル端末でのウェブアプリのデバッグ方法、Safari on iOS/Chrome on Android 2024-06-29T19:52:00+09:00 https://efcl.info/2024/06/29/debug-safari-chrome-on-mobile <![CDATA[

iOSのSafari、AndroidのChromeはそれぞれPCと連携してウェブアプリケーションをデバッグできます。 PCのSafariのWeb Inspector、ChromeのDevToolsと連携すれば、コンソールログやネットワーク、デバッガーなども利用できます。

スマホと繋いでWebサイトをデバッグする方法をまとめたページが見つけられなかったので、メモです。 ネットワークで繋いでデバッグもできたりするので、結構便利です。

Mobile Safari on iOS

iOSのSafariは、macOSのSafariと接続してログやネットワークの通信などを見るデバッグが可能です

iOSのSafariとmacOSのSafariを接続する

初回は次の設定が必要です

iOS

  1. 設定アプリを開く
  2. Safari の設定を開く
  3. 詳細を開く
  4. Webインスペクタ をONにする
iOS Settings

macOS

  1. Safariの設定を開く
  2. 詳細タブを開く
  3. Webデベロッパ用の機能を表示にチェックを入れる

macOS Safari

iOSのSafariとmacOSを接続してWeb Inspectorを表示する

  1. iPhoneとmacをUSBで接続します
    • 初回はデバイスを信頼しますか? というダイアログが出てきます
  2. iOSのSafariでデバッグしたいページを開く
  3. macOSのSafariの開発メニューから、Safariで開いているページを選択する

mmaOS Safari

これでmacOSのWeb InspectでiOSのSafariのデバッグでできます。

📝 初回はUSBで接続する必要があります。

ネットワークで接続したい場合は、”ネットワーク経由で接続”にチェックを入れてみてください。 (すでに入ってる場合は、一度外してから入れ直すなどすると、つながると思います。)

同じネットワークにいる場合は、USBを接続しなくても、iOSで開いているページをmacOSでデバッグできるようになります。 端末やタブが出てこない場合は、iOSのSafariを一回閉じたりすると出てくることがあります。

コンソールログを取る方法

  1. Web Inspectorのコンソールタブを開く
  2. 選択した項目をコピー または 選択部分を保存 でログをコピーできます

iOS Safari Console

ネットワークログを取る方法

  1. Web Inspectorのネットワークタブを開く
  2. HARを書き出すを選択

これでHARファイルを保存できます。

iOS Safari Network

HARファイルはリクエストとレスポンスが全て保存されているファイルになります。 ChromeのDevToolsやCharles/ProxymanなどのProxyツール、Playwrightなどのテストツールなども読み込みに対応しています。

そのサイトで行った通信が全て入ってるため、Cookieなどの認証情報も全て含まれています。 そのため、本番環境の通信内容を含むHARファイルを渡すことは避けてください。また信頼できない人へHARファイルを渡すことも避けてください。

本番環境の通信を含むHARファイルを渡す場合は、HAR File Sanitizerなどで余計な情報を削除してから渡すことが推奨されます。

Chrome on Android

Android開発者オプションを有効にする

設定 > デバイス情報 > ビルド番号連打すると、開発者向けオプションが有効された旨のメッセージが表示されます。

デバイスで USB デバッグを有効にする

Android端末をUSBでPCと繋ぐと「USBデバッグを許可しますか?」というダイアログが出た場合は、信頼するを選択してください。

手動で対応する場合は、Androidのバージョンによって設定が異なります。

  • Android 9(API レベル 28)以上: [設定] > [システム] > [詳細設定] > [開発者向けオプション] > [USB デバッグ]
  • Android 8.0.0(API レベル 26)および Android 8.1.0(API レベル 27): [設定] > [システム] > [開発者向けオプション] > [USB デバッグ]
  • Android 7.1(API レベル 25)以下: [設定] > [開発者向けオプション] > [USB デバッグ]

AndroidのChromeとPCのChromeを接続する

  1. AndroidとPCをUSBで接続します
  2. AndroidのChromeでデバッグしたいページを開く
  3. PCのchromeのロケーションバーより chrome://inspect/#devices にアクセス
  4. Androidで開いているタブが表示されるので、デバッグタブの”Inspect”ボタンをクリックします
  5. PCでDeveloper Toolsが起動します

詳細: Android デバイスのリモート デバッグ  |  Chrome DevTools  |  Chrome for Developers

コンソールログを取る方法

  1. Developer ToolsのConsoleタブを開く
  2. ログをコピーする

詳細: コンソールでメッセージをログに記録する  |  Chrome DevTools  |  Chrome for Developers

ネットワークログを取る方法

  1. Developer ToolsのNetworkタブを開く
  2. ダウンロードボタン↓(またはコンテキストメニューのSave all contents as HAR)を選択し、HARファイルを保存

詳細: ネットワーク機能のリファレンス  |  Chrome DevTools  |  Chrome for Developers

]]>
JavaScript PrimerのES2024対応を手伝ってくれるContributorとSponsorを募集しています 2024-03-21T10:00:00+09:00 https://efcl.info/2024/03/21/jsprimer-es2024-proposal <![CDATA[

JavaScript PrimerのES2024の対応を進めていく予定なので、 対応を手伝ってくれるContributorとjsprimerというプロジェクトを支援してくれるSponsorを募集しています。

追記(2024-03-22): Contributorを希望する方は集まりました。ありがとうございます!

JavaScript Primerスポンサーは引き続き募集しています!

Gold Sponsors

jsprimer sponsors

Supporters

jsprimer backers

3行サマリー

  • ES2024の対応を6月末までにやるマイルストーンを切りました
  • ES2024の対応を手伝ってくれるContributorを募集しています
  • Open Collectiveを始めたので、プロジェクトを支援してくれるSponsorを募集しています

jsprimerのES2024対応

jsprimerは毎年のECMAScriptの仕様改定にあわせてメジャーアップデートを行なっています。 次の更新は、2024年6月末ごろにリリースされる予定のECMAScript 2024に対応する予定です。

そのため、ES2024の対応とユースケースであるNode.jsに関するアップデートをしていくマイルストーンを切りました。

ES2024に対応するマイルストーンをざっとみると、大体ざっくり12-20日分ぐらいのタスクがあります。

v6(ES2024) Milestone

大きく分けて次の3つのグループに分かれています。

  • ES2024の対応
  • Stage 2.7の追加
  • Node.jsのユースケースの更新

ES2024の対応

ES2024ではいくつか機能が増えているので、それに対応するアップデートを行います。 ECMAScript 2024の対応 · Issue #1706というIssueに、ES2024の対応についてのタスクをまとめています。

具体的に対応したいIssueは次の3つになっています。

Array Groupingは恐らく対応が必須ですが、他の2つは必要なら対応する形になると思います。 実際に内容や入れる場所を考えてみて、それが読む人にとってほんとに必要な情報なのかどうかを考えて判断する形になると思います。

jsprimerでは、必ずしも新しい機能を網羅的に解説はしていません。 これはjsprimerでは、リファレンスを作ることは、目的ではないことだからです。

Stgae 2.7の追加

ECMAScriptの仕様策定プロセス自体が変更されているので、それを解説してるECMAScript · JavaScript Primer #jsprimerを更新する予定です。

多分これは自分がやるような気もしますが、やりたい人がいればコメントください。

Node.jsのユースケースの更新

Node.jsでCLIアプリ · JavaScript Primer #jsprimerの章を全体的にアップデートする予定です。

Meta: Node.jsのユースケースの更新 · Issue #1719というMeta Issueに依存関係をまとめています。 最近は、今まではnpmパッケージを使わないといけなかったものが、Node.jsの標準機能になったりしているので、それに対応する形のリファクタリングになります。

こちらは、サンプルコードのアップデートやそれにあわせた文章の変更が中心になります。 そのため、基本的に文章の意味合いを変えるような変更は少なくて、リファクタリング的な変更が中心になります。

jsprimerのContributorを募集しています

今回のjsprimerのES2024の対応を手伝ってくれるContributorを募集しています!

先ほども書いていましたが、おおよそざっくり12-20日ぐらいのタスクがあります。 jsprimerは文章のプロジェクトですが、textlintでのチェックやテストも多いので、必要以上に恐れずに手伝ってくれると嬉しいです。

Node.jsのユースケースの更新は、Node.jsの新しい機能を使うように変更するというのが主なので、そこに興味がある方は特に歓迎です。 こちらは、文章の意味合いはあまり変える必要はないので(コードが変わるのでそれに合わせた変更や流れの調整は必要です)、比較的やりやすいかと思います。

ただし、Node.jsはタスク間の依存関係がちょっとややこしいところが複数人だとちょっとやりにくいかもれないです。 (文章を自然に読めるようにするために未知のことに依存させないという方針なので、文章の並び替えが必要になります)

もし、やってみたい方とかがいれば、一度認識合わせのためにMTGしたり、レビューとかは当然やるのでコメントなどでお知らせください。 募集スレッドとして次のDiscussionを立てていますので、そちらでコメントしてもらえると嬉しいです。

皮算用になるため、まだやり方は決めていませんが、後述するOpen Collectiveの機能を使ってContributeに対して返せる仕組みも作っていく予定です。

jsprimerのSponsorを募集しています

jsprimerでは、jsprimer自体が変化をし続け、読んだ人が「変化に対応できる基礎を身につけること」を目的としています。 そのためjsprimerは、毎年のECMAScriptの更新に合わせてアップデートしています。 継続的にアップデートするには、継続的なサポートが重要だと考えています。

jsprimerでは、Open Collectiveを使ってSponsorを募集しています。 次のページから、毎年や毎月ごとに一定の金額を支援することができます。

Open CollectiveはGitHub Sponsorsと似たサービスですが、特定の個人ではなくjsprimerというプロジェクトに対して支援できます。 そして、Open Collectiveは支援された金額を、Contributorに対して分配することができます。 このやりとりをオープンに透明性を持ってできるのが、Open Collectiveの特徴です。

また、企業向けのスポンサーの特典として、次の特典を用意しています。

  • https://jsprimer.netのトップページへのロゴの掲載
  • リリースノートでのロゴの掲載
  • 好きなページへのアイコンの掲載/ページスポンサー(任意)

参考の情報として、https://jsprimer.netのアクセス数などの情報も公開しています。 毎月、ページビューは5-6万程度で、アクティブユーザーは2-3万程度いて、そのうち「読者」(3ページ以上読んだ人)は10%程度の2000人ぐらいになっています。 書籍なので、何度も見にくる人も多いという特性があります。

詳細はJavaScript Primerスポンサー · JavaScript Primer #jsprimerも参照してください。

元々jsprimerは、新しく会社に入った人などに「これ読んでおいて!」と渡せる書籍を作るという目的で始まりました。 色々なところで、そのような目的でjsprimerを使ってるという話は聞くようになったので、是非会社としての支援なども検討してみてください!

jsprimerは、2015年12月17日に開始したプロジェクトで、@azu@lacolacoでおおよそ8年ぐらいメンテナンスを続けています。

今回Open Collectiveで支援を募集するのは、次の2つの目的があります。

  • jsprimerの継続的な更新にかかっているコストを見える化する
  • 新しいContributorを増やしてプロジェクトを継続的に更新していく形を模索する

最適な方法はまだわかりませんが、継続的にアップデートする形を模索していくためにも、支援を募集しています!

まとめ

]]>
Kagi Searchをメインの検索エンジンとして使っている 2024-03-15T00:25:00+09:00 https://efcl.info/2024/03/15/kagi-search <![CDATA[

最近はGoogleではなくKagi Searchをメインの検索エンジンとして使っています。

Kagi Searchは$108/year($10/month)の有料の検索エンジンです。 広告モデルではない検索エンジンなので、有料のサブスクリプションモデルとなっています。

  • Plan Types | Kagi’s Docs
    • いくつかプランがあり、検索し放題のProfessionalプランが$10/monthです
    • Ultimate Plan ($25/month)だと外部のOpenAIのGPT 4とかClaude 3との連携とかも入ってきます

月に1-2万回ぐらいは検索することを考えると、 (108 / (10000 * 12)) * 150 で大体1検索が0.1円ぐらいのイメージですが、こちらもKagiのLLM機能は利用できるので、実質もう少しコスパは良いと思います。

検索ソースにはGoogle, Yandex, Mojeek, Braveなどのリソースを使っているので、検索結果自体はGoogleとそこまで変わらないと思います。

基本的な使い方ではGoogleで見つかったものがKagiで見つからなかったという経験はありません。もちろん、検索の量でGoogleより優れたものはないと思うので、量にフォーカスしたいときはGoogleを使えば良いと思います(逆に今は飽和的になって検索が逆に難しかったり、検索されてないところにコンテンツがあったりして、量でのカバーは難しい感じはします)

自分がKagiを使う一番理由は検索体験にあると思っています。

いいところ

  • Googleより良いと感じる検索結果が出しやすい(人による)
    • 日本語の検索結果はそこまで変わらない感じもする(後述する漢字だけ検索した時の問題はまだある)
      • フィルター系がビルトインであるので、絞り込みはしやすく感じる
    • 英語で検索した時に英語のリソースにマッチさせやすいので良い結果と感じることが多い
    • uBlacklistを使ってなくても、いかがでしたか?状態にはならない
      • スパムっぽいサイトとかを表示する回数は減った感覚がある
      • 後述するBlockやQuickAnswerを活用しているのもある
    • また、ビルトインでBlockや検索結果の優先度を変更する機能が入ってる
    • Redirects (URL Rewrites) | Kagi’s Docs という機能で、検索結果のURLを書き換える機能も持っている
  • ? を末尾につけると、LLMを使った検索結果のサマリを出してくれるQuick Answerが便利
  • Lensesで特定のサイトからの検索結果だけにフィルターできるのが便利
    • プログラミング関係(GitHubやStackoverflowなど)のサイトだけに絞ったりが、1 clickで切り替えできる
    • 日本の主要なブログだけを検索するLens
      • *.hatenablog.com, *.hatenablog.jp, *.hateblo.jp, *.hatenadiary.com, *.hatenadiary.jp, note.com, ameblo.jp, sizu.me, zenn.com, qiita.com
    • https://kagi.com/lenses/0Q9bHFmidnH3TfNAR3OYQKb0gyqDEzM7
    • レビューとか検索したいときに個人のブログを検索したい といった感じの用途でよく使う
  • 検索結果のOrder ByとTimeが素直な感じ
    • Filtering Results | Kagi’s Docs
    • Order By: recentで検索のソート順を新しいもの順にできる
    • Timeで24時間以内の結果にできる(ここはGoogleでもできる)
    • Verbatim modeを使うと、クエリの文字列が含まれているサイトだけが表示される
    • 全体的に素直な検索結果を扱える

だめなところ

  • 漢字だけのクエリだと中国語の結果が混ざることがある
    • Japanese / Chinese - Kagi Feedback をVoteしてください
    • RegionとLanguageの区別が部分的にしか実装されてないので、漢字だけだと両方出てくることがある
    • そこまで漢字の単語 一つだけ検索することは少ないので、数百回に1回ぐらいのイメージ
    • 漢字1つだけのケースは辞書的な検索な気はしてて、Wikipedia(!w bang)を指定したり、Googleに行ったり、もう少し明確なクエリにすることで回避してる
    • この問題をもう少し広く捉えると、ここを自動でやるのは検索的にかなり面倒そうな問題な気はしている。たとえば、ca-enとus-enでは求めてるものが違う、ca-frとca-enがあるとか
    • 自動でやり切るにはエッジケースが結構多そう(Googleはそれをやってるイメージだけど、丸まりすぎてしまう)
    • 人によって最適だと思う結果が結構違うので、クエリでその人の意図を表現できるような方向だと良い気はする
    • Ideaがある人はここに投稿すると良いと思う
    • Ideas for improving local/international/english search modalities - Kagi Feedback
  • 検索結果がGoogleより遅い

その他のTips

  • 「Kagi for Safari」を入れるとiOSでもKagiを使える
    • Google検索とかの代わりにKagiを表示してくれるSafari拡張になってる
    • ブロックの共有とかもされてるので、uBlacklistみたいな拡張をわざわざ入れなくてもPCと同じ検索体験になる
  • Bangs | Kagi’s Docs という機能で !g でGoogleにジャンプしたりできる
    • 自分はショートカットでGoogleに行きたかったので別の方法使ってる
    • Surfingkeys shortcut: kagi.com to google
    • 検索結果がダメだったら g をおして、Google検索をする

ユースケース

Kagi Searchの機能を使ったユースケースの紹介をいくつか書いています。 他のサービスでもブラウザ拡張などを使えば実現はできると思いますが、Kagi Search側の機能なのでPC/モバイル問わずどこからでも利用できるのが利点です。

AmazonのURLをシンプルする

Redirects (URL Rewrites) を使って、検索結果のAmazonのURLをシンプルにする。

^https://www.amazon.co.jp/([^/]+)/dp/(.*)|https://www.amazon.co.jp/dp/$2

npm docsを常に最新のバージョンにする

^https://docs.npmjs.com/cli/(v[^/]+)/(.*)|https://docs.npmjs.com/cli/$2

日本の主要なブログだけを検索する

次のLensesを使って、日本のブログだけに絞り込める

感想

Kagi Searchは有料の検索エンジンです。

万人におすすめできるというわけではないですが、プログラミングなどする人にとってはGoogleより扱いやすい検索エンジンだと感じる作りになってると思います。

Googleは賢いので、検索クエリに対してそれっぽい検索結果を出してしまうことがあります。1単語で検索した場合とかはこれが便利ですが、複数のクエリを組み合わせたような検索結果は求めてない結果が混ざることが多いです。

一方でKagiは素直な感じの検索エンジンなので、素直に書いたクエリの通りの検索結果になるというイメージです。 検索のデータとしてGoogleのものなども使っているので、Kagiでは見つからなかったけどGoogleでは見つかるというケースもあまりない印象です(特に思いつかなかった)。

これに加えて、LensやSort、Verbatimなども絞り込み系がアクセスしやすいので、扱いやすく感じます。 また、Kagiは https://kagi.com/settings?p=user_ranked どのドメインを出すか出さないかというパーソナライズも自分でちゃんと管理できます。

Googleは検索をちゃんとやろうとするほど余計な結果(もしかして)が混ざってくるのが不便でした。Kagiでは素直な検索が返ってくるので、検索したいことをちゃんと検索しやすいです。 また、曖昧な検索結果が欲しい時(何を調べればいいのかもまだわかってないことを調べる時)はQuick Answerを使うといった使い分けをしています。

使っていると、曖昧な検索と正確な検索?(絞り込んでいくような検索)を意識的に使い分けやすい作りになってるのかなと思いました。 前者はGoogleの方が考えずに使えると思いますが、後者になるとKagiの方が扱いやすい部分が多いと感じました。

Kagi Searchを使おうと思った理由としては

  • Google検索で絞り込んで行ったときに、意図してない結果が出てくるケースが最近多かったのでどうにかしたかった
  • 使ってみて、よくある懸念の「Googleで見つかるけどKagiでは見つからない」というケースはほとんどないと思った
  • Kagi search features | Kagi Blogとか基礎機能が細かくできてて良いと思った(設定画面をみるとわかる)
    • 過去にGoogleにあった機能もあるけど、Googleは機能を消してしまうので
    • LensとかBlockとかこういう機能は検索エンジン側に実装された方が管理が楽なので
  • Kagi Blogを読んでて方向性が結構面白いと思った
  • 試用期間(100回/month)だと機能を使いきれてなかったけど、まあ悪くなさそうと思って課金した
    • 最悪Googleとかに戻ればいいだけだし

最近ではWolframのFounderの人がKagiに入ったり、検索のフロントエンドとしてのKagiは結構面白いと思うので、それに期待して使ってる部分もあります。

一応、月100回までは無料で検索できるので試してみると良いと思いますが、正直100回ぐらいでいいかどうかはわからない感じはします。

機能というより体験的な部分に依存すると思うので、定常的に使わないと評価が難しいサービスだと思いました。

]]>
Twitter/Blueskyの自己ポストの全文検索サービスをNext.js App Router(RSC)で書きなおした方法/設計/感想 2024-02-26T16:18:00+09:00 https://efcl.info/2024/02/26/mytweets-next-rsc <![CDATA[

mytweetsという自分の Twitter/Bluesky の自己ポストの全部検索サービスをNext.js App Router(RSC)で書きなおしました。

mytweets は Twitter のアーカイブや Bluesky の API を使って自分のポストを S3 に保存しておき、 S3 Selectを使って全文検索ができる自分専用の Twilog のようなサービスです。

最初は CloudFront + Lambda@Edge + Next.js Pages Router で動かしていました。 その後、Next.js App Router が Stable になったので、App Router + React Server Components(RSC)で書きなおしました。

この記事では、Next.js Pages Router から Next.js App Router(RSC)に書きなおした話を紹介します。 ただし、この記事は発散的な内容になっているのと、あまり正確性が保証されてないので、個人的なメモ書きとして読んでください。

あまりにも長くなったので、あんまり読みやすくは書けませんでした。

📝 キャッシュとメモ化については特に触れていません

mytweets の動作

mytweets は、次のような動作をします。

  • クエリがない場合は、S3 Select から最新のポストを取得して表示
  • クエリを入力したら、S3 Select の API を使って全文検索を行い、結果を表示

この動画は、App Router + React Server Components(RSC)で動かしてるものを録画したものです。 表示的にファーストビューが出てからローディングが走って、結果を取得してポストを表示するという動作をしているので一般的なSPA (Single-page application)っぽく見えます。

実際のコードベース上では、クライアント側には Fetch API などは書いていません。 初期化のロード表示は、RSC + <Suspense> + useで実現しています。(静的な部分は SSR されているので、TTFB(Time to First Byte)が短いです。) 検索時の更新のロード表示は、Next.js のrouter.pushuseTransitionで実現しています。

App Router への移行のメモ

どのように移行したかを簡単に振り返ってみます。 メモ書きのようなものなので、かなり乱雑に書かれています。 具体的な変更だけ見たい人は、次の Pull Request を見てください。

大きく 3 つのステップで移行しました。

  1. App Router に移行
  2. RSC を使うように変更
  3. Suspense を使うように変更

1. App Router に移行

元々 mytweets は Next.js Pages Router で動いていました。

サーバ側の処理は、API Routesで S3 Select を叩く API を用意してるぐらいで、他はほぼクライアントの処理でした。 次のindex.tsxという一つのファイルに全部書いてあるような単純なページでした。

そのため、このindex.tsxに”use client”をつけて Client Component として移行すれば App Router でも動きます。

/pages/apiに定義するAPI Routesは、App Router でも動くのでサーバ側の処理はそのままにindex.tsxpages/からapp/に移動して、use clientをつけた Client Component に変更しました。

これで一旦 App Router で動くようになりました。 特に App Router の機能は使ってないですが、段階的に移行する際にはこのようなアプローチが利用できます。

参考:

2. RSC を使うように変更

このままでは、App Router の機能を使っていないので、RSC を使うように変更しました。 RSC をちゃんと使うために、コンポーネントが Client Component なのか RSC なのかが明確になっている必要があります。

これは、Client Component は RSC をインポートできないが、RSC は Client Component をインポートできるという不可逆性があるためです。 そのため、コンポーネントの境界を明確にする必要があります。

子\親 Client RSC Server Action
Client インポートできる インポートできない 呼べる(通信が発生)
RSC インポートできる インポートできる 呼べる(関数コール)

RSC はuseStateuseEffectなどは使えません。 インタラクティブな部分は、Client Component で行い、RSC はデータを受け取って表示するという形になります。 RSC は、サーバ側で処理されるので、そこで moment や marked のようなライブラリを使っても、クライアント側にはライブラリは含まれません。 (あくまで、処理結果だけがクライアントに渡される)

この境界を見極めるのが結構難しいですが、最悪 Client Component のままでも動作的には問題ないです。 そのため、mytweets で RSC を使う部分は、次のような目的を設定して進めていきました。

  • 基本コンセプトはクライアントサイドのサイズを削る目的

Client Component と RSC がツリーに混在することはありますが、基本的にはどちらかが上にいる形になります。

Client Component で RSC を包むような形は、Composition Patternsを使うとかけます。

"use client";
// children(RSCもOK) として ReactNode を受け取る
export const ClientComponent({ children }: ReactNode) {
  return <div>{children}</div>;
}

この書き方のユースケースとしては、枠を Client Component で作って、その中に RSC を入れてロード中は opacity を下げるというような使い方ができます。

mytweets でも入力して検索中の opacity を下げることでロード中を表現しています。

// Composition Patternを使う
// https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns
<ClientComponent>
  <RSC />
</ClientComponent>

逆に RSC の中に Client Component を入れるには単純にインポートして使うだけです。

多くの場合は、この形になって大枠を RSC に書いていき、部分的に Client Component を使うという形になります。 原理的にはIslands Architectureと同じで、大枠は静的な表示(RSC)にして、インタラクティブな部分(Client Component)を小さくしていくという形になります。

// RSCはClient Componentをインポートできる
<RSC>
  <ClientComponent />
</RSC>

Client Component と RSC のコンポーネントが横並びになる場合がかなり難しいです。

基本的には RSC をツリーの上に持ってきて、Client Compoent はツリーの下に持ってくると書きやすいと思います。 これ言い換えると、インタラクションがあるボタンやフォームなどは、範囲を限定しておくという形になります。 (再描画の範囲も小さくなるように書くというのが意識としては近いと思います)

// RSCが上にある形は、RSCからClient Componentをインポートできるので問題ない
<RSC>
  <ClientComponentX />
  <RSC_Y />
  <ClientComponentZ />
</RSC>

次の Client Component が上にある混在の仕方はかなり難しいので、基本的に避けた方が良さそうです。

// Composition Patternもやりにくいので基本的に避けたい
<ClientComponent>
  <RSC_Y />
  <ClientComponentX />
  <RSC_K />
</ClientComponent>

この辺を考えながえら、クライアントに不要なものを RSC に移行していくと、 最終的には、useStateuseEffectが必要ない部分が全部 RSC になりました。

擬似的なアプリの構造は、次のようになりました。

export const App = ({ searchParams }) => {
    const searchResults = await fetchS3Select(searchParams.query);
    return <TransitionContextProvider> {/* Client */}
        <SearchBox />  {/* Client */}
        <SearchResultContentWrapper>  {/* Client */}
                <SearchResultContent searchResults={searchResults} />  {/* Server */}
        </SearchResultContentWrapper>
    </TransitionContextProvider>
};

これに合わせて、データのリロード方法も RSC ベースに変更しています。 RSC はルーティングを移動すれば、もう一度 RSC の処理がよばれるので、ルーティングを移動することでデータのリロードができます。

内容 変更前 変更後
ベースの仕組み Pages Router + api/ App Router
API サーバ api/ で Rest API を作り、クライントから呼び出す RSC から関数としてサーバの処理を書いて呼ぶだけ
S3 Select の取得 api/ で S3 Select を叩いて、Stream として返して、クライアントから Fetch with Stream で取得しながら表示 pages.tsx で、S3 Select から取得して props で各コンポーネントに配るだけ
更新処理 入力欄が変更されたら、useEffect で Fetch して取得 → State を更新して描画し直す 入力欄が変更されたら、 router.push("/?q={検索]") へ移動するだけ(取得は pages.tsx に書かれてる仕組みがそのまま使われる)
初期ロード中の表示 Client 側で取得する。取得中は、isLoading の state(useState)を更新して、取得が終わったら state を更新する pages.tsx で、S3 Select から取得してし終わったらレンダリングするので、初期ロードはなし(ただし、S3 から取得できるまでページが表示されない)
更新中の表示 (初期ロードと同じ) Client 側で取得する。取得中は、isLoading の state(useState)を更新して、取得が終わったら state を更新する startTransition(() => router.push(...)) で更新中かの状態(state)を得て、更新中の表示を行う。この state を Context を通して、Client Component 間で共有して、いろいろな場所のローディング表示を行う。
URL 特に変化しない 入力に合わせて ?q=<クエリ> を更新していく

実際の Pull Request は、次の URL から見れます。

この時点でパフォーマンスは良くなり、クライアントからもuseEffectと Fetch でデータ取得をしていた複雑な部分がなくなりました。 コンポーネントの境界を考えたり、RSC と Client Component の組み合わせのためのコンポネーントは増えたりしますが、ロジック自体はかなりシンプルになりました。

  • FCP: 0.6s → 0.3s
  • LCP: 0.6s → 0.3s
  • Speed Index: 1.8s → 0.5s

perf App Router

3. Suspense を使うように変更

ここまでで、App Router + RSC で動くようになりました。 一方で、S3 Select の検索が終わるまで、ページが表示されないという問題があります。 S3 Select はファイルの上から下まで全文検索するので、ヒットしない場合は時間がかかります。

その間、ページが表示されないのは体験として良くないので、検索中もページが表示されるようにするために、Suspense を使うように変更しました。 Next.js のドキュメントだと Streaming という言葉が使われていますが、React の Suspense でロード中はプレースホルダーを表示する仕組みのことです。 (Fetch with Stream とは異なるものです)

RSC は、props として Promise を渡せるようになっています。

そのため、コンポーネントの Props として Promise を受け取り、そのコンポーネントを Suspense でラップすることで、ローディング中の表示を行うことができます。 受け取った Promise を unwrap するには、useを利用します。

先ほどのコードでは、awaitしていたので検索が終わるまでページが表示されない形になっていました。 次のようにawaitを外して Promise として、その Promise を検索結果を表示するコンポーネントに props として渡すだけです。

- const searchResults = await fetchS3Select(searchParams.query);
+ const searchResultsPromise = fetchS3Select(searchParams.query);

今までのコンポーネントの中でuseを使って Promise を unwrap(resolve した値を取得)してもいいのですが、promise を受け取るコンポーネントが気持ち悪いので、それ用のラッパーコンポーネントを定義しました。

// useでunwrapして渡すだけのコンポーネント
export const SearchResultContentStream = (props: {
  retPromise: Promise<FetchS3SelectResult>;
  screenName: string;
}) => {
  const { retPromise, ...other } = props;
  const ret = use(retPromise);
  return <SearchResultContent ret={ret} {...other} />;
};

もしくは、サーバ側のみで動くコンポーネントなら、async/awaitが利用できるので次のように書いても良いかもしれません。

export const SearchResultContentStream = async (props: {
  retPromise: Promise<FetchS3SelectResult>;
  screenName: string;
}) => {
  const ret = await props.retPromise;
  return <SearchResultContent ret={ret} {...other} />;
};

この Suspense と use を使ったラッパーコンポーネントを使った擬似的なアプリの構造は、次のようになりました。

export const App = ({ searchParams }) => {
  const searchResults = fetchS3Select(query); // waitしないでpromiseのまま扱う
  {/* Client */}
  return <TransitionContextProvider>
    {/* Client */}
    <SearchBox />
    <SearchResultContentWrapper>
      {/* Client */}
      <Suspense fallback={"Loading ..."}>
        {/* Server 中で use を使う*/}
        <SearchResultContentStream retPromise={searchResults} />
      </Suspense>
    </SearchResultContentWrapper>
  </TransitionContextProvider>;
};

これで、検索中もページが表示されるようになりました。 この変更により、検索が遅い場合でもページ自体は安定してすぐに表示されるようになりました。

📝 最良パターンは2より若干悪くなる(CLSもあるため)。最悪パターンは2よりかなり改善される

内容 変更前 変更後
ベースの仕組み Pages Router + api/ App Router
APIサーバ api/ でRest APIを作り、クライントから呼び出す RSCから関数としてサーバの処理を書いて呼ぶだけ
S3 Selectの取得 api/ でS3 Selectを叩いて、Streamとして返して、クライアントからFetch with Streamで取得しながら表示 pages.tsx で、S3 Selectから取得してpropsで各コンポーネントに配るだけ
更新処理 入力欄が変更されたら、useEffectでFetchして取得 → Stateを更新して描画し直す 入力欄が変更されたら、 router.push(”/?q={検索]”) へ移動するだけ(取得は pages.tsx に書かれてる仕組みがそのまま使われる)
初期ロード中の表示 Client側で取得する。取得中は、isLoadingのstate(useState)を更新して、取得が終わったらstateを更新する 更新点: <Suspense> を使う。<Suspense> と use を使うことで、部分的にローディング表示を組み込む。S3からの取得が完了する前からページは表示される。
更新中の表示 (初期ロードと同じ) Client側で取得する。取得中は、isLoadingのstate(useState)を更新して、取得が終わったらstateを更新する startTransition(() => router.push(...)) で更新中かの状態(state)を得て、更新中の表示を行う。このstateを Contextを通して、Client Component間で共有して、いろいろな場所のローディング表示を行う。
URL 特に変化しない 入力に合わせて ?q=<クエリ> を更新していく

実際の Pull Request は、次の URL から見れます。

感想

よく作られたサイトは クライアントサイドレンダリング だけでもほぼ RSC と同じことはできるけど、RSC はコンポーネントを分割する一種の規約なのでそれが強制される。 これは言い換えると、サイトが複雑化してきたときでもパフォーマンスが急激には悪くなりにくいという形になってる。 一般的に、クライアントサイドレンダリングだけだと開発が進んで複雑化してきたときに、同じパフォーマンスを維持するのはかなり難しくなる。

具体的には API が増えたときにどうするか、コンポーネントが増えた時にここは遅延ロードしないといけないとか、細かいことを色々考える必要がある。 これまでは GraphQL で必要なものだけを取得したり、初期表示に必要ないコンポーネントを遅延ロードするなどの対応をしてきている。

RSC だと

  • 必要なものだけ読み込む: RSC は必要なものだけを持ったコンポーネントをシリアライズしてクライアントに渡す仕組みになってる
  • 遅延ロード: Suspense を使ってStreamingでコンポーネントをロードする

パフォーマンスの悪化を避ける方法として、いらない処理を別のところに逃すというのは良くあることで、 RSC だと、この逃す場所として RSC と Server Action が増えたという感じがする。 必要になるまで読み込まないという考え方をなんでも取り込んでるのはQwikで、RSC の場合はシリアライズできる範囲としてコンポーネントと Promise ぐらいになっている。

一方で、Server Action は何も規約がないので、Web API を作る意識なくやってしまうと無法地帯となる可能性がある。 これは Next.js が柔軟性のためにフレームワークをしてない部分なので、この辺はもうちょっとフレームワークとしての仕組みが必要そう。

Next.js の App Router を見たときに、Client と Server で話を分けたくなるけど、実際には React Client Components/React Server Components/React Server Actions の 3 つに分かれる。

Why do Client Components get SSR’d to HTML? · reactwg/server-components · Discussion #4を見ると、クライアントとサーバというのは物理的なクライアントサーバの話ではないのがわかる。 React の元々あった Tree のことを Client Tree と呼んで、React Server Component と一緒にできたのを Server Tree と呼んでる。 (HTML を生成するものを”Client”と呼んで、“Client”にシリアライズしたデータを処理して渡すやつを”Server”と呼んでるだけ) そのため、“React の Client Component”ではなく”React Client の Component”という感じの意味合いになってる。

また、Client Component は RSC をインポートできないというルールを思い出すと、それぞれが扱うデータの範囲が異なるという感じがする。

名前 ユーザー入力 サーバーデータ
React Client Components 受け取る indirect read-only
React Server Components 受け取らない direct read-only
React Server Actions 受け取る direct read/write

📝 React Server Componets は searchParams でユーザー入力は受け取れるので全部ではない。またサーバーデータも読み書きできてしまうが、GET で Write は基本避けるので原則的な話。

RSC から Server Actions を呼ぶこともできるけど、その Server Actions を Cient Component から使い回すというやり方をすると事故る可能性がある。 これは、RSC がユーザー入力を基本的には受け取らない(searchParams はあるけど)けど、Client Component は受け取るという違いがある。 Server Actions から見るとどちらも同じ引数として渡ってくるので、この引数が安全なのかは基本的にわからない。

Server Actions は、クライアントとのインタラクティブ性がある API だったり、ユーザーに紐づかないデータ処理をサーバに逃すのに適している。 たとえば、郵便番号の検索して住所を返す処理とかフォームのバリデーションのような処理。

一方で、データを実際に Write するような処理は気をつけないといけないので、その辺はRoute Handlersの方が API として扱うには安全な感じがする。 もしくは、Server Actions で一旦受けてから、別のサーバの関数にバリデーションしてから渡すような形にするとか。 この辺が、結構あいまいになりやすい気がするので、ここはもうちょっと整理されるといいと思いました。

App Router は全体的に、需要を満たすための柔軟な機能を多めに入れている感じはします。 Page Router の場合は、最初はそこまでなんでもできるというものじゃなかった気はしますが、App Router は最初から Pages Router の superset として作られている感じはします。おそらくここが、複雑に感じる部分で、この辺が整理されるともっと使いやすくなると思う。

何が opt-in で何が opt-out なのかがわかりにくいのも、難しく感じる部分なのかもしれません。

opt-in opt-out
App Router キャッシュ
React Client Component(”use client”)
React Server Action(”use server”)

これは適当なテーブルなのでどこかにドキュメントが欲しい。

感想のサマリ

  • Next.js App Routerをちゃんと使うとパフォーマンスが落ちにくいサイトを作れるフレームワークになっている
  • 一方で、ただ乗れば作れるという感じではなく、ちゃんと設計する必要はある
  • 現状だと、フレームワークがフレームワークしてない部分もあるので、この辺は考えて扱う必要がある

こっからはメモ書き成分が多いです。 作りながら書いてたメモをコピペしてます。

技術的なメモ書き

実際に動かさないとやり方がわからなかった部分をメモ書きとして残しておきます。

Client Component 間のデータのやり取り

Islands Architectureと同じ話ですが、Client ComponentとRSCの境界を切っていくと、Client Component同士が離れた位置にあるけど、状態は同期したいというケースが出てきます。 入力中の表示を別の場所に出すとか、ロード中は色々なところにあるボタンをdisabledにしたとか、大枠をまたいで状態を共有したいというケースです。

この場合は、Client Component間で状態を共有する方法が必要です。

やったこと

具体的には次のようなTransitionContextProviderという Provider のラッパーコンポーネントを用意してる。 このコンポーネントは RSC からもインポートして埋め込むことができる。

"use client";
import { createContext, ReactNode, useContext, useState } from "react";

export type TransitionContext = {
  isLoadingTimeline: boolean;
  setIsLoadingTimeline: (isLoading: boolean) => void;
};
const TransitionContext = createContext<TransitionContext>({
  isLoadingTimeline: false,
  setIsLoadingTimeline: () => {},
});
export const TransitionContextProvider = (props: { children: ReactNode }) => {
  const [isLoadingTimeline, setIsLoadingTimeline] = useState(false);
  return (
    <TransitionContext.Provider
      value=
    >
      {props.children}
    </TransitionContext.Provider>
  );
};
export const useTransitionContext = () => {
  const context = useContext(TransitionContext);
  if (!context) {
    throw new Error(
      "useTransitionContext must be used within a TransitionContextProvider"
    );
  }
  return context;
};
  • Server では引数に setState を渡すということができないので、初期値を持たない Context Provider を作るにはラッパーが必要となる
  • RSC では、Client Component の境界のためにこういったラッパーコンポーネントを作るケースが結構ある
  • ここではContextを作っているけど、state管理のライブラリを使う場合も大体似た話になります

参考

ルーティングの移動中の判定

router.pushで移動中の表示をしたいというケース。 たとえば、移動中はローディング表示をしたいとか、ボタンクリックでロード中はボタンを disable にしたいというケース。

  • useTransition を使うとできる
  • router.push と const [isPending, startTransition] = useTransition(); を組み合わせる
// 移動中はisLoadingがtrueになる
const [isLoading, startTransition] = useTransition();
const handlers = useMemo(
  () => ({
    search: (query: string) => {
      startTransition(() => router.push(`/?q=${query}`));
    },
  }),
  []
);

これは、Server Action を呼ぶときにも利用できる。

この辺が、はっきりと Next.js のドキュメントには書かれてなくてかなりわかりにくいと思った。

問題

フォーカス管理とルーティング

  • input の状態とルーティングと同期できていない
    • 戻るで戻ったときにinputの値が残ったままになる
  • Vercel の公式サンプルもルーティングと input の同期するために <input key={key}/>という感じで key を変えて破棄している
  • key で破棄すると input のフォーカスも無くなるので、体験が悪い

Failed to load response data: No data found for resource with given identifier

参考

]]>
私のJavaScriptの情報収集法 2024年版 2024-02-08T13:52:00+09:00 https://efcl.info/2024/02/08/javascript-info <![CDATA[

個人的なJavaScriptの情報収集の方法についてまとめてみます。

JSer.infoなどをやっているので、JavaScriptの情報については色々な情報源を見るようにしています。 JSer.infoの範囲の中での情報源については、次の記事でまとめています。

この記事では、少しスコープを広げてJavaScriptの情報収集についてまとめてみます。

Scope

かなりスコープが広がってしまうので、万人向けの方法ではなく、個人的な情報収集方法としてまとめています。

この記事では、膨大な情報の中から見つけるというアプローチをとっているので、人によって向き不向きがあると思います。

情報収集の方法

情報の元となる情報源はさまざまなサイトや人になると思います。 しかし、そのサイトや人ごとに見ていくというのはかなり大変で、それ自体が大変になると見なくなる可能性が高いと思います。 そのため、情報収集においては、情報を自分が見やすいと思える場所に集めることが重要だと考えています。

人によってこの集める場所は異なりため、Twitter(X)、RSSリーダー、Notion、SlackやDiscordなど色々な場所があると思います。 自分の場合はRSSリーダーに情報が集まるようにしています。

RSSリーダー

技術的なサイトにはRSSフィードがついてることが多いので、自分はRSSリーダーにRSSフィードを集約しています。 また、後述しますがRSSフィードがないものも、欲しい情報源からRSSフィードを作るなどとしてRSSリーダーに集約しています。

RSSリーダーにはInoreaderを利用しています。 InoreaderのフロントエンドとしてIrodrを使っています。

自分の情報収集はメモまでがセットなので、RSSは基本的にPCでしか見ていません。 スマホで見つけたものに関しては、Pocketにあとで読む登録して、PocketのRSSフィードを購読しているので、それをPCで見るようにしています。 (最近音声メモも結構やり始めたので、この辺は変わるかも。ref superwhisperでの音声入力を試す | Web Scratch)

RSSフィードをまとめるフォルダは、LDRのレート分類をそのまま使っていて、レートに加えて一部特殊なフォルダのみで管理しています。

  • GitHubReleases: GitHubでWatchしてるリポジトリのリリース情報
  • ★★★★★ 重要なフィード、Pocketなどもここに入る
  • ★★★★☆ 興味がある情報が書かれているフィード
  • ★★★☆☆ デフォルト
  • ★★☆☆☆ あんまり興味はないけど、目に入れておきたいぐらいのフィード、一般的なニュースサイト
  • ★☆☆☆☆ ほとんど使ってない
  • ☆☆☆☆☆ 普段は全くみないけど、意識したタイミングで読みたいフィード
  • NewsLetter: ニュースレター

レート

基本的に、これを上から読んでいくだけです。後半になるほど、あんまり興味がない情報になっていくので、中身をちゃんと読む頻度が下がっていきます。 GitHubReleasesは特殊なフォルダで、後述する仕組みでGitHubのWatchしてるリポジトリごとのリリースやGitHubでの脆弱性情報などが集まっています。 NewsLetterも特殊なフォルダで、1週間ごとにまとめて更新されたりするので、みたい時にみるという感じの運用をしています。 NewsLetterをメールで見ていないのは、Inbox Zeroでメールを管理しているため溜まりがちなニュースレターは全部RSSフィードにしたためです。

購読しているRSSフィードの数は大体2000から3000ぐらいになっています。

2023年から増えているのは、GitHubリポジトリのリリースノートを自動的にRSSフィードとして購読する仕組みを作ったためです。 2024年2月時点での全てのRSSフィード数は3626に対して、GitHubリポジトリのRSSフィード数は1382なので、実際のブログとかのフィード数は2000前後という感じです。

RSSフィードは特に気にせずに購読することにしていて、手動で購読解除することはかなり少ないです(乗っ取りとか完全に興味がないものを外す程度)。 どちらかというとサイトが404になるなどして、自動的に購読解除されることが多いです。 この方針でRSSフィードを増やしていても、自動的に解除されるものも同じぐらいなので、実質的なRSSフィード数はそこまで変わってない印象です。 (GitHubは例外)

情報源となるリソース

ここからは、実際に購読してRSSフィードについてピックアップして紹介します。

色々なブログ

初っ端から曖昧ですが、色々なブログを購読しています。 一度見つけて気になったらとりあえず購読しています。

全てを列挙するのは難しいので、JavaScriptに関連するブログは次のサイトにまとめられています。

JSer.info Watch Listは、JSer.infoで2年以内に紹介したサイトを自動的にまとめているページです。 2024-02-08時点で、718紹介されていて、そのうちGitHubリポジトリが322なので、GitHubのリポジトリを除くと400ぐらいのサイトを紹介しています。 OPMLデータ(RSSフィードをまとめたリスト)も公開してるので、RSSリーダーにインポートすれば一気に購読できます。

GitHubを除いたサイトを1つのRSSフィードとしてまとめたものも公開しています。

GitHubリポジトリのリリースノート

先程も紹介しましたが、GitHubリポジトリのリリースノートを自動的にRSSフィードとして購読する仕組みを作っています。

GitHubリポジトリには、次のURLでリリースノートをRSSフィードとして購読できます。

https://github.com/azu/watch-rss/releases.atom

azu/watch-rssは、WatchしてるリポジトリのリリースノートをInoreaderで自動的に購読するための仕組みです。 そのため、リリースを追いたいなーと思ったリポジトリはWatchしておくと、自動的にリリースノートをRSSとして購読できるようになります。

欠点としては、GitHubのAPIでは全てを”Watch”してるリポジトリしか取得できないので、Customで”Releases”のみのWatchだと動きません。 WatchしまくるとNotificationは崩壊するので、Notificationを使ってる人は注意が必要です。

Release

APIで、Customで”Releases”のみのリポジトリを取得する方法を知っている方がいたら教えてください。

GitHubでStarしたリポジトリのリリースノート

Bandito.reでは、GitHub Starしたリポジトリのリリースノートをまとめて購読できます。 自分はとりあえずGitHub Starしてることが多いです。 ただ、Watchするかは意識しないと忘れるので、そういった見逃しがたまに回収できます。

GitHub Issue/PRの検索結果

現在は多くのオープンソースやECMAScriptの仕様などもGitHubで管理されています。 そのため、GitHubのIssueやPull Requestで重要なやりとりが行われることが増えています。

そういったIssueやPull Requestのやりとりを購読するために、GitHubの検索結果をRSSフィードとして購読しています。

github-search-rssは、検索クエリを書くことでその結果をRSSフィードとして作成するGitHub Actionsのリポジトリです。 特定のラベルがついたIssueやPull Request、特定のキーワードを含むリポジトリなどのRSSフィードを作成できます。

export const SEARCH_ITEMS: RSSItem[] = [
    // Issue
    {
        title: "microsoft/TypeScript Iteration Plan",
        query: "repo:microsoft/TypeScript is:issue label:Planning",
        TYPE: "ISSUE",
        link: `${BASE_URL}/typescript-iterator-plan.json`,
        homepage: "https://github.com/search?q=repo%3Amicrosoft%2FTypeScript+is%3Aissue+label%3APlanning"
    },
        {
        title: "mdn/content update content",
        query: "repo:mdn/content is:pr is:open",
        TYPE: "ISSUE",
        link: `${BASE_URL}/mdn-content.json`
    },
    {
        title: "Node.js notable changes",
        query: "repo:nodejs/node label:notable-change is:pr is:closed -label:doc",
        TYPE: "ISSUE",
        link: `${BASE_URL}/nodejs-notable.json`
    },
    {
        title: "whatwg/html changes",
        query: 'repo:whatwg/html is:pr label:"impacts documentation"',
        TYPE: "ISSUE",
        link: `${BASE_URL}/whatwg-html.json`
    }
];

自分が利用しているRSSフィードは次のページにまとめてあります。

具体的には、TypeScriptのロードマップや、Node.jsの重要な変更、ブラウザの仕様に対するポジション、MDNの更新、HTMLの仕様の更新などを購読しています。

これらを見てると、ウェブの仕様の具体的な流れが見えたりします。

たとえば、ブラウザや仕様にちゃんとした変更を入れようと思うと、2つの実装者が必要です。 今はChromeの人がかなり仕様を追加してるので、新しい機能を作った時にMozilla/WebKitに対して仕様の意見を聞くIssueを作っています。 またW3C Tagのデザインレビューも行うので、新しい仕様を検討するときは次のリポジトリにIssueが作成されます。

この仕様が進んで、実装されるとブラウザのリリースノートになって、互換テーブルとかにその情報が反映されます。

この辺の流れが結構見えたりするので、実際にリリースされるまでにどういう議論点があったのかをちょっとわかってる状態になるのでおすすめです。

GitHub Advisory Database

GitHub Advisory Databaseは、GitHubのセキュリティアドバイザリ情報を提供しています。 これらは言語ごとに提供されていて、JavaScriptの場合はnpmのパッケージの脆弱性情報が提供されています。 この更新もRSSフィードとして購読しています。

フォローしてる人からの情報

はてなブックマークでフォローしてる人のブックマーク

はてなブックマークには、自分がフォローしてるアカウントのブックマークをRSSフィードとして購読できます。

https://b.hatena.ne.jp/{user}/favorite.rss

📝 https://b.hatena.ne.jp/{user}/favorite にアクセスすると、RSSフィードのリンクがページ内に書かれています。

これによって、自分が気になる情報をブクマしてる人をフォローしておけば、その人がブックマークした記事を購読できます。

GitHubでフォローしてる人がStarしたリポジトリ

starseekerは、GitHubでフォローしてる人がStarしたリポジトリを購読できるサービスです。

GitHubでフォローしてる人は興味が似ていると思うので、そういったリポジトリを発見できます。

タグ検索

ブックマークサービスなどでは、タグ検索をしてその結果をRSSフィードとして購読できることがあります。

たとえば、はてなブックマークだと次のようなURLでタグ検索の結果をRSSフィードとして購読できます。

他にも、ブログサービスに似たような仕組みがあります。

アグリゲーションサイト

Menthasは、はてなブックマークを元にしたアグリゲーションサイトです。

企業テックブログRSSは、企業のブログを手動で集めて1つのRSSフィードとして購読できるサイトです。

ニュースレター

PR: JSer.infoも1週間に1度のページでJavaScriptの情報をまとめています。 JSer.infoスポンサーも募集しています。

まとめ

自分の場合は、RSSリーダーに情報を集約しているので、RSSフィードがあるものは基本的に購読しています。 メタ的なサイトや検索を使いながら、新しく見つけたサイトをRSSとして購読していくというのを繰り返しています。 ずっと同じ頻度で同じ質で更新されるようなサイトはかなり少ないと思うので、購読して読むものもだんだんと変わっていきます。 これによって、情報の新陳代謝ができるので、新しい情報を見つけることできるのではないかなと思いました。

細かいテクニック的な話だと、InoreaderはOPMLを購読できる仕組みがあります。

これは、OPMLのURLを指定することで、OPMLの中身が変わったら自動的に購読しているフィードが増減するという仕組みです。 これを使うことで、RSSを購読するという操作を意識しなくても、RSSを購読できるようにしています。

github-search-rssRSS Feeds for GitHub Advisory Databaseなどはこの仕組み(OPMLを生成してる)を利用して購読しています。購読するかどうかを考えるのは結構大変なので、その辺を自動化/他の操作に置き換えることで、情報の鮮度が保ちやすいのかなと思いました。

フォローしたら自動的に購読できるというのは、SNSやYoutubeなどではあると思うので、それをRSSリーダーに持ってきてるという感覚です。 今だとBluesky上のフィードで似たような仕組みを作れたりしそうなので、試して見ると面白いかもしれません。

]]>
#jsprimer week: 2024-02-05 - 2024-02-11 2024-02-05T21:11:00+09:00 https://efcl.info/2024/02/05/jsprimer-week <![CDATA[

先週のtextlint weekに引き続き、今週はjsprimerの開発に集中するjsprimer weekです。

目標としては、次のECMAScript仕様であるES2024に入るProposalが今週のTC39ミーティングで確定するので、それに合わせてjsprimerをどう更新するかを決めることです。

ES2024の変更はそこまで大きなものはこなさそうなので大丈夫そうですが、Node.jsの変更が多くなってるのでNode.jsでCLIアプリ · JavaScript Primer #jsprimerをどうするかをちょっと考えたいです。

また、企業でjsprimerの更新を支援できる仕組みとしてJavaScript Primer - Open Collectiveを作ってありますが、まだちゃんと運用できてないので、運用できるようにしたいです。

Open Collectiveの資金を使って、jsprimerの更新を手伝ってくれた方にもちゃんと労力にあった報酬を支払えるようにしたいです。

jsprimerの更新に興味があるって人は、それとなくコメントしてくれると嬉しいです。

ECMAScriptに合わせた更新は毎年やっているので、どういうことをやってるかは次のIssueを見るとイメージできると思います。 今年は、Array.groupByの更新ぐらいな気はしているので、そこまで大きな変更はないと思います。 どちらかというとNode.jsに関する更新は、どう変えると読みやすいかは考える必要がありそうです。

やる人が出てきたら、一回ミーティングをすると良いかもしれません。

]]>
Node.jsで機能やパッケージの非推奨メッセージを通知する方法 2024-02-04T12:10:00+09:00 https://efcl.info/2024/02/04/emitWarning <![CDATA[

ライブラリやツールなどを作っているときに、特定の機能やパッケージを非推奨にする場合があります。 これらの非推奨はリポジトリ上のREADMEやIssueなどに書いても、利用者が気づかないことがあります。 そのため、利用者が気付けるように非推奨の機能やパッケージを使った場合に警告を出す方法を紹介します。

非推奨にはいくつかの段階があり、それに応じてやり方を変えられるので、それぞれの方法を紹介します。

  • パッケージの非推奨化: npm deprecate <package> <message>
  • コードレベルの非推奨化: JSDocタグの@deprecated
  • 実行時の非推奨化: process.emitWarning()

パッケージの非推奨化: npm deprecate <package> <message>

npmのパッケージレベルで、そのパッケージが非推奨であることを通知するにはnpm deprecateコマンドを使います。

npm deprecate <package> <message>

<package>には非推奨にするパッケージ名を指定します。 <message>には非推奨にする理由を書きます。

@azu/test-packageパッケージを非推奨にする場合は次のようにします。

npm deprecate @azu/test-package "@azu/test-package is deprecated. use new-someting instead." 

非推奨とするパッケージのバージョンを指定することもできます。

npm deprecate @azu/[email protected] "@azu/[email protected] is deprecated. use new-someting instead." 

npm deprecateコマンドで非推奨にしたパッケージは、インストールしようとしたときに警告が表示されます。

$ npm install @azu/test-package
npm WARN deprecated @azu/[email protected]: @azu/test-package is deprecated. use new-someting instead.

added 1 package in 1s

また、npmのウェブサイト上で非推奨になっていることが表示されます。

npm web

コードレベルの非推奨化: JSDocタグの@deprecated

JSDocの@deprecatedタグを使うことで、コードレベルで非推奨な機能をエディタやIDEで警告することができます。

/**
 * @deprecated use new-someting instead.
 */
function oldFunction() {
    // ...
}

たとえば、textlint v14ではTextLintCoreというAPIを非推奨にしています。 このTextLintCoreには@deprecatedタグが付与されています。 そのため、このAPIを使おうとすると、エディタやIDEで打ち消し線と共に警告が表示されます。

@deprecated

TypeScriptでは、LSP(Language Server Protocol)側がこの@deprecatedタグをサポートしています。 そのためVSCodeやWebStormなど色々なエディタが、この表示をサポートしているはずです。

ただし、この表示はあくまでエディタ上での警告であり、実行時に警告を出すわけではありません。 そのため、普段編集しないコードだと気づかない場合があります。

実行時に警告を出すには、process.emitWarning()を使います。

実行時の非推奨化: process.emitWarning()

実行時に非推奨な機能を使った場合に警告を出すには、process.emitWarning(warning[, options])が利用できます。 console.warnなどのログ出力でもいいですが、Node.js向けのライブラリやツールならばprocess.emitWarning()を使うことで、警告を受け取る側も制御しやすくて便利です。

process.emitWarning()で非推奨メッセージを出す場合は、次のようにします。

process.emitWarning("This is deprecated", {
    type: "DeprecationWarning",
});

typeDeprecationWarningにすることで、Node.jsのフラグである--throw-deprecation/--no-deprecation/--trace-deprecationで利用者側が警告の動作を制御できます。 デフォルトは、警告メッセージがコンソールに出力されるだけですが、それぞれのフラグをnodeコマンドに渡すことで、警告の動作を制御できます。

  • --throw-deprecation を使うと、非推奨の警告が例外としてスローされます。
  • --no-deprecation を使うと、非推奨の警告が抑制されます。
  • --trace-deprecation を使うと、非推奨の警告がstderrに出力され、スタックトレースが表示されます。

たとえば、デフォルトでは次のような非推奨の警告が実行時に表示されます。

$ node index.mjs
(node:85465) DeprecationWarning: textlint: TextLintCore is deprecated. Please use new APIs https://github.com/textlint/textlint/issues/1310

You can control this deprecation message by Node.js command-line flags.

If the NODE_OPTIONS=--throw-deprecation is used, the deprecation warning is thrown as an exception rather than being emitted as an event.
If the NODE_OPTIONS=--no-deprecation is used, the deprecation warning is suppressed.
If the NODE_OPTIONS=--trace-deprecation is used, the deprecation warning is printed to stderr along with the full stack trace.


(Use `node --trace-deprecation ...` to show where the warning was created)

一方で、nodeコマンドに--throw-deprecationを渡すことで、非推奨の警告が例外としてスローされます。 加えて、スタックトレースも表示されるため、どこで非推奨のAPIを使っているかがわかります。

$ NODE_OPTIONS=--throw-deprecation node index.mjs
node:internal/process/warning:162
        throw warning;
        ^

DeprecationWarning: textlint: TextLintCore is deprecated. Please use new APIs https://github.com/textlint/textlint/issues/1310

You can control this deprecation message by Node.js command-line flags.

If the NODE_OPTIONS=--throw-deprecation is used, the deprecation warning is thrown as an exception rather than being emitted as an event.
If the NODE_OPTIONS=--no-deprecation is used, the deprecation warning is suppressed.
If the NODE_OPTIONS=--trace-deprecation is used, the deprecation warning is printed to stderr along with the full stack trace.


    at Logger.deprecate (/~/textlint-deprecated-example/node_modules/textlint/lib/src/util/logger.js:28:17)
    at TextLintCore.setupRules (/~/textlint-deprecated-example/node_modules/textlint/lib/src/DEPRECATED/textlint-core.js:98:25)
    at file:///~/textlint-deprecated-example/index.mjs:4:10
    at ModuleJob.run (node:internal/modules/esm/module_job:217:25)
    at async ModuleLoader.import (node:internal/modules/esm/loader:316:24)
    at async loadESM (node:internal/process/esm_loader:34:7)
    at async handleMainPromise (node:internal/modules/run_main:66:12)

Node.js v20.8.1

node --throw-deprecation index.mjsと書いても同じです。 コマンドラインツールはnodeにフラグを渡せないことの方が多いので、NODE_OPTIONS=--throw-deprecationの環境変数を使うのが便利です。

process.emitWarningに挙動を制御

console.warnとの違いは、非推奨のAPIを利用する側に、その警告の動作を制御できる標準的な方法が提供されていることです。 そのほかにもprocess.on("warning", (warning) => {})を使うことで、非推奨の警告を受け取ることができます。

詳細はドキュメントを参照してください。

textlint v14では、古いAPIの実行時警告にこのprocess.emitWarning()を使っています。

まとめ

ライブラリやツールで機能やパッケージを非推奨にする場合は、利用者が気づけるように警告を出すことが重要です。 この記事では、パッケージの非推奨化、コードレベルの非推奨化、実行時の非推奨化の3つの方法を紹介しました。

]]>