iwate

A software developper in Japan github: https://github.com/iwate

続Azure Functions Isolated でのシングルトン

以前dotnet-isolatedなFunctionでSingletonがどうしても使いたくて、自前ロックを作った。

ただ、これでも完ぺきではなく課題が残ってる。それは実行順序。

前にも書いたが、a)のように並行して動いてしまう関数を、b)OrderIDごとに直列にしたい。

 a)

 +-----------------+  +-----------------+  +-----------------+
 |ORDER ID: 100    |  |ORDER ID: 100    |  |ORDER ID: 101    |
 |TIMESTAMP: 3s ago|  |TIMESTAMP: 2s ago|  |TIMESTAMP: 1s ago|
 +-------+---------+  +-------+---------+  +--------+--------+
         |                    |                     |
 +-------v--------------------v---------------------v--------+
 |                                                           |
 |                      Azure Functions                      |
 |                                                           |
 +-----------------------------------------------------------+

 b)

 +-----------------+
 |ORDER ID: 100    |
 |TIMESTAMP: 2s ago|
 +-------+---------+
         |
 +-------v---------+                       +-----------------+
 |ORDER ID: 100    |                       |ORDER ID: 101    |
 |TIMESTAMP: 3s ago|                       |TIMESTAMP: 1s ago|
 +-------+---------+                       +--------+--------+
         |                                          |
 +-------v------------------------------------------v--------+
 |                                                           |
 |                      Azure Functions                      |
 |                                                           |
 +-----------------------------------------------------------+

このとき、前回の自前ロックを使うことで、b)のようにOrderID=100のイベントを1個ずつ処理することに成功した。

ただし、実際は、a)のように走り始めて、先にロックが取れたほうが後続処理を実行できるので、

OrderId=100に関する2つのイベントの実行順序は不定である。 3s ago → 2s ago かもしれないし、 2s ago → 3s ago かもしれない。

それだと困る場合がある。 これをどうにか回避したい。

キューは発火のみに使う

まず、困るときの内容について考える。 どういったときに困るのか、それは、Queueメッセージに具体的なデータがありそれに基づき処理を行う場合だ。

例えば、OrderId=100の2イベント内容が以下だったとする。

{
    "OrderId": 100,
  "OrderStatus": "出荷指示",
  "Timestamp": <3s ago>,
},
{
    "OrderId": 100,
  "OrderStatus": "出荷済",
  "Timestamp": <2s ago>,
},

出荷指示がでて、出荷済になったという順番で到着する。これに基づいて処理をする函数を作らなければならないとすると、実行順序が不定だと困ることが容易に想像できる。(そもそもAuzre Storage Queue自体が順序保証していないということはいったん棚上げしておく)

この2イベントをこの順序で実行したい。ただし、CosmosDB ChangeTrackerやService Bus Queueを使うのはなし。あくまでストレージ縛りでいく。

そこで思いつくのは、キューからイベントデータであるOrderStatusとTimestampを落としてしまうという方法だ。

そうすると、キューメッセージはこのようになり、もはや順番は関係なくなる。

{ "OrderId": 100 }
{ "OrderId": 100 }

そして、除外したデータは、Blob(もしくはTable)に置いておく。

Path Content
/events/{OrderId}/{timestamp or UUID}.json {"OrderStatus": "出荷指示", "Timestamp": 3s ago }
/events/{OrderId}/{timestamp or UUID}.json {"OrderStatus": "出荷済", "Timestamp": 2s ago }

キュートリガーで起動した関数は、ロックを取得後、このBlobから自身のトピックディレクトリ、ここでは /events/100/ 配下の先頭ファイルを取得して処理をする。

処理が終わったらこのBlobファイルは削除するか、処理済みディレクトリに移動させる。

これで処理順序を保証しつつトピックに基づいた直列実行ができそう。まだ試してない。

The cheap way to make multi tenant SaaS on Azure #1 | Poor man's Azure

Many people say Azure is expensive. But I think Azure isn't so expensive compared AWS or GCP on full workload.
However it's true that minimum cost is expensive on Azure.

Therefore I've decided to find to make multi tenancy application on Azure as cheap as possible and I'm going to try to write a series of them.

In this commemorable first article, I consider front-end and authentication.

Azure has many scenarios to host web application, such as WebApps, VM, Container Apps, aks, aci, Static WebApps(swa), Functions, etc...

But there are not many one which has free tier.

Let's check free services of Azure at this web page.

Following three services can host web service with free. Other services, such as VM, Container Apps, aks, aci, don't have free tier and aren't cheap.

  • Web Apps Free instance
    • No Custom Doamin
    • 60min/day CPU
    • 1GB Storage
  • Static Web Apps
    • 2 Custom Domain
    • 5GB Storage
  • Functions(Consumption)
    • 1M requests
    • Custom Domain

Then, which service do I select to host my app? I think swa is the best.
Because free instances of web apps cann't handle any custom domain. Functions can handle custom domain and host an HTTP trigger function to return an HTML file but it's not good DX for front-end(html&js) devlopment.

Great news of swa came last month. It supports Next.js SSR. Swa has become the perfect host service for a modern web application!

I decide to use swa to host Next.js application. Amazingly, I use it as free.

But what about for authentication, what should I use and how?

It is generally split into two choices to implement authentication for multi tenants.

One is like GitHub. There is one account store and many organizations. Users are stored in the shared account store and join to several organizations.

Another is like Slack. There are organizations which has each account store. If org-A and org-B exist and each have a same email user(mail@iwate.me), org-A's mail@iwate.me user is not equal to org-B's mail@iwate.me user.

GitHub style is easy to collaborate people across organisations. So I decide make this style.

Azure has a IDaaS, Azure AD B2C(aad b2c). It has good security features and is free until 50,000 active users per month. There is no way to not use.

But there is a problem. It's how to connect to swa.

If you have used azure app services, You think of its authentication and authorization, it was called easy-auth.

However, the feature of swa needs standard plan. So it can't use on free plan.

Omg! but it's still too early to be fall.

Swa can host SSR Next.js now. So we can use NextAuth.js! We don't need standard plan for easy-auth.

1. Create Static Web Apps instance

Create swa instance on portal and get its URL like as https://xxxx-xxxx-xxxxxxxxx.x.azurestaticapps.net

2. Create AAD B2C and its User-Flow

3. Register Application to AAD B2C

Input redirect URIs:

  • [Created SWA URL on 1.]/api/auth/callback/azure-ad-b2c
  • [Created SWA URL on 1.]/api/auth/signout
  • [endpoint for local Next.js]/api/auth/callback/azure-ad-b2c
  • [endpoint for local Next.js]/api/auth/signout

