カテゴリー
役立ち情報
2024/08/01・役立ち情報
.code-note {
background: #1f8cc2;
color: white;
display: inline-block;
padding: 8px 16px;
border-radius: 8px 8px 0 0;
transform: translateX(16px) translateY(16px);
}
今回はタイトルの通り、DraggableScrollableSheetにおけるmaxChildSizeを子要素の実際のサイズに合わせる方法を解説します。DraggableScrollableSheetのコンテンツの高さが場合によって変わる場合、展開したときの最大サイズをそのコンテンツの高さに合わせたい場合があると思います。何も指定ない状態では、以下のようにコンテンツの量に対してシートが多くの面積を占領してしまうケースがあります。
:::asdf
:::
これをどうにかしていきましょう。
※StatefulWidgetを使用したコードについては、今後追加予定です。
実現のために必要な要素
maxChildSizeを子要素の実際のサイズに合わせるには、子要素のサイズに合わせてmaxChildSizeを変えてやる必要があります。これを実現するためには、以下の4つ数値をどうにかして入手する必要があります。
実際の子要素のサイズ
DraggableScrollableSheetの最大高さ
子要素の最大サイズ割合 (maxChildSize) (サイズがこの値で頭打ちするように)
初期サイズ割合 (initialChildSize) (この値より小さい値がmaxChildSizeに割り当てられることを防ぐため)
DraggableScrollableSheetのmaxChildSizeプロパティは、DraggableScrollableSheetが展開できる最大サイズに対する割合 で取るため、子要素のサイズに加えてその最大サイズが分からないといけません。これらはプログラムで取得する必要があります。
子要素の最大サイズ割合は、子要素が十分に大きい際に頭打ちする値です。初期サイズ割合については、これより小さい値がmaxChildSizeに設定されてしまうとエラーが発生するため、これを防ぐために必要です。
コンテンツ(子要素)の高さを取得する
まず、コンテンツ(子要素)の高さを取得するため、当該の子要素にkeyをつけます。
:::code-note
Flutter Hooksを利用する場合
:::
class MyBottomSheet extends HookWidget {
const MyBottomSheet({super.key});
@override
Widget build(BuildContext context) {
final contentsKey = useMemoized(() => GlobalKey()); // <=
return DraggableScrollableSheet(
expand: false,
snap: true,
maxChildSize: 0.9,
builder: (context, scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: Column(
key: contentsKey // <=
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < 15; i++)
Card(
child: Text("Item $i"),
),
],
),
);
},
);
}
}
StatefulWidgetを利用する場合
class MyBottomSheetWoHooks extends StatefulWidget {
const MyBottomSheetWoHooks({super.key});
@override
State<MyBottomSheetWoHooks> createState() => _MyBottomSheetWoHooksState();
}
class _MyBottomSheetWoHooksState extends State<MyBottomSheetWoHooks> {
final contentsKey = GlobalKey(); // <=
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
expand: false,
snap: true,
maxChildSize: 0.9,
builder: (context, scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: Column(
key: contentsKey, // <=
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < 15; i++)
Card(
child: Text("Item $i"),
),
],
),
);
},
);
}
}
このkeyに対して、key.currentContext.findRenderObject()を通してWidgetの実際の描画サイズにアクセスできます。
【注意】SingleChildScrollViewにpaddingを適用している場合、その子要素のPaddingに対してkeyを設定する必要があり、SingleChildScrollViewのプロパティでpaddingを設定してはいけません。こうしないと、ScrollViewで見えている範囲の高さとなってしまい、正しいコンテンツの高さが取得できません。
builder: (context, scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: Padding(
key: contentsKey, // <= ここに設定
padding: const EdgeInsets.only(left: 16.0, bottom: 16.0, right: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < 15; i++)
Card(
child: Text("Item $i"),
),
],
),
),
);
},
DraggableScrollableSheetが占領できる最大の高さを取得する
この高さは、要するにmaxChildSize: 1.0の場合のDraggableScrollableSheetの最大高さです。これを取得するには、LayoutBuilderを使います。
Flutter Hooksを利用する場合
class MyBottomSheet extends HookWidget {
const MyBottomSheet({super.key});
@override
Widget build(BuildContext context) {
final contentsKey = useMemoized(() => GlobalKey());
final availableHeight = useRef<double?>(null); // <= 追加
return LayoutBuilder( // <= 追加
builder: (context, constraints) {
availableHeight.value = constraints.maxHeight; // <= 追加
return DraggableScrollableSheet(
expand: false,
snap: true,
maxChildSize: 0.9,
builder: (context, scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: Padding(
key: contentsKey,
padding: const EdgeInsets.only(left: 16.0, bottom: 16.0, right: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < 15; i++)
Card(
child: Text("Item $i"),
),
],
),
),
);
},
);
},
); // <= 追加
}
}
※availableHeightはbuildメソッドの中で値を取得・設定しているため、値が変更されても再描画が走らないuseRefを使用しています。(buildメソッドの処理が終わっていないタイミングで再描画が走るとエラーになります)
最大領域に対する子要素のサイズの割合を計算する
DraggableScrollableSheetが展開できる最大サイズに対する、子要素のサイズの割合を計算します。
Flutter Hooksを利用する場合
class MyBottomSheet extends HookWidget {
const MyBottomSheet({super.key});
@override
Widget build(BuildContext context) {
final contentsKey = useMemoized(() => GlobalKey());
final availableHeight = useRef<double?>(null);
const maxChildSize = 0.9; // <= 追加
const initialChildSize = 0.5; // <= 追加
double calculateChildSizeRatio() { // <= 追加
final renderBox = contentsKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null || availableHeight.value == null) {
return maxChildSize;
}
return max(initialChildSize, min(renderBox.size.height / availableHeight.value!, maxChildSize));
}
return LayoutBuilder(
builder: (context, constraints) {
availableHeight.value = constraints.maxHeight;
return DraggableScrollableSheet(
expand: false,
snap: true,
maxChildSize: maxChildSize, // <= 変更
initialChildSize: initialChildSize, // <= 追加
builder: (context, scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: Padding(
key: contentsKey,
padding: const EdgeInsets.only(left: 16.0, bottom: 16.0, right: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < 15; i++)
Card(
child: Text("Item $i"),
),
],
),
),
);
},
);
},
);
}
}
実際に割合を計算しているのはこの部分ですね。あまり難しい計算はしていないです。
max(initialChildSize, min(renderBox.size.height / availableHeight.value!, maxChildSize));
maxChildSizeを、計算した値に設定する
いよいよ、計算した値を設定していきます。Hooksを使用している場合は、useEffectを用いて初回のレンダリングが終わったタイミングで設定します。
Flutter Hooksを利用する場合
class MyBottomSheet extends HookWidget {
const MyBottomSheet({super.key});
@override
Widget build(BuildContext context) {
final contentsKey = useMemoized(() => GlobalKey());
final availableHeight = useRef<double?>(null);
const maxChildSize = 0.9;
const initialChildSize = 0.5;
final currentMaxChildSize = useState(maxChildSize); // <= 追加
double calculateChildSizeRatio() {
final renderBox = contentsKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null || availableHeight.value == null) {
return maxChildSize;
}
return max(initialChildSize, min(renderBox.size.height / availableHeight.value!, maxChildSize));
}
useEffect(() { // <= 追加
WidgetsBinding.instance.addPostFrameCallback((_) {
currentMaxChildSize.value = calculateChildSizeRatio();
});
return null;
}, [],);
return LayoutBuilder(
builder: (context, constraints) {
availableHeight.value = constraints.maxHeight;
return DraggableScrollableSheet(
expand: false,
snap: true,
maxChildSize: currentMaxChildSize.value, // <= 変更
initialChildSize: initialChildSize,
builder: (context, scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: Padding(
key: contentsKey,
padding: const EdgeInsets.only(left: 16.0, bottom: 16.0, right: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < 15; i++)
Card(
child: Text("Item $i"),
),
],
),
),
);
},
);
},
);
}
}
実行結果
子要素に合わせて展開サイズが変わるようになりました。パチパチ
2023/10/21・役立ち情報
前提
Bunを使っていてGitでマージしようとした時、ロックファイルがコンフリクトすることがあります。しかし、Bunのロックファイルはバイナリなので、手作業でコンフリクトの解決をすることができません。以下は、Bunの自動コンフリクト解決機能を用いる方法です。
方法
リベース/マージを試みます。コンフリクトが起こります。
bun iを実行します。自動的にコンフリクトが解消されます。
git <rebase/merge> --continueを実行します。コミットメッセージをよしなに編集します。
これでコンフリクトが解消できます。
2023/08/27・役立ち情報
概要
Xcode 15.0 Beta 5以降、こんなエラーが出て CocoaPods の pod installに失敗する。
DT_TOOLCHAIN_DIR cannot be used to evaluate LIBRARY_SEARCH_PATHS, use TOOLCHAIN_DIR instead (in target 'TARGET' from project 'PROJECT')
この不具合は既にIssueが立っており、修正がマージされている。しかし、v1.13.0にマイルストーンが立っており、リリースは進捗的に少し先になりそうな様子。それまでは以下の対処法でビルドを通すことができる。
2023/09/25追記: この問題の修正版、v1.13.0がリリースされた。現在は以下の対処法ではなく、CocoaPodsを更新することで対処できる。
対処法
Podfileを編集する。
# ...
post_install do |installer|
# ...
installer.pods_project.targets.each do |target|
# ...
target.build_configurations.each do |config|
# --- Workaround for Xcode 15.0 ---
xcconfig_path = config.base_configuration_reference.real_path
xcconfig = File.read(xcconfig_path)
xcconfig_mod = xcconfig.gsub(/DT_TOOLCHAIN_DIR/, "TOOLCHAIN_DIR")
File.open(xcconfig_path, "w") { |file| file << xcconfig_mod }
# ---------------------------------
end
end
end
参考
"Error 'DT_TOOLCHAIN_DIR cannot be used to evaluate LIBRARY_SEARCH_PATHS, use TOOLCHAIN_DIR instead' in Xcode 15 beta 5" (GitHub)
2023/08/25・役立ち情報
前提
ネイティブからFlutterにURIを渡すためには、EventChannelを利用すると思います。単純にFlutter側でmain()でリスナーを設定し、ネイティブでapplication(_:open:options:)やonNewIntentなどでURIを送り込むと、アプリ起動中にURIが流れてきた場合は動作するしれませんが、URIによってアプリが起動された際にはURIを渡すことができません。何故なら、Flutterエンジンの初期化には時間がかかるためです。
そのため、リッスンされるまではネイティブのコードでURIを貯蔵しておき、リッスンが開始された際に流し込む、というようなアプローチが必要になります。
ホスト(ネイティブ)側のコード
iOS
class UriEventApi: NSObject {
static let channelName = "com.example.app.event/uri" // Channel name (任意に変更)
var channel: FlutterEventChannel
var eventSink: FlutterEventSink? // リッスンが開始されたタイミングで代入され、キャンセルされたタイミングでnilにされる
var pendingUri: String?
init(binaryMessenger: FlutterBinaryMessenger) {
channel = FlutterEventChannel(name: UriEventApi.channelName, binaryMessenger: binaryMessenger)
}
func initHandler() {
channel.setStreamHandler(self)
}
func onUri(uri: String) {
if (eventSink != nil) { // Flutter側で既にリッスンが開始されている場合、
eventSink!(uri) // そのままeventSinkを発火させる
} else { // リッスンが開始されていない = まだFlutterエンジンが初期化されていない 場合、
pendingUri = uri // pendingUriに代入して貯めておく
}
}
}
extension UriEventApi: FlutterStreamHandler {
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { // リッスンが開始された際、
eventSink = events
if (pendingUri != nil) { // 保留中のURIが存在する場合、
eventSink!(pendingUri) // eventSinkを発火させてFlutter側に伝える
pendingUri = nil
}
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
eventSink = nil
return nil
}
}
Android
// 実装の内容はiOS(Swift)と同様
class UriEventApi(binaryMessenger: BinaryMessenger) : EventChannel.StreamHandler {
private val channel = EventChannel(binaryMessenger, CHANNEL_NAME)
companion object {
const val CHANNEL_NAME = "com.example.event/uri"
}
private var eventSink: EventChannel.EventSink? = null
private var pendingData: String? = null
fun initHandler() {
channel.setStreamHandler(this)
}
fun onUri(uri: String) {
if (eventSink == null) {
pendingData = uri
} else {
eventSink!!.success(uri)
}
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
if (pendingData != null) {
eventSink!!.success(pendingData)
pendingData = null
}
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
}
Flutter側のコード
扱いやすくするためにUriEventApiという名前でクラスを作ります。
// lib/event_api/uri_event_api.dart
class UriEventApi {
static const _eventName = "com.example.app.event/uri";
static const _channel = EventChannel(_eventName);
StreamSubscription listen() {
return _channel.receiveBroadcastStream().listen((uri) {
// process
});
}
}
これを任意のタイミングでリッスンします。
UriEventApi().listen();
このコードで基本的には動くはずです。
【Xcode 15/iOS/WidgetKit】"Error (Xcode): Cycle inside Runner; building could produce unreliable results." エラー
2023/08/08・役立ち情報
問題発生
久々にFlutterアプリをビルドしようとしたら、以下のようなエラーが発生して失敗する問題が起きた。
Error (Xcode): Cycle inside Runner; building could produce unreliable results.
Cycle details:
→ Target 'Runner': CodeSign
/Users/xxx/Library/Developer/Xcode/DerivedData/Runner-xxx/Build/Intermediates.noindex/ArchiveIntermediates/Runner/InstallationBuildProductsLocation/Applications/Runner.app
○ That command depends on command in Target 'Runner': script phase “[CP] Embed Pods Frameworks”
○ Target 'Runner' has copy command from
'/Users/chika/Library/Developer/Xcode/DerivedData/Runner-xxx/Build/Intermediates.noindex/ArchiveIntermediates/Runner/BuildProductsPath/Release-iphoneos/WidgetKitExtension.appex' to
'/Users/xxx/Library/Developer/Xcode/DerivedData/Runner-xxx/Build/Intermediates.noindex/ArchiveIntermediates/Runner/InstallationBuildProductsLocation/Applications/Runner.app/PlugIns/WidgetKitExtension.appex'
○ That command depends on command in Target 'Runner': script phase “Thin Binary”
○ Target 'Runner' has process command with output
'/Users/xxx/Library/Developer/Xcode/DerivedData/Runner-xxx/Build/Intermediates.noindex/ArchiveIntermediates/Runner/InstallationBuildProductsLocation/Applications/Runner.app/Info.plist'
○ Target 'Runner' has copy command from
'/Users/xxx/Library/Developer/Xcode/DerivedData/Runner-xxx/Build/Intermediates.noindex/ArchiveIntermediates/Runner/BuildProductsPath/Release-iphoneos/WidgetKitExtension.appex' to
'/Users/xxx/Library/Developer/Xcode/DerivedData/Runner-xxx/Build/Intermediates.noindex/ArchiveIntermediates/Runner/InstallationBuildProductsLocation/Applications/Runner.app/PlugIns/WidgetKitExtension.appex'
環境
Flutter 3.10.6
% flutter --version
Flutter 3.10.6 • channel stable • https://github.com/flutter/flutter.git
Framework • revision f468f3366c (4 weeks ago) • 2023-07-12 15:19:05 -0700
Engine • revision cdbeda788a
Tools • Dart 3.0.6 • DevTools 2.23.1
Xcode 15.0 Beta 4
対処法
Appleのエラーメッセージは非常に分かりにくいのですが、改めて隅々まで読んでみると Build Phasesの順序が問題で、[CP] Embed Pods FrameworksとThin BinaryのBuild Phaseは、WidgetKitのExtensionのコピー作業を前提としているために失敗している、と書かれています。多分。
そのため、Embed App ExtensionsのBuild Phaseを上記の2つより上に持ってくる ことで解決しました。
Build Phasesの内容は環境によってかなり異なるため、上記の画像と違う場合にはエラーメッセージを詳しく読んでみてください。
2023/06/08・役立ち情報
ハマった
以下のように、WindowsホストランナーでOutputしようとするとうまくいかない。
jobs:
build_windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- id: test
run: echo "FILENAME=test.zip" >> "$GITHUB_OUTPUT"
- run: echo ${{ steps.test.outputs.FILENAME }}
対処法
WindowsホストのシェルであるPowershellにおいては、環境変数にアクセスするにはenv:のプレフィックスが必要な模様。
run: echo "FILENAME=test.zip" >> "${env:GITHUB_OUTPUT}"
2023/06/01・役立ち情報
経緯
Google Search Index用にサイトマップを作りたくなった。サイトマップの生成自体はsitemapパッケージでどうにかなるが、いちいち全部のルートを取得してリストを作るのは面倒臭い。そこで、nitroに備わっているクローラーを使って、サイト内にあるリンクからルート一覧を生成してみる。
nitroのクローラーは、HTMLに含まれる、「/」で始まるhref属性を含むタグを自動的に認識し、リンク先のルートを生成できる。
壁
nitroでは、クローラーによって自動的に動的ルートのページが生成される。しかし、クロール後のルート一覧を取得する方法が存在しない。そこで、ページを生成するタイミングでフックを挟んで、変数のリストに追加していくことにする。
コード
まず、sitemapパッケージをインストールしておく。
yarn add sitemap
次に、nuxt.config.tsのdefineNuxtConfigの上に以下を追加する。
const hostname = "<hostname>"
const routes: string[] = []
export default defineNuxtConfig({
...
続いて、defineNuxtConfigの中に以下を追加する。
export default defineNuxtConfig({
...
nitro: {
hooks: {
"prerender:route"(route) {
routes.push(route.route)
},
close() {
if (routes.length > 0) {
const links: SitemapItemLoose[] = routes.map(route => ({
url: route,
}))
const stream = new SitemapStream({
hostname,
})
return streamToPromise(Readable.from(links).pipe(stream))
.then((sm) => {
return fs.writeFileSync("dist/sitemap.xml", sm.toString())
})
}
},
},
},
...
})
9行目のif (routes.length > 0)は、これを入れないとnuxt prepare(yarn install)を実行した際にもこの処理が走ってしまうために入れています。
2023/03/22・役立ち情報
問題発生
IntelliJ IDEA (というか全Jetbrains IDEで共通) でGitのコミットを署名する設定を、Macでこれに従って設定したところ、以下のようなエラーが出てコミットできなくなった。
error: gpg failed to sign the data
fatal: failed to write commit object
原因
いろいろ調べた結果、パスワード入力用のダイアログの表示に失敗している様子。
GPGではセキュリティーのためか、pinentryというソフトでGUIのパスフレーズ入力ダイアログを出し、コンソールに直接パスフレーズを入力することを避けるようにしている。そのmacOS版であるpinentry-macをインストールすると、pinentryとpinentry-macという2種類のコマンドが実行できるようになる。しかし、なんと pinentryの方はmacOSでは動かない。
デフォルトではこのpinentryの方が使用されるようになっているためにエラーが発生している様子。~/.gnupg/gpg-agent.confでpinentryのパスを変更できるらしいが、なぜか設定しても反映されない。(いろいろ試したものの…)
そこで、シンボリックリンクの作成という力技で対処することにする。
対処法
まず、現在設定されているpinentryのパス と、 pinentry-macのパス を調査する。
前者は以下のコマンドで調べられる。
% gpgconf
gpg:OpenPGP:/opt/homebrew/Cellar/gnupg/2.4.0/bin/gpg
...
pinentry:パスフレーズ入力:/opt/homebrew/opt/pinentry/bin/pinentry
上の実行結果でいうと、/opt/homebrew/opt/pinentry/bin/pinentryの部分が現在設定されているパス。そして、このファイルを削除する。
% rm <現在設定されているpinentryのパス>
次に、pinentry-macのパスを調べる。
% which pinentry-mac
/opt/homebrew/bin/pinentry-mac
最後に、以下のコマンドでシンボリックリンクを作成する。
% ln -s <pinentry-macのパス> <現在設定されているpinentryのパス>
これでコミットできるようになるはず。
2023/03/14・役立ち情報
問題発生
OGPのサムネイル生成用に、Puppeteerを使ったCloud Functionsの関数をデプロイしようとした。
開発環境(エミュレーター)では正常に動作するのに、なぜかデプロイするとError: could not handle the requestと表示されて動作しなくなる。ログを確認すると、以下のようなエラーが出ていた。
Error: Could not find Chromium (rev. 1095492). This can occur if either
1. you did not perform an installation before running the script (e.g. `npm install`) or
2. your cache path is incorrectly configured (which is: /www-data-home/.cache/puppeteer).
For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.
どうやら「Chromiumどこ…?」というエラーの様子。
結論
以下の手順で解決できる。
functionsディレクトリに.puppeteerrc.cjsファイルを作成し、以下のような中身にする。
/**
* @type {import('puppeteer').Configuration}
*/
module.exports = {
cacheDirectory: require("path").join(__dirname, ".cache", "puppeteer"),
}
firebase.jsonに以下を追記する。(※JSONではコメントの記述はできないため、//以下は削除してください)
"functions": [
{
"source": "functions",
"codebase": "frontend",
"ignore": [
"node_modules",
".git",
"firebase-debug.log",
"firebase-debug.*.log",
".cache" // <= 追記
],
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run lint",
"npm --prefix \"$RESOURCE_DIR\" run build"
]
}
],
package.jsonを以下のように変更する。(※JSONではコメントの記述はできないため、//以下は削除してください)
"scripts": {
"lint": "eslint --ext .js,.ts .",
...
"postinstall": "node node_modules/puppeteer/install.js" // <= 追記
},
...
"engines": {
"node": "16" // <= 16にする。詳細は下記。
}
解説
調べてみると、Puppeteerのv19で以下のような破壊的変更があった模様。
use ~/.cache/puppeteer for browser downloads
(https://github.com/puppeteer/puppeteer/releases/tag/v19.0.0)
ホームディレクトリはCloud Functionsのデプロイの対象にならないため、実際に実行される環境でChromiumが存在せず、エラーとなっているようです。
そのため、Puppeteerのインストール先をプロジェクト内に変更してやります。これが1.です。
この状態でnode_modulesを削除してnpm installし直すと、設定したパスに開発端末のOSに合ったChromiumがインストールされます。ここでfirebase deployしようとすると、「パッケージが大きすぎるためデプロイできません」のようなエラーが発生します。これは巨大なChromium本体がデプロイの対象となっているためで、これはデプロイ不要なので除外してやる必要があります。これが2.です。
あとは、Cloud Functionsではなぜかnpm install時にChromiumが自動的にインストールされないので、postinstallでインストールスクリプトを実行してやります。(3.)
また、Nodeの最新バージョンは18ですが、2023/03/14現在、Node 18用のイメージにPuppeteerの実行に必要なライブラリが含まれていないため、16にします。
2023/01/06・役立ち情報
経緯
GitHub連携を用いて、Cloudflare Pagesでページを公開し、一部のパスでFunctionが実行されるようにしたかったが、なぜかFunctionsだけデプロイされない。
公式ドキュメントの通り、プロジェクトルートにfunctionsディレクトリを作成し、その中にapi/showcase.tsを作り、example.com/api/showcaseでAPIが走るようにしたかった。しかし、そのままデプロイしてもNuxtの静的ページに飛び、Functionsに繋がらない。
対処法
pages functions not recognizedで検索したところ、https://github.com/cloudflare/wrangler2/issues/1859がヒットした。
結論としては、nuxt.config.tsに以下を追記する。
...
nitro: {
preset: "node-server",
},
...
https://github.com/cloudflare/wrangler2/issues/1859#issuecomment-1269616054によると、Nitroでは環境に応じて自動的に適切なプリセットが選択され、Cloudflare Pagesではcloudflare-pagesが設定されるようになります。本来はnuxt generate時に.output/publicに/functionsがコピーされるべきですが、Nuxt3の/serverとして扱われ、認識できるexportが存在しないことからレンダリング前に/functionsが空っぽにされ、結果全くデプロイされないようです。(私の認識が正しければ)