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 "**/*"
SecretlintはDockerも対応していますが、コマンドラインツールの場合は単体のバイナリとして使えると何かと便利です。
npm経由と比べて、Node.jsを必要としないのとダウンロードが1つのファイルだけなのでシンプルです。 Docker経由に比べて、Dockerを必要としないのと実行までの時間が早いはずです。 デメリットとしては、単体のバイナリはルールも同梱しないと配布できないので、ルールの追加が難しい点です。 (一応 node_modules 以下にルールがあれば参照できますが、それは実質npmに依存してるので素直にnpm使ったほうが良いです)
この secretlint
バイナリは、Bunを使ってランタイムも含んだSingle-file executable binaryとして配布しています。
この記事では、Bunを使ってNode.jsのツールのSingle-file executable binaryを配布する方法を紹介します。
Bunは、Node.js互換のランタイムです。 Bun 1.0が出てから1年ほど経ちますが、Node.jsのコードは結構そのまま動きます。 Secretlintは、特別なBunの対応はすることなく、そのままBunでも実行できました。
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にするには工夫が必要です。
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)
をラップした関数を提供しています。
それと合わせて、registerResolveHook
とregisterImportHook
という関数を提供して、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 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 compile
での実装: https://github.com/secretlint/secretlint/tree/d23e72dec453dbbdb9a7e0fd58ca56e91af30dcd/publish/binary-compiler最初は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を使うことにしました。
Secretlintの実装では、@secretlint/resolver
というHookするパッケージを実装しています。
これは内部的なコードだから対応できましたが、外部のパッケージが同じような動的ロードをしている場合は、Hookすることが難しいです。
これを回避するためにBunのPlugin APIを使って、require.resolve
やimport(name)
をHookする方法を試しましたが、うまくいきませんでした。
onResolve
でrequire.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をリリースしました 🎉
JavaScript Primer v6.0.0では、ECMAScript 2024の対応とNode.jsのユースケースを新たに増えたnode:util
のparseArgs
関数やnode:test
を使うように書き直しています。
JavaScript Primer(jsprimer)では、Open Collectiveを通じてプロジェクトの更新に関わる資金を募っています。
今回のv6リリースにおいては、次の方々にご支援いただきました!
株式会社コクチョウさんは、Yearly Gold Sponsorとしてご支援いただいています。
ご支援ありがとうございます!
JavaScript Primerスポンサーについては、次のページを参照してください。
また、ご支援いただいた資金は、jsprimerにcontributionしてくれた方へ還元できるような仕組みを設計しました。 次回からは、この仕組みを回していけるようにしたいです。
JavaScript Primer v6.0.0の変更点について紹介します。
リリースノートは次のページからも確認できます。
更新をメールで受け取りたい方は、次のフォームから登録できます。
フォームが表示されない人は https://github.us13.list-manage.com/subscribe/post?u=fc41e11a2b9dc6f05350e0de0&id=7ab1594ae8 から登録できます。
node:util
の parseArg
に変更ES2022で追加されたError
のcause
オプションは、エラーのスタックトレースを改善するために追加されました。
cause
オプションを使うことで、エラーを再度投げ直す場合に、元のエラーのスタックトレースを維持しながら新しいエラーを投げることができます。
ErrorCause
オブジェクトの説明を追加電子版の改訂2版が公開されたので、リンクを追加しました。
Map.groupBy
静的メソッドの追加 by azu · Pull Request #1751ES2024では、配列の要素をグループ分けしたマップを作成する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 #1749ES2024では、配列の要素をグループ分けしたオブジェクトを作成するObject.groupBy
静的メソッドが追加されています。
配列の要素をグループ化を簡潔に書けるようになっています。
Array.prototype.groupBy
のようなArrayのメソッドではないのは、既存のprototype拡張をしていたライブラリとの競合を避けるためです。
最初は、Array.prototype.groupBy
メソッドとして提案されていましたが、既存のウェブサイトとの互換性の問題が見つかったため、Object.groupBy
静的メソッドに変更されました。
Object.groupBy
静的メソッドの説明を追加ECMAScript ProposalのStage 2.7を追加しました
ブラウザが実装しはじめて見つかる問題などによりステージ2と3を行き来してしまうことがあったためです。
そのため、実装の前のテストと検証のためのステージとして2.7
が追加されました。
Node.jsの標準パッケージがカバーする範囲が広がったため、Node.jsのユースケースを大幅に書き換えました。
node:util
の parseArg
に変更 by azu · Pull Request #1757commander
パッケージは使わずに、Node.jsの標準モジュールであるnode:util
のparseArg
関数を使うように変更しています。
package.json
の作成)commander
を node:util
の parseArgs
関数に変更デフォルト設定を定義する {#declare-default}
セクションは削除(defaultオプションでいいため)--gfm
の呼び方をフラグに統一(オプションと呼んでる場所があった)npm install
の説明を移動node:
に統一したので、逆に本文にはいらなくなったnode:test
に変更する by windchime-yk · Pull Request #1737テストをMochaから標準モジュールのnode:test
に変更しました
node:test
に変更test
フォルダが指定されていることも加味してnode --test
でテストを実行するように変更marked
パッケージを14
にアップデートしました。
marked@4
からmarked@14
にアップデート<h1 id=xxx>
は付与されなくなったので出力から削除
jsprimerでは毎年更新していけるような仕組み作りの一貫としてJavaScript Primer - Open Collectiveでの支援を募集しはじめました。サイト上へのロゴの掲載やリリースノートへのロゴの掲載などの特典を含んでいます。
また、文章の修正やコード的なコミットはいつでも歓迎しています!
大体毎年の1月ぐらいには、次のECMAScriptのリリースに合わせた計画を立て始めています。 この時期になると次のリリースに向けてのIssueが立ち始めるので、興味がある人はリポジトリをWatchしてください!
]]>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 行ほどのスニペットを書いたので、使い方を紹介します。
次の場所にサンプルリポジトリがあります。
大きく分けて、環境変数の型を定義する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
では、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: true
で defaultValue
が指定されていなくて、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
関数を使って、プロセスに対して環境変数をセットします。
例として、次のようなenv.local.js
とenv.test.js
のような環境変数をまとめたファイルを作ります。
import { setEnv } from "./src/env/setEnv";
// local環境用の環境変数をセット
setEnv({
LOCALHOST_URL: "http://localhost:3500",
IS_TEST_MODE: "false",
});
import { setEnv } from "./src/env/setEnv";
// test環境用の環境変数をセット
setEnv({
LOCALHOST_URL: "http://localhost:3500",
IS_TEST_MODE: "true",
});
あとは、このenv.*.js
をNODE_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 dev
やnpm test
などのコマンドごとに異なる環境変数をセットできるようになります。
defineEnv.ts
の方はただのTypeScriptなのであまり問題ないと思います。
setEnv.js
の方は、TypeScriptではなくJavaScriptで書いていますが、checkJsを使って型チェックを行っています。
具体的には、次のようなtsconfig.json
を使って、env.*.js
を型チェックしています。
allowJs
を有効化していますが、通常はなんでも.js
をtsc
では扱いたくないので、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;
});
};
.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_OPTIONS
でenv.*.js
を読み込むことで、コマンドごとに異なる環境変数をセット50行ほどのスニペットですが、環境変数を型安全に扱うことができるので結構便利でした。
iOSのSafari、AndroidのChromeはそれぞれPCと連携してウェブアプリケーションをデバッグできます。 PCのSafariのWeb Inspector、ChromeのDevToolsと連携すれば、コンソールログやネットワーク、デバッガーなども利用できます。
スマホと繋いでWebサイトをデバッグする方法をまとめたページが見つけられなかったので、メモです。 ネットワークで繋いでデバッグもできたりするので、結構便利です。
iOSのSafariは、macOSのSafariと接続してログやネットワークの通信などを見るデバッグが可能です
初回は次の設定が必要です
iOS
macOS
これでmacOSのWeb InspectでiOSのSafariのデバッグでできます。
📝 初回はUSBで接続する必要があります。
ネットワークで接続したい場合は、”ネットワーク経由で接続”にチェックを入れてみてください。 (すでに入ってる場合は、一度外してから入れ直すなどすると、つながると思います。)
同じネットワークにいる場合は、USBを接続しなくても、iOSで開いているページをmacOSでデバッグできるようになります。 端末やタブが出てこない場合は、iOSのSafariを一回閉じたりすると出てくることがあります。
これでHARファイルを保存できます。
HARファイルはリクエストとレスポンスが全て保存されているファイルになります。 ChromeのDevToolsやCharles/ProxymanなどのProxyツール、Playwrightなどのテストツールなども読み込みに対応しています。
そのサイトで行った通信が全て入ってるため、Cookieなどの認証情報も全て含まれています。 そのため、本番環境の通信内容を含むHARファイルを渡すことは避けてください。また信頼できない人へHARファイルを渡すことも避けてください。
本番環境の通信を含むHARファイルを渡す場合は、HAR File Sanitizerなどで余計な情報を削除してから渡すことが推奨されます。
設定 > デバイス情報 > ビルド番号連打すると、開発者向けオプションが有効された旨のメッセージが表示されます。
Android端末をUSBでPCと繋ぐと「USBデバッグを許可しますか?」というダイアログが出た場合は、信頼するを選択してください。
手動で対応する場合は、Androidのバージョンによって設定が異なります。
chrome://inspect/#devices
にアクセス詳細: Android デバイスのリモート デバッグ | Chrome DevTools | Chrome for Developers
詳細: コンソールでメッセージをログに記録する | Chrome DevTools | Chrome for Developers
詳細: ネットワーク機能のリファレンス | Chrome DevTools | Chrome for Developers
]]>JavaScript PrimerのES2024の対応を進めていく予定なので、 対応を手伝ってくれるContributorとjsprimerというプロジェクトを支援してくれるSponsorを募集しています。
追記(2024-03-22): Contributorを希望する方は集まりました。ありがとうございます!
JavaScript Primerスポンサーは引き続き募集しています!
Gold Sponsors
Supporters
jsprimerは毎年のECMAScriptの仕様改定にあわせてメジャーアップデートを行なっています。 次の更新は、2024年6月末ごろにリリースされる予定のECMAScript 2024に対応する予定です。
そのため、ES2024の対応とユースケースであるNode.jsに関するアップデートをしていくマイルストーンを切りました。
ES2024に対応するマイルストーンをざっとみると、大体ざっくり12-20日分ぐらいのタスクがあります。
大きく分けて次の3つのグループに分かれています。
ES2024ではいくつか機能が増えているので、それに対応するアップデートを行います。 ECMAScript 2024の対応 · Issue #1706というIssueに、ES2024の対応についてのタスクをまとめています。
具体的に対応したいIssueは次の3つになっています。
Array Groupingは恐らく対応が必須ですが、他の2つは必要なら対応する形になると思います。 実際に内容や入れる場所を考えてみて、それが読む人にとってほんとに必要な情報なのかどうかを考えて判断する形になると思います。
jsprimerでは、必ずしも新しい機能を網羅的に解説はしていません。 これはjsprimerでは、リファレンスを作ることは、目的ではないことだからです。
ECMAScriptの仕様策定プロセス自体が変更されているので、それを解説してるECMAScript · JavaScript Primer #jsprimerを更新する予定です。
多分これは自分がやるような気もしますが、やりたい人がいればコメントください。
Node.jsでCLIアプリ · JavaScript Primer #jsprimerの章を全体的にアップデートする予定です。
Meta: Node.jsのユースケースの更新 · Issue #1719というMeta Issueに依存関係をまとめています。 最近は、今まではnpmパッケージを使わないといけなかったものが、Node.jsの標準機能になったりしているので、それに対応する形のリファクタリングになります。
node:test
に変更する · Issue #1717こちらは、サンプルコードのアップデートやそれにあわせた文章の変更が中心になります。 そのため、基本的に文章の意味合いを変えるような変更は少なくて、リファクタリング的な変更が中心になります。
今回のjsprimerのES2024の対応を手伝ってくれるContributorを募集しています!
先ほども書いていましたが、おおよそざっくり12-20日ぐらいのタスクがあります。 jsprimerは文章のプロジェクトですが、textlintでのチェックやテストも多いので、必要以上に恐れずに手伝ってくれると嬉しいです。
Node.jsのユースケースの更新は、Node.jsの新しい機能を使うように変更するというのが主なので、そこに興味がある方は特に歓迎です。 こちらは、文章の意味合いはあまり変える必要はないので(コードが変わるのでそれに合わせた変更や流れの調整は必要です)、比較的やりやすいかと思います。
ただし、Node.jsはタスク間の依存関係がちょっとややこしいところが複数人だとちょっとやりにくいかもれないです。 (文章を自然に読めるようにするために未知のことに依存させないという方針なので、文章の並び替えが必要になります)
もし、やってみたい方とかがいれば、一度認識合わせのためにMTGしたり、レビューとかは当然やるのでコメントなどでお知らせください。 募集スレッドとして次のDiscussionを立てていますので、そちらでコメントしてもらえると嬉しいです。
皮算用になるため、まだやり方は決めていませんが、後述するOpen Collectiveの機能を使ってContributeに対して返せる仕組みも作っていく予定です。
jsprimerでは、jsprimer自体が変化をし続け、読んだ人が「変化に対応できる基礎を身につけること」を目的としています。 そのためjsprimerは、毎年のECMAScriptの更新に合わせてアップデートしています。 継続的にアップデートするには、継続的なサポートが重要だと考えています。
jsprimerでは、Open Collectiveを使ってSponsorを募集しています。 次のページから、毎年や毎月ごとに一定の金額を支援することができます。
Open CollectiveはGitHub Sponsorsと似たサービスですが、特定の個人ではなくjsprimerというプロジェクトに対して支援できます。 そして、Open Collectiveは支援された金額を、Contributorに対して分配することができます。 このやりとりをオープンに透明性を持ってできるのが、Open Collectiveの特徴です。
また、企業向けのスポンサーの特典として、次の特典を用意しています。
参考の情報として、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つの目的があります。
最適な方法はまだわかりませんが、継続的にアップデートする形を模索していくためにも、支援を募集しています!
最近はGoogleではなくKagi Searchをメインの検索エンジンとして使っています。
Kagi Searchは$108/year($10/month)の有料の検索エンジンです。 広告モデルではない検索エンジンなので、有料のサブスクリプションモデルとなっています。
月に1-2万回ぐらいは検索することを考えると、 (108 / (10000 * 12)) * 150
で大体1検索が0.1円ぐらいのイメージですが、こちらもKagiのLLM機能は利用できるので、実質もう少しコスパは良いと思います。
検索ソースにはGoogle, Yandex, Mojeek, Braveなどのリソースを使っているので、検索結果自体はGoogleとそこまで変わらないと思います。
基本的な使い方ではGoogleで見つかったものがKagiで見つからなかったという経験はありません。もちろん、検索の量でGoogleより優れたものはないと思うので、量にフォーカスしたいときはGoogleを使えば良いと思います(逆に今は飽和的になって検索が逆に難しかったり、検索されてないところにコンテンツがあったりして、量でのカバーは難しい感じはします)
自分がKagiを使う一番理由は検索体験にあると思っています。
?
を末尾につけると、LLMを使った検索結果のサマリを出してくれるQuick Answerが便利
*.hatenablog.com, *.hatenablog.jp, *.hateblo.jp, *.hatenadiary.com, *.hatenadiary.jp, note.com, ameblo.jp, sizu.me, zenn.com, qiita.com
!w
bang)を指定したり、Googleに行ったり、もう少し明確なクエリにすることで回避してる!g
でGoogleにジャンプしたりできる
g
をおして、Google検索をするKagi Searchの機能を使ったユースケースの紹介をいくつか書いています。 他のサービスでもブラウザ拡張などを使えば実現はできると思いますが、Kagi Search側の機能なのでPC/モバイル問わずどこからでも利用できるのが利点です。
Redirects (URL Rewrites) を使って、検索結果のAmazonのURLをシンプルにする。
^https://www.amazon.co.jp/([^/]+)/dp/(.*)|https://www.amazon.co.jp/dp/$2
^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を使おうと思った理由としては
最近ではWolframのFounderの人がKagiに入ったり、検索のフロントエンドとしてのKagiは結構面白いと思うので、それに期待して使ってる部分もあります。
一応、月100回までは無料で検索できるので試してみると良いと思いますが、正直100回ぐらいでいいかどうかはわからない感じはします。
機能というより体験的な部分に依存すると思うので、定常的に使わないと評価が難しいサービスだと思いました。
]]>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 は、次のような動作をします。
この動画は、App Router + React Server Components(RSC)で動かしてるものを録画したものです。 表示的にファーストビューが出てからローディングが走って、結果を取得してポストを表示するという動作をしているので一般的なSPA (Single-page application)っぽく見えます。
実際のコードベース上では、クライアント側には Fetch API などは書いていません。
初期化のロード表示は、RSC + <Suspense>
+ useで実現しています。(静的な部分は SSR されているので、TTFB(Time to First Byte)が短いです。)
検索時の更新のロード表示は、Next.js のrouter.push
とuseTransitionで実現しています。
どのように移行したかを簡単に振り返ってみます。 メモ書きのようなものなので、かなり乱雑に書かれています。 具体的な変更だけ見たい人は、次の Pull Request を見てください。
大きく 3 つのステップで移行しました。
元々 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.tsx
をpages/
からapp/
に移動して、use client
をつけた Client Component に変更しました。
これで一旦 App Router で動くようになりました。 特に App Router の機能は使ってないですが、段階的に移行する際にはこのようなアプローチが利用できます。
参考:
このままでは、App Router の機能を使っていないので、RSC を使うように変更しました。 RSC をちゃんと使うために、コンポーネントが Client Component なのか RSC なのかが明確になっている必要があります。
これは、Client Component は RSC をインポートできないが、RSC は Client Component をインポートできるという不可逆性があるためです。 そのため、コンポーネントの境界を明確にする必要があります。
子\親 | Client | RSC | Server Action |
---|---|---|---|
Client | インポートできる | インポートできない | 呼べる(通信が発生) |
RSC | インポートできる | インポートできる | 呼べる(関数コール) |
RSC はuseState
やuseEffect
などは使えません。
インタラクティブな部分は、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 に移行していくと、
最終的には、useState
やuseEffect
が必要ない部分が全部 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 の組み合わせのためのコンポネーントは増えたりしますが、ロジック自体はかなりシンプルになりました。
ここまでで、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
が利用できるので次のように書いても良いかもしれません。
use
はClient Component/RSCどちらでも動くが、Async/AwaitはRSCのみで動く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 だと、この逃す場所として 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”) |
これは適当なテーブルなのでどこかにドキュメントが欲しい。
こっからはメモ書き成分が多いです。 作りながら書いてたメモをコピペしてます。
実際に動かさないとやり方がわからなかった部分をメモ書きとして残しておきます。
Islands Architectureと同じ話ですが、Client ComponentとRSCの境界を切っていくと、Client Component同士が離れた位置にあるけど、状態は同期したいというケースが出てきます。 入力中の表示を別の場所に出すとか、ロード中は色々なところにあるボタンをdisabledにしたとか、大枠をまたいで状態を共有したいというケースです。
この場合は、Client Component間で状態を共有する方法が必要です。
やったこと
<Provider value={ setState } />
みたいな技は使えない具体的には次のような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;
};
参考
router.push
で移動中の表示をしたいというケース。
たとえば、移動中はローディング表示をしたいとか、ボタンクリックでロード中はボタンを disable にしたいというケース。
useTransition
を使うとできる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 key={key}/>
という感じで key を変えて破棄している
key
で破棄すると input のフォーカスも無くなるので、体験が悪いNext.js RSCのpayloadが"Failed to load response data: No data found for resource with given identifier"のエラーで見えないの、
— azu (@azu_re) February 24, 2024
Chrome DevToolsでChrome DevToolsにdebuggerを入れてみると、https://t.co/U9fdVQBLNshttps://t.co/UZB1AsycaT
ここでエラーとなってる。 pic.twitter.com/Jmh2J2Fwkk
getServerSideProps
の代用(page.tsx のみ)として使い、そのほかは client component として使う方法experimental.typedRoutes
で router.push も型安全になる--turbo
は experimental.typedRoutes
に対応してない<a>
で移動させるとかなのかなconst C = (props: {a:Promise<A>}) => {
const a = use(props.a);
...
}
const C_for_Suspense = (props: Promise<CProps>) => {
const props = use(props);
...
}
const { a, ...other } = props;
みたいなことをしないと props のバケツリレー漏れが起きるのでできれば避けたいconst C = (props: {a: A}) => {
...
};
const CStream = (props: {a: Promise<A>}) => {
const a = use(props.a);
return <C a={a} />
}
"use server"
でマークされたサーバの関数を RPC 的に呼ぶのは、Server Action と言うらしい<form action={fn}>
だけじゃなくて、Client → Server function も Server Action と呼んでる個人的なJavaScriptの情報収集の方法についてまとめてみます。
JSer.infoなどをやっているので、JavaScriptの情報については色々な情報源を見るようにしています。 JSer.infoの範囲の中での情報源については、次の記事でまとめています。
この記事では、少しスコープを広げてJavaScriptの情報収集についてまとめてみます。
かなりスコープが広がってしまうので、万人向けの方法ではなく、個人的な情報収集方法としてまとめています。
この記事では、膨大な情報の中から見つけるというアプローチをとっているので、人によって向き不向きがあると思います。
情報の元となる情報源はさまざまなサイトや人になると思います。 しかし、そのサイトや人ごとに見ていくというのはかなり大変で、それ自体が大変になると見なくなる可能性が高いと思います。 そのため、情報収集においては、情報を自分が見やすいと思える場所に集めることが重要だと考えています。
人によってこの集める場所は異なりため、Twitter(X)、RSSリーダー、Notion、SlackやDiscordなど色々な場所があると思います。 自分の場合は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してるリポジトリごとのリリースや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リポジトリのリリースノートを自動的に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を使ってる人は注意が必要です。
APIで、Customで”Releases”のみのリポジトリを取得する方法を知っている方がいたら教えてください。
Bandito.reでは、GitHub Starしたリポジトリのリリースノートをまとめて購読できます。 自分はとりあえずGitHub Starしてることが多いです。 ただ、Watchするかは意識しないと忘れるので、そういった見逃しがたまに回収できます。
現在は多くのオープンソースや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の仕様の更新などを購読しています。
repo:microsoft/TypeScript is:issue label:Planning
: https://azu.github.io/github-search-rss/typescript-iterator-plan.jsonrepo:w3ctag/design-reviews is:issue
: https://azu.github.io/github-search-rss/w3ctag-design-reviews.jsonrepo:npm/rfcs is:issue
: https://azu.github.io/github-search-rss/npm-rfcs.jsonrepo:npm/statusboard is:issue
: https://azu.github.io/github-search-rss/npm-statusboard.jsonrepo:github/roadmap is:issue
: https://azu.github.io/github-search-rss/github-roadmap.jsonrepo:mozilla/standards-positions is:issue
: https://azu.github.io/github-search-rss/mozilla-standards-positions.jsonrepo:WebKit/standards-positions is:issue
: https://azu.github.io/github-search-rss/WebKit-standards-positions.jsonrepo:Fyrd/caniuse label:"Support data suggestion"
: https://azu.github.io/github-search-rss/caniuse.jsonorg:wintercg is:open is:issue -repo:wintercg/admin
: https://azu.github.io/github-search-rss/wintercg.jsonrepo:babel/proposals is:issue
: https://azu.github.io/github-search-rss/babel-proposals.jsonrepo:antifraudcg/proposals is:issue
: https://azu.github.io/github-search-rss/antifraudcg-proposals.jsonrepo:mdn/browser-compat-data is:pr is:open
: https://azu.github.io/github-search-rss/mdn-browser-compat-data.jsonrepo:mdn/content is:pr is:open
: https://azu.github.io/github-search-rss/mdn-content.jsonrepo:nodejs/node label:notable-change is:pr is:closed -label:doc
: https://azu.github.io/github-search-rss/nodejs-notable.jsonrepo:whatwg/html is:pr label:"impacts documentation"
: https://azu.github.io/github-search-rss/whatwg-html.jsonlightweight language:javascript language:typescript sort:updated-desc
: https://azu.github.io/github-search-rss/lightweight-javascript-repo.jsonこれらを見てると、ウェブの仕様の具体的な流れが見えたりします。
たとえば、ブラウザや仕様にちゃんとした変更を入れようと思うと、2つの実装者が必要です。 今はChromeの人がかなり仕様を追加してるので、新しい機能を作った時にMozilla/WebKitに対して仕様の意見を聞くIssueを作っています。 またW3C Tagのデザインレビューも行うので、新しい仕様を検討するときは次のリポジトリにIssueが作成されます。
この仕様が進んで、実装されるとブラウザのリリースノートになって、互換テーブルとかにその情報が反映されます。
この辺の流れが結構見えたりするので、実際にリリースされるまでにどういう議論点があったのかをちょっとわかってる状態になるのでおすすめです。
GitHub Advisory Databaseは、GitHubのセキュリティアドバイザリ情報を提供しています。 これらは言語ごとに提供されていて、JavaScriptの場合はnpmのパッケージの脆弱性情報が提供されています。 この更新もRSSフィードとして購読しています。
COMPOSER
: https://azu.github.io/github-advisory-database-rss/composer.json (atom)GO
: https://azu.github.io/github-advisory-database-rss/go.json (atom)MAVEN
: https://azu.github.io/github-advisory-database-rss/maven.json (atom)NPM
: https://azu.github.io/github-advisory-database-rss/npm.json (atom)NUGET
: https://azu.github.io/github-advisory-database-rss/nuget.json (atom)PIP
: https://azu.github.io/github-advisory-database-rss/pip.json (atom)PUB
: https://azu.github.io/github-advisory-database-rss/pub.json (atom)RUBYGEMS
: https://azu.github.io/github-advisory-database-rss/rubygems.json (atom)RUST
: https://azu.github.io/github-advisory-database-rss/rust.json (atom)ERLANG
: https://azu.github.io/github-advisory-database-rss/erlang.json (atom)ACTIONS
: https://azu.github.io/github-advisory-database-rss/actions.json (atom)SWIFT
: https://azu.github.io/github-advisory-database-rss/swift.json (atom)はてなブックマークには、自分がフォローしてるアカウントのブックマークをRSSフィードとして購読できます。
https://b.hatena.ne.jp/{user}/favorite.rss
📝 https://b.hatena.ne.jp/{user}/favorite
にアクセスすると、RSSフィードのリンクがページ内に書かれています。
これによって、自分が気になる情報をブクマしてる人をフォローしておけば、その人がブックマークした記事を購読できます。
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-rssやRSS Feeds for GitHub Advisory Databaseなどはこの仕組み(OPMLを生成してる)を利用して購読しています。購読するかどうかを考えるのは結構大変なので、その辺を自動化/他の操作に置き換えることで、情報の鮮度が保ちやすいのかなと思いました。
フォローしたら自動的に購読できるというのは、SNSやYoutubeなどではあると思うので、それをRSSリーダーに持ってきてるという感覚です。 今だとBluesky上のフィードで似たような仕組みを作れたりしそうなので、試して見ると面白いかもしれません。
]]>先週の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に関する更新は、どう変えると読みやすいかは考える必要がありそうです。
やる人が出てきたら、一回ミーティングをすると良いかもしれません。
]]>ライブラリやツールなどを作っているときに、特定の機能やパッケージを非推奨にする場合があります。 これらの非推奨はリポジトリ上のREADMEやIssueなどに書いても、利用者が気づかないことがあります。 そのため、利用者が気付けるように非推奨の機能やパッケージを使った場合に警告を出す方法を紹介します。
非推奨にはいくつかの段階があり、それに応じてやり方を変えられるので、それぞれの方法を紹介します。
npm deprecate <package> <message>
@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のウェブサイト上で非推奨になっていることが表示されます。
@deprecated
JSDocの@deprecated
タグを使うことで、コードレベルで非推奨な機能をエディタやIDEで警告することができます。
/**
* @deprecated use new-someting instead.
*/
function oldFunction() {
// ...
}
たとえば、textlint v14ではTextLintCore
というAPIを非推奨にしています。
このTextLintCore
には@deprecated
タグが付与されています。
そのため、このAPIを使おうとすると、エディタやIDEで打ち消し線と共に警告が表示されます。
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",
});
type
をDeprecationWarning
にすることで、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
の環境変数を使うのが便利です。
console.warn
との違いは、非推奨のAPIを利用する側に、その警告の動作を制御できる標準的な方法が提供されていることです。
そのほかにもprocess.on("warning", (warning) => {})
を使うことで、非推奨の警告を受け取ることができます。
詳細はドキュメントを参照してください。
textlint v14では、古いAPIの実行時警告にこのprocess.emitWarning()
を使っています。
ライブラリやツールで機能やパッケージを非推奨にする場合は、利用者が気づけるように警告を出すことが重要です。 この記事では、パッケージの非推奨化、コードレベルの非推奨化、実行時の非推奨化の3つの方法を紹介しました。
npm deprecate <package> <message>
@deprecated
process.emitWarning()