4. Create a Backend Azure Functions Instance

I've dicided to use swa for front-end. However I think it is not enough for my application. I love C# and I want to write business logic with C#. So Let's create Azure Function and host business logic api application.

Don't worry, Azure Function (Consumption) has free tier until 1M requests.

Create a comsumption function and host a http trigger, such as:

And enable easy-auth for AAD B2C.

5. Create Next.js application

Install Next.js, NextAuth.js and dependencies.

npm install next react react-dom next-auth swr

Open package.json and add the following scripts.

"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "next lint"
}

Create auth endpoint file.

// pages/api/auth/[...nextauth].js
import NextAuth from "next-auth"
import AADB2CProvider from "next-auth/providers/azure-ad-b2c"
export const authOptions = {
    // Configure one or more authentication providers
    providers: [
        AADB2CProvider({
            tenantId: process.env.AZURE_AD_B2C_TENANT_NAME,
            clientId: process.env.AZURE_AD_B2C_CLIENT_ID,
            clientSecret: process.env.AZURE_AD_B2C_CLIENT_SECRET,
            primaryUserFlow: process.env.AZURE_AD_B2C_PRIMARY_USER_FLOW,
            authorization: { params: { scope: "offline_access openid" } },
        })
        // ...add more providers here
    ],
    callbacks: {
        async jwt({ token, account }) {
            // Persist the OAuth access_token to the token right after signin
            if (account) {
                token.idToken = account.id_token
            }
            return token
          },
        async session({ session, token, user }) {
            // Send properties to the client, like an access_token from a provider
            session.idToken = token.idToken
            session.apiEp = process.env.NEXT_PUBLIC_API_EP
            return session;
        }
    }
}
export default NextAuth(authOptions)

Implement sign-in sign-out buttons into pages/index.js

// pages/index.js
import { useSession, signIn, signOut } from "next-auth/react"
import useSWR from "swr";

const createFetcher = token => {
    const headers = new Headers();
    headers.append('Authorization', `Bearer ${token}`)
    return async (url) => fetch(url, { headers, mode: "cors" }).then(res => res.text())
}

const ApiData = () => {
    const { data: session } = useSession()
    const { data, error } = useSWR(`${session.apiEp}/api/HttpTrigger1?name=iwate`,createFetcher(session.idToken))
    if (error) return <div>failed to load</div>
    if (!data) return <div>loading...</div>
    return <div>{data}</div>
}

function HomePage() {
    const { data: session } = useSession()

    if (session) {
        return (
            <>
                Signed in as {session.user.email} <br />
                <ApiData/> <br/>
                <button onClick={() => signOut()}>Sign out</button>
            </>
        )
    }
    return (
        <>
            Not signed in <br />
            <button onClick={() => signIn('azure-ad-b2c')}>Sign in</button>
        </>
    )
}

export default HomePage

6. Set Environment Variable into SWA

Open Configuration panel of swa on azure portal. And set the fllowing env values:

  • AZURE_AD_B2C_TENANT_NAME: this is your if your aad b2c domain name is your.onmicrosoft.com
  • AZURE_AD_B2C_CLIENT_ID: Registered application ID
  • AZURE_AD_B2C_CLIENT_SECRET: Client secret for registered applciation
  • AZURE_AD_B2C_PRIMARY_USER_FLOW: A user flow name which is created on 2.
  • NEXTAUTH_URL: The URL of own swa.
  • NEXT_PUBLIC_API_EP: The URL of own backend functions.

7. Enable swa preview feature.

It need to modify github action workflow file for swa deply to execute SSR Next.js on swa.
Add the following environment variables to Azure/static-web-apps-deploy@v1 step

env: # Add environment variables here
  ENABLE_PREVIEW_FEATURES: true
  FUNCTION_LANGUAGE: node
  FUNCTION_LANGUAGE_VERSION: 16

8. Let's have fun!

Pixel Watch買った

4年前ぐらいに買った初代Fitbit Versaからついに乗り換えた。

Smart watchでスイカ使えるようになった。

スマホ用のPixel WatchアプリをPixel 6 Proにインストールしてポチポチしてセットアップしたんだけど、スイカは新しく発行された。

移管されると思ってたんだけど、そうはならなかった。モバイルスイカのIDをgmailにしてないからかもしらん。

今のチャージ分を使い切ったら試してみる。

充電スタンドほしいんだけどまだなさそう。apple watchの充電スタンドでいけるかなと思って試したら、充電自体はできそうだけど、磁石の極性が違って反発しちゃう。形態のUSB-C統一もそうだけど時計のほうも統一してほしい。

バッテリーライフは今のとこだめそう。Fitbitに比べたらね。

ECGとか、apple watchに負けてる部分が今後埋まったらいいなあ。

型パズル難しい

あるテーブルへの操作について考える。

1操作をOp型で表す

type Op<T> = {
    table: string
    pk: (keyof T)[]
    cols: Partial<T>
}

keyof Tは型Tが持つプロパティ名のみの型になる。

type MyObj = {
    prop1: number
    prop2: string
}
type MyObjKeys = keyof MyObj // == 'prop1' | 'prop2'

Partial<T>は型Tのすべてプロパティをundefined可能にする

const obj1: MyObj = {prop1:0} // error porp2は必須

type MyObjPartial = Partial<MyObj> // == { prop1?:number, prop2?:string }
const obj2: MyObjPartial = {prop1:0} // OK

これらの組み合わせで型Op<T>は、pkには必ずTに存在するプロパティ名、colsには必ずTの部分型を引数として渡すことを制約できる。

const op1: Op<MyObj> = { table: 'myobjs', pk:['prop1'], { prop1: 1, prop2: ''} }
const op2: Op<MyObj> = { table: 'myobjs', pk:['prop3'], { prop1: 1, prop2: ''} } // error
const op3: Op<MyObj> = { table: 'myobjs', pk:['prop1'], { prop1: 1, prop3: ''} } // error 

ところで、JavaScriptの配列には、違う方のものを詰め込める。なのでこんな関数も作れる

function batch(operations) {
    // 同一トランザクションでoperationsすべてを実行する
}

batch([
    { table: 'A', pk: ['id'], { id: 1, col1: '' } },
    { table: 'B', pk: ['code'], { code: 'c', col1: '' } }
])

これに型を付けたいのが本題。

普通に型を付けてみる。

function batch<T>(operations: Op<T>) {
}

ダメだというの容易に想像がつく。これでは、すべてのOpの型パラメータTが一緒でないと通らない。
それは思っているやつじゃない。

ググっていたらinferにたどり着いた。

extendsとこのinferを使用することで型を計算することができるという。これが結構難しく巷では型パズルと呼ばれるらしい。

次のToArrayextendsinferを使用して、配列の各要素の型を取り出す式だ。
stringnumberからなる要素数2の配列(タプル)があったとすると、(string|number)[][string, number]もしくは[number, string]に推論してくれる。

type ToArray<T> = T extends [] ? [] 
                : T extends [infer U, ...infer V] ? [U, ...ToArray<V>]
                : T

function f<T extends unknown[]>(...args: ToArray<T>): ToArray<T> {
    return args;
}

f(0, 'a') // これの戻り値の型は [number, string]になる。
f('a', 0) // これの戻り値の型は [string, number]になる。

extendsは三項演算子を伴って、型を導出する式をかける。
少し難しいのは T extends [infer U, ...infer V] ? [U, ...ToArray<V>]の部分。T extends [infer U, ...infer V]の部分はTが要素を持つ配列だった時、先頭の要素をU、それ以降の要素をまとめてVとして当てはめて推論させている。これにマッチした時、[U, ...ToArray<V>]で、先頭の要素の型Uは確定して、残りの要素Vを再帰的にマッチングさせる。その結果を...でフラットに展開させている。

もし、[U, ...ToArray<V>][U,ToArray<V>]と書くと結果は[string,[number]]とLispみたいにネストしていく。

このToArrayを調整し、Opを要素に持つ、BatchOp<T>を書いてみた。

type Op<T> = {
    table: string
    pk: (keyof T)[]
    cols: Partial<T>
}

type BatchOp<T> = T extends [Op<infer U>, ...infer V] ? [Op<U>, ...BatchOp<V>] : []

function batch<T>(ops: BatchOp<T>) {
  // transaction
}

type V1 = {
    id: number
    col1: string
}

type V2 = {
    code: string
    col2: string
}

const v1 = { id: 1, col1: 'col1' }
const v2 = { code: 'code', col2: 'col2' }

batch<[Op<V1>,Op<V2>,Op<V2>]>([
    { table: 'A', pk: ['id'], cols: v1 },
    { table: 'B', pk: ['code'], cols: v2 },
    { table: 'B', pk: ['id'], value: v2 }, // 無事エラーになる
])

狙った通りの要素でエラーを出せた。

ここにたどり着くまで1日が溶けた。型パズルムズイ。

Content Security Policyの困りごと

CSP入れたほうがいいかなあと思って、いくつかのサイトにCSP Reportを仕込んでる。

Report OnlyはCSPに違反するリクエストがあったときに、ブロックはせずに通知だけしてくれるブラウザの機能で、モダンブラウザは大抵対応している。

ブロック入れる前にこれで様子見をしてるが、結構通知がくる。

引っ掛かっているのは次のようなリクエスト。

https://www.google.at/ads/ga-audiences?...
https://www.google.cn/ads/ga-audiences?...
...

Google Adさんのトップレベルドメインが国別なせいでCSPに登録してない国のGoogleドメインの通知が飛んでくる。

CSPのドメイン指定は、 www.google.* みたいなことはできない。 www.google.evil.xyz とかがすり抜けちゃうから。

えー。つまり、これ全部 CSPに入れる?そんなばかな。

.google.com
.google.ad
.google.ae
.google.com.af
.google.com.ag
.google.com.ai
.google.al
.google.am
.google.co.ao
.google.com.ar
.google.as
.google.at
.google.com.au
.google.az
.google.ba
.google.com.bd
.google.be
.google.bf
.google.bg
.google.com.bh
.google.bi
.google.bj
.google.com.bn
.google.com.bo
.google.com.br
.google.bs
.google.bt
.google.co.bw
.google.by
.google.com.bz
.google.ca
.google.cd
.google.cf
.google.cg
.google.ch
.google.ci
.google.co.ck
.google.cl
.google.cm
.google.cn
.google.com.co
.google.co.cr
.google.com.cu
.google.cv
.google.com.cy
.google.cz
.google.de
.google.dj
.google.dk
.google.dm
.google.com.do
.google.dz
.google.com.ec
.google.ee
.google.com.eg
.google.es
.google.com.et
.google.fi
.google.com.fj
.google.fm
.google.fr
.google.ga
.google.ge
.google.gg
.google.com.gh
.google.com.gi
.google.gl
.google.gm
.google.gr
.google.com.gt
.google.gy
.google.com.hk
.google.hn
.google.hr
.google.ht
.google.hu
.google.co.id
.google.ie
.google.co.il
.google.im
.google.co.in
.google.iq
.google.is
.google.it
.google.je
.google.com.jm
.google.jo
.google.co.jp
.google.co.ke
.google.com.kh
.google.ki
.google.kg
.google.co.kr
.google.com.kw
.google.kz
.google.la
.google.com.lb
.google.li
.google.lk
.google.co.ls
.google.lt
.google.lu
.google.lv
.google.com.ly
.google.co.ma
.google.md
.google.me
.google.mg
.google.mk
.google.ml
.google.com.mm
.google.mn
.google.ms
.google.com.mt
.google.mu
.google.mv
.google.mw
.google.com.mx
.google.com.my
.google.co.mz
.google.com.na
.google.com.ng
.google.com.ni
.google.ne
.google.nl
.google.no
.google.com.np
.google.nr
.google.nu
.google.co.nz
.google.com.om
.google.com.pa
.google.com.pe
.google.com.pg
.google.com.ph
.google.com.pk
.google.pl
.google.pn
.google.com.pr
.google.ps
.google.pt
.google.com.py
.google.com.qa
.google.ro
.google.ru
.google.rw
.google.com.sa
.google.com.sb
.google.sc
.google.se
.google.com.sg
.google.sh
.google.si
.google.sk
.google.com.sl
.google.sn
.google.so
.google.sm
.google.sr
.google.st
.google.com.sv
.google.td
.google.tg
.google.co.th
.google.com.tj
.google.tl
.google.tm
.google.tn
.google.to
.google.com.tr
.google.tt
.google.com.tw
.google.co.tz
.google.com.ua
.google.co.ug
.google.co.uk
.google.com.uy
.google.co.uz
.google.com.vc
.google.co.ve
.google.vg
.google.co.vi
.google.com.vn
.google.vu
.google.ws
.google.rs
.google.co.za
.google.co.zm
.google.co.zw
.google.cat

WSLからホストを介して外部通信する

WindowsでVPNを使用しているとき、WSLのリクエストはVPNを経由できないらしい。

ので、プロキシなりなんなりでWSLのリクエストをホストに渡して、ホスト側からリクエストを投げないといけない。

Windows 11では設定>アプリ>オプション機能から、Open SSH Server(sshd)をインストールできるので、これでポートフォワードしてみる。

インストールしたら、firewall設定が自動的に設定される。初期値はAllだから適宜見直す。

sshdの起動は管理者権限のPowerShellで

PS> Start-Service sshd

あとは、WSL側から接続。

# ポートフォワーディングってこれでいいのかな
$ ssh -L <target host>:443:<target host>:443 <username>@<host ip>

実は、VPN持ってないから試せてない。

TypeScriptで型からプロパティ名をとりたいけど...

C#ではリフレクションで簡単に取れる。

IEnumerable<string> propNames = typeof(T).GetProperties().Select(p => p.Name);

同じようなことをしたい。TypeScriptはJavaScriptにトランスパイルされる。つまり、型があるのはコンパイル時点までだから、リフレクションみたいな実行時の解決はできないはず。だから、あるとしたら言語サポートの特殊キーワードだと思うんだけど見つからない。

typeof も keyof も nameof もちゃうんよ。

できないのかなあ。

ほしい用途としてはAPI周り。

async function apiPost<T>(body: T) {
    const res = await fetch(url, { 
        method: 'post', 
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify(body)
    });
  return await res.json();
}

type ReqBody = {
    prop1: string,
    prop2: string,
}

await apiPost<ReqBody>({prop1: '', prop2: '', prop3: ''}); // これは余剰プロパティチェック (excess property checking)で意図通りエラーになる。

let req1: ReqBody;
req1 = {prop1: '', prop2: '', prop3: ''};
await apiPost(req1); // これはエラーにならない

const req2: ReqBody = JSON.parse('{"prop1":"", "prop2":"", "prop3":""}');
await apiPost(req2); // これもエラーにならない

APIによっては、リクエストに余剰なプロパティがあると400になるものも多い。
TypeScriptには余剰プロパティチェックがあるので、余剰なプロパティがペイロードに入り込むことなんてないぜって思いきや、そうでもない。
場合にもよるけど、チェックが走らないケースは多い。

つまり、apiPostのパラメータ型定義は、最小限その型を満たすプロパティがあるということだけしか保証してない。

どんな引数でもちゃんとPOSTできるようにしようとするとこうなる。

async function apiPostReqBody(body: ReqBody) {
    const res = await fetch(url, { 
        method: 'post', 
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify({
            prop1: body.prop1, // さあ、詰め替えの時間だ!
            prop2: body.prop2,
        })
    });
  return await res.json();
}

詰め替えないといけないので、ジェネリックは諦めなきゃいけないし、propの数だけ頑張らないといけない。
まあ、明白になるから悪くないといえなくもないけど、ぶっちゃけつらい。propが20,30あったらそれはもうつらい

ここで型からプロパティ名がstring[]でとれれば、mapper作れるのに。

もう1つあると便利だなと思うケース。

async function apiGet<T>(path:string, select: string[] = []) {
    let url = `${ep}/${path}?&select=${select.join(',')}`; 
    const res = await fetch(url);
  return await res.json();
}
type User = {
    id: number,
  email: string,
  name: string,
  birthday: string,
  ...
}
type IdAndEmailOfUser = Pick<User, 'id'|'email'>

const userinfo = await apiGet<IdAndEmailOfUser>('users', ['id','email']);

リクエストパラメータでレスポンスに含めるプロパティを絞れるAPIは結構ある。ODataとか。

このとき、型定義から、プロパティ名がとれれば、 'id''email'を2回書かなくていいのに。

型からプロパティ名をとる方法はほんとにないんかあ

Learning Zig #1

ZigがはやってるらしいのでHelloWorldぐらいやっておこうと思った。

Getting Startedに沿って環境を準備。

といっても、zipをダウンロードして展開後そこにPATHを通すだけ。

ついでに、makeも用意した。windows環境なのでGnu for Windowsのバイナリ。 wingetでインストールできるので楽ちん。
PATHは別途通さないとだめ。

> winget install GnuWin32.Make

zigの面白いなと思ったところが、c(c++)言語が、そのまま、コンパイルできるらしいということ。

TypeScriptみたいだな。

Goがでたころ、Better Cっていう表現されてたけど、そのままコンパイルできるzigがでてきたのでBetter Cのポジションはzigが奪っていくのかなあ。Goと違ってGCなしなのもいいなと思った。

同じGCなしだと、Rustがあるけど、Rustに比べてZigのメモリ管理はプログラマ任せ。Cみたいにメモリリーク発生してしまうけど、Go同様defer構文があるので、Cよりもプログラマに優しい。

zigをコンパイルする前に、このCをそのままコンパイルできるってのを試してみる。

次のファイルを用意した。

// main.c
#include <stdio.h>

int main(void) {
    printf_s("Hello,World!!\n");
    return 0;
}
# Makefile
OBJS=main.o
CMD=helloworld.exe
RM=del

CFLAGS+= -g -fexec-charset=UTF-8 -finput-charset=UTF-8

CC=zig cc

build: $(OBJS)
    $(CC) $(CFLAGS) -o $(CMD) $(OBJS)

main.o: main.c
    $(CC) $(CFLAGS) -c main.c -o main.o

clean:
    $(RM) "$(CMD)"
    $(RM) *.o
    $(RM) *.ilk
    $(RM) *.pdb

make buildして実行すると、確かに動いてる。

> make build
> ./helloworld.exe
Hello,World!!
>

そのままコンパイルできるとは言え、コマンドは zig cc で別なのか。

次はzigファイルであれこれしてみよう。

GPD Micro PC 買っちゃった

最近気になってたGPD Micro PC買っちゃった。
メルカリ中古で38,000円

土曜の朝届いて、土日はこいつをセットアップして遊んだ。

CPUはCelron N4100で、RAMは8G。
Windows 10が入ってたけど、最近Linuxで遊びたかったので、はやりのZorinOS Liteを入れてみた。

でもやっぱり、Linux + Laptopは難しいね。Hibernateやら、CPUの周波数制限でバッテリーライフの向上とかやってたら土日が解けました。

最近英語勉強してるけど、そのおともに使おうと思います。

weblio調べたり、YouGlish見たり、SKELLで用例探して、それらをメモする感じに使おうと思ってます。

今までスマホでやってたけど、スマホのキーボードって結構画面を占有するから、メモするとなるとクラムシェルがいいなあと思ってたんですよね。

しばらく試してみます。

An error occurred (MalformedCertificate) when calling the UploadServerCertificate operation: Unknown

AWSに証明書をアップロードするときに、エラーが出た。

aws iam upload-server-certificate --server-certificate-name mycert --certificate-body file://cert.pem --private-key file://private.key --certificate-chain file://chain.pem --path /mycert/

An error occurred (MalformedCertificate) when calling the UploadServerCertificate operation: Unknown

ググると公式ドキュメントが出てくる
https://aws.amazon.com/jp/premiumsupport/knowledge-center/import-ssl-certificate-to-iam/

曰く

API リクエストのパラメータ certificate-body、certificate-chain、private-key に「file://」プレフィックスを指定する必要があります。それ以外の場合、リクエストは「MalformedCertificate:Unknown」(MalformedCertificate: 不明) というエラーメッセージで失敗します。

ちゃんとfile://プレフィックスついてるのになあ。

CLIツールのバージョン上げたが効果なし。

別のドキュメント読むと
https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_server-certs.html

The private key must be unencrypted. You cannot upload a private key that is protected by a password or passphrase. For help decrypting an encrypted private key, see Troubleshooting.

なるほど、パスフレーズ付きの秘密鍵は使えないのね。前にもあったようななかったような。忘れてた。後で探せるようにブロブに書いておくために今に至る。

パスワードついてる場合は次のコマンドでパスワード無しに変換する

openssl x509 -inform DER -in Certificate.der -outform PEM -out Certificate.pem

Azure Functions Isolated でのシングルトン

.NET用のAzure Functionsは元々ホストが実行されるプロセスで関数を実行していたが、ホストとワーカーが別プロセスで動作するIsolatedタイプが今後主流になるということで、仕事で新しく作るFunctionsはIsolatedでやっている。

ただまだまだIsolatedが発展途上なのか困りごとにもしばしば直面する。

よく書く処理で一番こたえがたいのがSingletonがサポートされていないことだ。

Singletonは便利なやつで、どんなにスケールアウトされていても、並列実行数を限定することができるし、同一IDは直列に処理したい時に便利だ。

 a)

 +-----------------+  +-----------------+  +-----------------+
 |ORDER ID: 100    |  |ORDER ID: 100    |  |ORDER ID: 101    |
 |TIMESTAMP: 3s ago|  |TIMESTAMP: 2s ago|  |TIMESTAMP: 1s ago|
 +-------+---------+  +-------+---------+  +--------+--------+
         |                    |                     |
 +-------v--------------------v---------------------v--------+
 |                                                           |
 |                      Azure Functions                      |
 |                                                           |
 +-----------------------------------------------------------+

 b)

 +-----------------+
 |ORDER ID: 100    |
 |TIMESTAMP: 2s ago|
 +-------+---------+
         |
 +-------v---------+                       +-----------------+
 |ORDER ID: 100    |                       |ORDER ID: 101    |
 |TIMESTAMP: 3s ago|                       |TIMESTAMP: 1s ago|
 +-------+---------+                       +--------+--------+
         |                                          |
 +-------v------------------------------------------v--------+
 |                                                           |
 |                      Azure Functions                      |
 |                                                           |
 +-----------------------------------------------------------+

a) はIsolatedで発生してしまう動き、 b)はもともとホスト実行型のAzure Functionsでできてた便利な処理順制御。

やっぱりb)を使いたい。というより重複実行が問題になる処理をFunctionsに載せたいからb)じゃないと困る。

ないなら書くしかなかろうも。少々汚いけども。

public class LockService
{
    const string CONNECTION_STRING_NAME = "AzureWebJobsStorage";
    const string CONTAINER_NAME = "azure-webjobs-hosts";
    private readonly BlobContainerClient _blobContainerClient;
    public LockService(IConfiguration configuration)
    {
        var serviceClient = new BlobServiceClient(configuration[CONNECTION_STRING_NAME]);
        _blobContainerClient = serviceClient.GetBlobContainerClient(CONTAINER_NAME);

        // azure-webjobs-hostsコンテナがないなんてことはないはず
        // _blobContainerClient.CreateIfNotExists(); 
    }

    public async ValueTask<Lock> Lock(string filename, CancellationToken cancellationToken)
    {
        var client = _blobContainerClient.GetBlockBlobClient(filename);
        if (!await client.ExistsAsync().ConfigureAwait(false))
        {
            try
            {
                await client.UploadAsync(new MemoryStream(Array.Empty<byte>())).ConfigureAwait(false);
            }
            catch (Azure.RequestFailedException ex)
            {
                // 並列で実行されている時は発生し得る
                // Leaseがとられてる場合は412が返るのでそれは無視。
                if (ex.Status != 412)
                    throw;
            }
        }
        var @lock = new Lock(client.GetBlobLeaseClient());
        await @lock.StartAsync(cancellationToken).ConfigureAwait(false);
        return @lock;
    }
}

public class Lock : IAsyncDisposable
{
    private readonly BlobLeaseClient _blobLeaseClient;
    private CancellationTokenSource? _cancellationTokenSource = null;
    private Task? _task = null;

    public Lock(BlobLeaseClient blobLeaseClient)
    {
        _blobLeaseClient = blobLeaseClient;

    }
    public async ValueTask StartAsync(CancellationToken cancellationToken)
    {
        if (_task != null)
        {
            throw new InvalidOperationException("Already Locked!");
        }
        while (!cancellationToken.IsCancellationRequested)
        {
            Azure.Response<BlobLease>? response = null;
            try
            {
                // リース時間は60秒がマックスらしい
                response = await _blobLeaseClient.AcquireAsync(TimeSpan.FromSeconds(60), cancellationToken: cancellationToken).ConfigureAwait(false);
            }
            catch (Azure.RequestFailedException ex)
            {
                // 既に誰かがリース済みなら409が返ってくる。
                // 409ならリースが解放されるまで待ちたいのでthrowしない
                if (ex.Status != 409)
                    throw;
            }
            if (response != null && response.Value != null && !string.IsNullOrEmpty(response.Value.LeaseId))
            {
                _cancellationTokenSource = new CancellationTokenSource();
                var cts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationTokenSource.Token, cancellationToken);
                // 処理が60秒で終わらない可能性があるので、バックグラウンドでリースを更新し続ける。
                _task = Task.Run(async () => {
                    var renewed = true;
                    while (!cts.Token.IsCancellationRequested)
                    {
                        await Task.Delay(TimeSpan.FromSeconds(renewed ? 30 : 1), cts.Token).ConfigureAwait(false);
                        try
                        {
                            await _blobLeaseClient.RenewAsync(cancellationToken: cts.Token).ConfigureAwait(false);
                            renewed = true;
                        }
                        catch
                        {
                            renewed = false;
                        }
                    }
                }, cts.Token);
                return;  
            }
            else
            {
                // 誰かがリース済みなら、10秒待ち
                await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
            }
        }
    }
    public async ValueTask FinishAsync(CancellationToken cancellationToken)
    {
        if (_task != null && _cancellationTokenSource != null)
        {
                        _task = null;
            _cancellationTokenSource.Cancel();
                        await _blobLeaseClient.ReleaseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
        }

    }
    public async ValueTask DisposeAsync()
    {
        await FinishAsync(CancellationToken.None).ConfigureAwait(false);
    }
}

使い方

await using var @lock = await _lockService.Lock($"path/to/the/{id}.lock", CancellationToken.None);
// 以降はロックが取れてる状態

このセルフロックの問題点は、BlobInputとかのInputバインドが使えなくなること。
ロックが取れた後に自分でブロブやらテーブルから取得しないと、古いデータで処理することになってしまう。
だからほんとは関数実行前にホスト側でやってほしい。

注意点
ロックによって確保されるのは、同じリースIDが同時実行されないということだけなので、実行される順序自体は不定。
だからステートを内包しているキューでそのステートに順序性があるものには使用できない。 まあそれは従来のFunctionでも一緒のはず。

I released QjsIpc 🎉

Learning WASM 4 ~ 8 の成果をライブラリ化して公開した。

JS側では今回作成したQuickJSの拡張モジュールでるipcモジュールを使用し、プロシージャを登録して、最終的にlistenメソッドを実行する

import * as ipc from 'ipc';

ipc.register('echo', function (payload) {
  return payload;
});

ipc.listen();

.NET側では、QjsIpcEngineのインスタンスを作成し、JSファイルと実行フォルダを指定してQuickJSを実行する。

実行した後は、InvokeAsyncメソッドでJS側に登録したプロシージャを実行できる。

using QjsIpc;

await using var engine = new QjsIpcEngine();

engine.Start(new QjsIpcOptions
{
    ScriptFileName = "main.js",
    AllowedDirectoryPath = Environment.CurrentDirectory,
});

var message = await engine.InvokeAsync<string>("echo", "Hello, World!");

Console.WriteLine(message);

.NET側にプロシージャを用意し、JS側から呼び出すこともできる。

.NET側にプロシージャホストとなるクラスを作成し、そのインスタンスをMethodHostオプションに設定する。
生やすメソッドは、値でもTaskでもValueTaskでも大丈夫。

public class Host
{
    public int HostAdd(int a, int b) => a + b;

    public Task<string> HostStringTask() => Task.FromResult("host value");

    public async Task<string> HostStringAsync() {
        await Task.Delay(100);
        return "host value";
    }

    public async ValueTask<string> HostStringValueAsync() {
        await Task.Delay(100);
        return "host value";
    }
}

public class Program
{
    public static async Task Main(string[] args)
    {
        await using var engine = new QjsIpcEngine();

        engine.Start(new QjsIpcOptions
        {
          ScriptFileName = "main.js",
          AllowedDirectoryPath = Environment.CurrentDirectory,
          MethodHost = new Host()
        });

        await engine.InvokeAsync('createMessage', "iwate");
    }
}

JS側ではipc.invokeメソッドで呼び出せる。戻り値はPromise

import * as ipc from 'ipc';

ipc.register('createMessage', async function (name) {
  const value = ipc.invoke('HostAdd', 100, 200);
  return `Hi, ${name}. The result of HostAdd(100, 200) is ${value}`;
});

ipc.listen();

ベンチマークもとってみたが、想像通り速くはない。けど使えないほどではない。

自分の実装の中で遅い部分の検討はついてて、.NET<->JS間を1バイトずつやり取りしてるのでまあ遅い。

wasmのmemory使ってページ(64kb)ごとにやり取りすればもっと早くなるかな。今後に課題。

// * Summary *

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000
11th Gen Intel Core i7-1165G7 2.80GHz, 1 CPU, 8 logical and 4 physical cores
.NET SDK=6.0.200-preview.21617.4
  [Host]     : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT  [AttachedDebugger]
  DefaultJob : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT


|           Method |       Mean |     Error |    StdDev |
|----------------- |-----------:|----------:|----------:|
|             Echo |   303.7 us |  28.22 us |  81.42 us |
|        HostValue |   456.2 us |  41.25 us | 120.97 us |
|              Ejs | 1,414.2 us |  91.87 us | 269.43 us |
| EjsWithHostValue | 1,499.9 us | 121.33 us | 340.22 us |

Learning WASM #8

前回作ったqjs.wasmとJSON-RPCするプログラムを改良して、dotnetからjsへrpcコール中に、jsからdotnetにrpcコールできるようにした。

これでホスト側に生やしたヘルパー関数をゲスト関数が使える。

もう書いたコードが長くなってきたので全部貼ることはしないけど、いつも通りリポジトリにあげてある。

こんなホスト関数を生やしたクラスを用意して、

public class Host
{
    public async Task<int> HostMethod1(int intParam, float floatParam, string stringParam, ObjParam objParam)
    {
        await Task.Delay(1);
        Console.WriteLine("Invoke HostMethod1");
        Console.WriteLine($"intParam = {intParam}");
        Console.WriteLine($"floatParam = {floatParam}");
        Console.WriteLine($"stringParam = {stringParam}");
        Console.WriteLine($"objParam = {objParam?.Property}");
        return -1;
    }
}

public class ObjParam
{
    public string Property { get; set; }
}

QjsRpcのStartメソッドに渡すと、JS側から呼べるようにしてある。

var dir = AppDomain.CurrentDomain.BaseDirectory;
var rpc = new QjsRpc(dir);
var host = new Host();

rpc.Start(host);

var transformed = await rpc.InvokeAsync<JObject>("transform", new[] { new { name = "iwate" } }, CancellationToken.None);

Console.WriteLine(transformed);

await rpc.CloseWaitAsync();

JS側はdotnet側から呼ぶtransform関数が登録してあって、その中でホストのHostMethod1関数を呼んでいる。
Promiseが解決したら、EJSを実行して、HostMehtod1の結果と混ぜてホストに値を返す。

...
register('transform', function (payload, callback) {
  invoke('HostMethod1', 1, 1.0, 'hello', {property:'propvalue'}).then(function (value) {
    ejs.renderFile('template.ejs', payload, {}, function (err, str) {
      if (err) {
        std.err.puts(err);
      }
      else {
        const transformed = {
          name: payload.name,
          html: str,
          value,
        };
        callback(transformed);
      }
    });
  });
});
...

実行結果はこんな感じ。

Invoke HostMethod1
intParam = 1
floatParam = 1
stringParam = hello
objParam = propvalue
{
  "name": "iwate",
  "html": "<html>\r\n<body>\r\n<p>Hello, Wasmtime! I am iwate.</p>\r\n</body>\r\n</html>",
  "value": -1
}

HostMethod1の出力もあるし、transformedにHostMethod1の結果であるvalue:-1もセットされている。大成功。

いい感じにまとまってきたので、次回はライブラリにしてNuGetに上げようかな。

Learning WASM #7

前回はqjsのカスタモジュールにreadメソッドとwriteメソッドを生やして、hostのdotnetランタイムと文字列のやり取りをできるようにした。

今回は、文字列がやり取りできるようになったのでJSON-RPCを流して、dotnetからjsの関数をコールしてその結果を得たい。

RPC処理ロジックをカスタムモジュールにするには、Cで書く必要がありちょっと大変なので、いったんmain.jsの中にJavaScriptで書いた

import * as host from 'host';
import * as std from 'std';
import ejs from './ejs.mjs';
ejs.fileLoader = function(filename) {
    const file = std.open(filename,'r');
    return file.readAsString();
};

const handlers = {};
function register(methodName, handler) {
    handlers[methodName] = handler;
}
function listen() {
    while(true) {
        const line = host.readLine();

        if (line == '.quit') {
            break;
        }

        const data = JSON.parse(line);
        const func = handlers[data.method];
        if (typeof func == 'function' && Array.isArray(data.params)) {
            func.apply(null, data.params.concat([function (result) {
                host.writeLine(JSON.stringify({
                    jsonrpc: '2.0',
                    result,
                    id: data.id,
                }));
            }]));
        }
    }
}
register('transform', function (payload, callback) {
    ejs.renderFile('template.ejs', payload, {}, function (err, str) {
        if (err) {
            std.err.puts(err);
        }
        else {
            const transformed = {
                name: payload.name,
                html: str
            };
            callback(transformed);
        }
    })
});

listen();

関数を登録する registerとhostとのやり取りを開始するlistenを作成した。 json-rpcにはないが、一応終了させるための .quitを受信するとlistenが終了しプログラムが終了する。

dotnetは前回と同じ、ペイロードとなる rbufの初期値だけ違う

var rpccall = JsonSerializer.Serialize(new
{
    jsonrpc = "2.0",
    method = "transform",
    @params = new[] { new { name = "iwate"} },
    id = Guid.NewGuid().ToString()
});

using var rbuf = new MemoryStream(Encoding.UTF8.GetBytes($"{rpccall}\n.quit\n"));
using var wbuf = new MemoryStream();

using var engine = new Engine();
using var module = Module.FromFile(engine, "qjs.wasm");
using var linker = new Linker(engine);
using var store = new Store(engine);

linker.DefineFunction("env", "read", () => {
    return (int)rbuf.ReadByte();
});

linker.DefineFunction("env", "write", (int c) => {
    wbuf.WriteByte((byte)c);
});

linker.DefineWasi();

store.SetWasiConfiguration(new WasiConfiguration()
    .WithPreopenedDirectory(dir, ".")
    .WithInheritedStandardOutput()
    .WithArgs("--", "main.js"));

var instance = linker.Instantiate(store, module);

var run = instance.GetFunction(store, "_start");

run.Invoke(store);

wbuf.Seek(0, SeekOrigin.Begin);
var str = Encoding.UTF8.GetString(wbuf.ToArray());
Console.WriteLine(str);

実行結果はこんな感じ、EJSの実行結果がJSON-RPC形式で返ってきてる。

{"jsonrpc":"2.0","result":{"name":"iwate","html":"<html>\r\n<body>\r\n<p>Hello, Wasmtime! I am iwate.</p>\r\n</body>\r\n</html>"},"id":"e4c82100-a5b8-4389-bcbf-41b77682f311"}

次は、dotnet側をTask化して、main.jsをlisten状態で常駐させておいて、任意のタイミングで呼び出せるようにしたい。
それか、qjs側のJSON-RPC処理部のCモジュール化とJSON-RPCエラー系の実装か。

どれ最初にやろうかな。まよう。

自由にjs処理を呼べる下地ができてきていよいよ楽しくなってきた。

Learning WASM #6

前回はqjsからhostの関数を呼び出した。

今回は、実際に通信できるように readwrite を生やしてみる。

C側は putsgetLine を参考にこんな風に実装。dotnet側には1byteずつread writeする関数を生やして、jsモジュールとしては改行コードもしくはEOFまで書き込み読み込みし続けるreadLinewriteLineとして実装する。

__attribute__((import_name("read")))extern int host_read();
__attribute__((import_name("write")))extern void host_write(int c);

static JSValue js_host_read_line(JSContext *ctx, JSValueConst this_val, 
                                int argc, JSValueConst *argv)
{
    int c;
    DynBuf dbuf;
    JSValue obj;

    js_std_dbuf_init(ctx, &dbuf);
    for(;;) {
        c = host_read();
        if (c == EOF) {
            if (dbuf.size == 0) {
                /* EOF */
                dbuf_free(&dbuf);
                return JS_NULL;
            } else {
                break;
            }
        }
        if (c == '\n')
            break;
        if (dbuf_putc(&dbuf, c)) {
            dbuf_free(&dbuf);
            return JS_ThrowOutOfMemory(ctx);
        }
    }
    obj = JS_NewStringLen(ctx, (const char *)dbuf.buf, dbuf.size);
    dbuf_free(&dbuf);
    return obj;
}

static JSValue js_host_write_line(JSContext *ctx, JSValueConst this_val, 
                                int argc, JSValueConst *argv)
{
    int i,j;
    size_t len;
    const char *str;

    for(i = 0; i < argc; i++) {
        str = JS_ToCStringLen(ctx, &len, argv[i]);
        if (!str)
            return JS_EXCEPTION;

        for (j = 0; j < len; j++) {
            host_write(str[j]);
        }

        host_write('\n');

        JS_FreeCString(ctx, str);
    }
    return JS_UNDEFINED;
}

static const JSCFunctionListEntry js_host_funcs[] = {
    JS_CFUNC_DEF("readLine", 0, js_host_read_line),
    JS_CFUNC_DEF("writeLine", 1, js_host_write_line),
};

static int js_host_init(JSContext *ctx, JSModuleDef *m)
{
    return JS_SetModuleExportList(ctx, m, js_host_funcs,
                                  countof(js_host_funcs));
}

JSModuleDef *js_init_module_host(JSContext *ctx, const char *module_name)
{
    JSModuleDef *m;
    m = JS_NewCModule(ctx, module_name, js_host_init);
    if (!m)
        return NULL;
    JS_AddModuleExportList(ctx, m, js_host_funcs, countof(js_host_funcs));
    return m;
}

dotnet側はとりあえずStreamに直結させるリングバッファとか考えたけどこれが手っ取り早い

using System;
using System.IO;
using System.Text;
using Wasmtime;

var dir = AppDomain.CurrentDomain.BaseDirectory;
var script =
@"
import * as host from 'host';
console.log('-- start js');
host.writeLine('Hello, I\'m js!');
const str = host.readLine();
console.log(str);
console.log('-- end js');

";
File.WriteAllText(Path.Combine(dir, "main.js"), script);

using var rbuf = new MemoryStream(Encoding.UTF8.GetBytes("Hello, I'm dotnet!\n"));
using var wbuf = new MemoryStream();

using var engine = new Engine();
using var module = Module.FromFile(engine, "qjs.wasm");
using var linker = new Linker(engine);
using var store = new Store(engine);

linker.DefineFunction("env", "read", () => {
    return (int)rbuf.ReadByte();
});

linker.DefineFunction("env", "write", (int c) => {
    wbuf.WriteByte((byte)c);
});

linker.DefineWasi();

store.SetWasiConfiguration(new WasiConfiguration()
    .WithPreopenedDirectory(dir, ".")
    .WithInheritedStandardOutput()
    .WithArgs("--", "main.js"));

var instance = linker.Instantiate(store, module);

var run = instance.GetFunction(store, "_start");

run.Invoke(store);

wbuf.Seek(0, SeekOrigin.Begin);
var str = Encoding.UTF8.GetString(wbuf.ToArray());
Console.WriteLine(str);

実行結果はこんな感じ。 qjs内でdotnetから読み込んだ文字列を標準出力して、実行完了後、dotnet側でjsから受け取った文字列を標準出力してる。

-- start js
Hello, I'm dotnet!
-- end js
Hello, I'm js!

これで、文字列のやり取りができるようになったので、JSON RPCはできそう。

ただ、ループで回してたりするからjsからdotnetにRPCしている中でdotnetからjsにRPCするような、行ったり来たりするRPCはできそうにない。

まあそれは出来なくても大丈夫なはず。

次はJSON RPCを組んでみようと思う。

Learning WASM #5

前回、dotnet上で動かしたwasmtimeの上でJS(QuickJS)を動かした。(ややこしいな

QuickJSの性質上、QuickJSで動いているJSとホストであるdotnetで通信させる方法が、ファイルを返した通信に限られる。

標準入出力でも行けないかと思ったけど、process分離して動いてるわけじゃないから標準入出力を共有してるためフックできなかった。

Cのfopenが実装されているので、ファイルだけでなくUNIXソケットファイルとかでも通信可能そうだったけどそうなるとホストがUNIXやLinuxじゃないとだめなので、開発しにくそうでやめた。おんなじ理由でtmpfsを使ったRAM上の通信もやめた。

せっかく、WASMを使ってるんだから、QucikJSに手を入れてホストと通信する関数をはやしてしまえばいいだろうということで書いてみた。

https://github.com/iwate/learn-wasm-with-dotnet/tree/main/challenge05

今回は簡単にwasmtimeのサンプルも記載されている引数なしのhello関数をホストに生やした。

using var engine = new Engine();
using var linker = new Linker(engine);
using var store = new Store(engine);

linker.DefineFunction("env", "hello", () => { Console.WriteLine("This is called from wasm."); });

QuickJSはCで書かれているので(しかもファイル数が少ない)、カスタムが簡単。

外部の関数を取り込むにはCの作法どうり、externで使いたい関数のシンボルを定義

extern void hello();

↓の感じでモジュールとして拡張できればかっちょいいなと思ったので、

import * as host from 'host'
host.hello();

quickjs_libc.cに他のモジュールを参考にhostモジュールを追加する

extern void hello();

static JSValue js_host_hello(JSContext *ctx, JSValueConst this_val, 
                                int argc, JSValueConst *argv)
{
    hello();
    return JS_UNDEFINED;
}

static const JSCFunctionListEntry js_host_funcs[] = {
    JS_CFUNC_DEF("hello", 0, js_host_hello),
};

static int js_host_init(JSContext *ctx, JSModuleDef *m)
{
    return JS_SetModuleExportList(ctx, m, js_host_funcs,
                                  countof(js_host_funcs));
}

JSModuleDef *js_init_module_host(JSContext *ctx, const char *module_name)
{
    JSModuleDef *m;
    m = JS_NewCModule(ctx, module_name, js_host_init);
    if (!m)
        return NULL;
    JS_AddModuleExportList(ctx, m, js_host_funcs, countof(js_host_funcs));
    return m;
}

ただ、こんなエラーが出てすんなりとはビルドがうまくいかない。

wasm-ld: error: CMakeFiles/qjs.dir/src/quickjs-libc.c.obj: undefined symbol: hello

さっきのexternで定義したホストの関数が特定できない。実行時にリンクされるから、この段階はundefinedでいいんだけど無視してくれない。

wasm-ldのオプション調べたらどうやら --allow-undefined をつければいいらしく、CMakeLists.txtに設定を足す

SET(CMAKE_EXE_LINKER_FLAGS  "${CMAKE_EXE_LINKER_FLAGS} --allow-undefined")

でもそれでもエラー。なぜか無視してくれないから頭を抱えたけど、カンマ区切りで付与することに途中で気づいた。CMakeむずい。

SET(CMAKE_EXE_LINKER_FLAGS  "${CMAKE_EXE_LINKER_FLAGS},--allow-undefined")

無事ビルドできたqjs.wasmを実行する

using System;
using System.IO;
using Wasmtime;

var dir = AppDomain.CurrentDomain.BaseDirectory;
var script =
@"
import * as host from 'host';
host.hello();
";
File.WriteAllText(Path.Combine(dir, "main.js"), script);

using var engine = new Engine();
using var module = Module.FromFile(engine, "qjs.wasm");
using var linker = new Linker(engine);
using var store = new Store(engine);

linker.DefineFunction("env", "hello", () => { Console.WriteLine("This is called from wasm."); });

linker.DefineWasi();

store.SetWasiConfiguration(new WasiConfiguration()
    .WithPreopenedDirectory(dir, ".")
    .WithArgs("--", "main.js"));

var instance = linker.Instantiate(store, module);

var run = instance.GetFunction(store, "_start");

run.Invoke(store);

実行結果はもちろん

This is called from wasm.

やったね。hostの関数を呼び出せた。これでファイルを介さずに通信できる、ホスト機能やデータが使える。