@gasboost/app
v5.5.0
Published
A lightweight TypeScript runtime for building Google Apps Script applications with type-safe GET, POST, and RPC handlers.
Downloads
804
Maintainers
Readme
@gasboost/app
Google Apps Script アプリケーション向けの軽量な TypeScript ランタイムです。
Google Apps Script 固有のグローバル関数を直接管理する代わりに、GET / POST / RPC ハンドラや middleware を通常の TypeScript API として定義できます。
インストール
pnpm add @gasboost/appnpm:
npm install @gasboost/appTypeScript で GAS API を利用する場合は型定義も追加してください。
pnpm add -D @types/google-apps-scriptQuick Start
import { AppsScript, type InferAppsScript } from "@gasboost/app";
const app = new AppsScript()
.get((request) => {
const name = request.query("name") ?? "world";
return HtmlService.createHtmlOutput(`Hello ${name}`);
})
.post((request) => {
return ContentService.createTextOutput(request.text());
})
.call("sum", (a: number, b: number) => a + b)
.call("getUser", async (id: string) => ({
id,
name: "Taro",
}));
export default app;
export type AppType = InferAppsScript<typeof app>;GET
.get() で doGet に対応するハンドラを登録します。
const app = new AppsScript().get((request) => {
const id = request.query("id");
return ContentService.createTextOutput(id ?? "");
});登録すると、GAS から呼び出される doGet がグローバルランタイムへ公開されます。
クエリパラメータは以下の API から取得できます。
request.query();
request.query("id");
request.queries();
request.queries("tag");request.path() は GAS の e.pathInfo をそのまま返します(5.4.0以降)。
/exec/bootstrap?token=example へのアクセスでは "bootstrap"、パスが未指定なら
undefined です。先頭・末尾の / の除去やURLデコードなどの変換は行いません。
クエリ取得と同時に使用できます。
const app = new AppsScript().get((request) => {
const path: string | undefined = request.path();
const token: string | undefined = request.query("token");
return ContentService.createTextOutput(JSON.stringify({ path, token }));
});GET ハンドラは同期的に HtmlOutput または TextOutput を返します。
Promiseを返すハンドラはサポートしません。この制約の詳細は
Issue #66を参照してください。
POST
.post() で doPost に対応するハンドラを登録します。
const app = new AppsScript().post((request) => {
return ContentService.createTextOutput(request.text());
});Body を文字列として取得できます。
request.text();JSON としてパースすることもできます。
const body = request.json<{
name: string;
}>();POST ハンドラも HtmlOutput または TextOutput を返します。
RPC
.call() で名前付き RPC を登録します。
const app = new AppsScript()
.call("sum", (a: number, b: number) => a + b)
.call("getUser", async (id: string) => ({
id,
name: "Taro",
}));登録された名前は GAS のグローバルスコープへ公開されます。
同じ名前を複数回登録することはできません。
RPC ハンドラは同期・非同期のどちらにも対応しています。
複数の RPC handler をまとめて登録する
calls() を使うと、複数の RPC handler をまとめて登録できます。
const handlers = {
signIn: (email: string, password: string) => {
// ...
},
signOut: () => {
// ...
},
};
const app = new AppsScript().calls(handlers);calls() は内部的に既存の call() と同じ登録処理を使用します。
Middleware
.use() で GET / POST / RPC の実行前後に共通処理を追加できます。
middleware には AppsScriptContext と next() が渡されます。
const app = new AppsScript()
.use((context, next) => {
console.log(context.invocation.type);
console.log("before");
const result = next();
console.log("after");
return result;
})
.call("hello", () => {
return "Hello";
});middleware は登録順に実行されます。
next() を呼ぶと次の middleware へ進み、最後の middleware から next() を呼ぶと対象の GET / POST / RPC ハンドラが実行されます。
const app = new AppsScript()
.use((_context, next) => {
console.log("middleware 1 before");
const result = next();
console.log("middleware 1 after");
return result;
})
.use((_context, next) => {
console.log("middleware 2 before");
const result = next();
console.log("middleware 2 after");
return result;
})
.call("hello", () => {
console.log("handler");
return "Hello";
});実行順は次のようになります。
middleware 1 before
middleware 2 before
handler
middleware 2 after
middleware 1 afterGET / POST のルート固有 middleware
.get(handler, middlewares?) / .post(handler, middlewares?) の第2引数に、.call() と同じ middleware 配列を指定できます。省略時は従来どおりです。
import { AppsScript, type AppsScriptMiddleware } from "@gasboost/app";
type SystemStatusState = {
initialized: boolean;
appsScriptSettingsUrl: string;
};
const systemStatus: AppsScriptMiddleware<{}, SystemStatusState> = (context, next) => {
const properties = PropertiesService.getScriptProperties();
context.state.set("initialized", properties.getProperty("BOOTSTRAPPED") === "true");
context.state.set(
"appsScriptSettingsUrl",
`https://script.google.com/home/projects/${ScriptApp.getScriptId()}/settings`,
);
return next();
};
const app = new AppsScript()
.get((_request, context) => {
const template = HtmlService.createTemplateFromFile("index");
template.ssr = JSON.stringify({
initialized: context.state.get("initialized"), // boolean
appsScriptSettingsUrl: context.state.get("appsScriptSettingsUrl"), // string
});
return template.evaluate();
}, [systemStatus])
.post((request) => ContentService.createTextOutput(request.text()));実行順は、ルート登録時点までの .use() → ルート固有 middleware → handler です。後から追加した .use() は既登録の GET / POST へ遡及しません。ルート固有 middleware は他の GET / POST / RPC へ適用されず、その State の型も伝播しません。
配列の順序に沿って State の依存関係を検証し、各 middleware が保証する State を handler の Context へ推論します。必要な State がまだ保証されていない順序は型エラーになります。同じ invocation の Context / State を共有し、next() を呼ばない場合は後続と handler を実行せず、その middleware の返却値を返します。
Context
AppsScriptContext から現在の invocation に紐づく State と invocation 情報を取得できます。
app.use((context, next) => {
context.state;
context.invocation;
return next();
});同一の GET / POST / RPC invocation では、middleware と handler が同じ Context / State を共有します。
handler でも Context を受け取れます。
RPC:
app.call("getCurrentUser", (_input, context) => {
return context.state.get("user");
});GET:
app.get((request, context) => {
context.state;
return ContentService.createTextOutput(request.query("name") ?? "");
});POST:
app.post((request, context) => {
context.state;
return ContentService.createTextOutput(request.text());
});Context と State は invocation ごとに新しく生成されます。
そのため、複数の RPC が並行実行された場合でも State は invocation 間で共有されません。
Invocation
context.invocation は GET / POST / RPC を表す discriminated union です。
app.use((context, next) => {
if (context.invocation.type === "get") {
const token = context.invocation.request.query("token");
console.log(token);
}
if (context.invocation.type === "post") {
const token = context.invocation.request.query("token");
const body = context.invocation.request.json();
console.log(token);
console.log(body);
}
if (context.invocation.type === "call") {
console.log(context.invocation.name);
console.log(context.invocation.args);
}
return next();
});GET invocation:
type AppsScriptGetInvocation = {
readonly type: "get";
readonly request: AppsScriptHttpRequest;
};POST invocation:
type AppsScriptPostInvocation = {
readonly type: "post";
readonly request: AppsScriptPostRequest;
};RPC invocation:
type AppsScriptCallInvocation = {
readonly type: "call";
readonly name: string;
readonly args: readonly unknown[];
};Short circuit
next() を呼ばずに値を返すことで、後続の middleware とハンドラの実行を停止できます。
const app = new AppsScript()
.use((_context, next) => {
const authenticated = false;
if (!authenticated) {
return {
error: "Unauthorized",
};
}
return next();
})
.call("getProfile", () => {
return {
name: "Taro",
};
});この場合、getProfile ハンドラは実行されません。
middleware は GET / POST / RPC のすべてに適用されます。
State
middleware と handler の間で invocation-scoped な値を共有するために state を利用できます。
State は AppsScript instance に保持される共有状態ではありません。
GET / POST / RPC の invocation ごとに新しい State が生成され、その invocation の middleware と handler の間だけで共有されます。
middleware が後続処理に State を保証する場合は、AppsScriptMiddleware の input state と output state で表現します。
import { AppsScript, type AppsScriptMiddleware } from "@gasboost/app";
interface User {
id: string;
name: string;
}
type UserState = {
user: User;
};
const app = new AppsScript();
app.use<{}, UserState>((context, next) => {
context.state.set("user", {
id: "1",
name: "Taro",
});
return next();
});
app.call("getCurrentUser", (_input, context) => {
const user = context.state.get("user");
// user: User
return user;
});middleware が保証した State は、後続 middleware / handler では undefined を含まない型として取得できます。
複数の middleware が State を追加する場合も合成できます。
type SessionState = {
session: {
userId: string;
};
};
type TenantState = {
tenant: {
id: string;
};
};
const sessionMiddleware: AppsScriptMiddleware<{}, SessionState> = (
context,
next,
) => {
context.state.set("session", {
userId: "user-1",
});
return next();
};
const tenantMiddleware: AppsScriptMiddleware<
SessionState,
SessionState & TenantState
> = (context, next) => {
const session = context.state.get("session");
context.state.set("tenant", {
id: `tenant-${session.userId}`,
});
return next();
};
const app = new AppsScript()
.use(sessionMiddleware)
.use(tenantMiddleware)
.call("handler", (_input, context) => {
const session = context.state.get("session");
const tenant = context.state.get("tenant");
return {
session,
tenant,
};
});State の key と value は型安全です。
存在しない key や異なる型の値は TypeScript の型エラーになります。
State は authentication、authorization、logging、tracing など、middleware から後続処理へ invocation 単位の情報を渡す用途に利用できます。
Middleware をパッケージとして提供する
AppsScriptMiddleware は public API として利用できます。
これにより、@gasboost/auth のような外部パッケージから、後続 middleware / handler に State を保証する middleware を提供できます。
import type { AppsScriptMiddleware } from "@gasboost/app";
interface User {
id: string;
name: string;
}
type AuthState = {
user: User;
};
export const auth = (): AppsScriptMiddleware<{}, AuthState> => {
return (context, next) => {
const user = {
id: "1",
name: "Taro",
};
context.state.set("user", user);
return next();
};
};middleware は invocation 情報にもアクセスできるため、RPC 名や HTTP request に応じた認証・認可も実装できます。
export const auth = (): AppsScriptMiddleware<{}, AuthState> => {
return (context, next) => {
if (context.invocation.type === "call") {
console.log(context.invocation.name);
}
context.state.set("user", {
id: "1",
name: "Taro",
});
return next();
};
};利用側では通常の middleware と同じように登録できます。
import { AppsScript } from "@gasboost/app";
import { auth } from "@gasboost/auth";
const app = new AppsScript()
.use(auth())
.call("getCurrentUser", (_input, context) => {
const user = context.state.get("user");
// user: User
return user;
});auth() が AuthState を保証しているため、後続 handler の context.state.get("user") は User | undefined ではなく User として推論されます。
これにより、認証などの個別機能を @gasboost/app 本体へ組み込まず、型安全な独立 middleware パッケージとして提供できます。
InferAppsScript
InferAppsScript は、登録された RPC からクライアントと共有できる RPC 契約を生成します。
export type AppType = InferAppsScript<typeof app>;例えば、
.call("getUser", async (id: string) => ({
id,
name: "Taro",
}));から次の型が推論されます。
type AppType = {
getUser: {
args: [id: string];
result: {
id: string;
name: string;
};
};
};Promise の戻り値は展開されます。
また、GAS RPC のレスポンスが JSON として転送されることに合わせて、戻り値に含まれる Date は型上でも string に変換されます。
配列やオブジェクト内に含まれる Date についても再帰的に変換されます。
Middleware や State の型は RPC 契約には含まれません。
フロントエンドとの型共有
バックエンドから RPC 契約だけを import できます。
import type { AppType } from "../backend/main";import type を使用することで、バックエンドのランタイムコードをフロントエンド bundle に含めずに型だけを共有できます。
この型は @gasboost/client から利用できます。
GAS 型を backend のみに限定する
@types/google-apps-script が提供する SpreadsheetApp、Utilities、HtmlService などは ambient global 型です。
frontend 側へ GAS 固有の global 型を持ち込まないため、google-apps-script の型は backend 側の TypeScript 設定でだけ有効にしてください。
例えば次のような構成にできます。
project/
├── tsconfig.json
└── src/
├── backend/
│ ├── tsconfig.json
│ └── main.ts
└── frontend/
└── main.tsbackend の tsconfig.json だけに GAS の型を指定します。
{
"compilerOptions": {
"types": ["google-apps-script"]
}
}root や frontend 側では google-apps-script を読み込む必要はありません。
frontend では backend が export した RPC 契約を import type で参照します。
import type { AppType } from "../backend/main";import type のため、backend の runtime 実装は frontend bundle に含まれません。
また、backend 配下だけで google-apps-script の型を有効にしておけば、SpreadsheetApp、Utilities、HtmlService などの GAS 固有 global 型を frontend 側で利用可能にする必要もありません。
TypeScript Project References は、この型共有のための必須要件ではありません。プロジェクト構成上必要な場合には利用できますが、gasboost の標準的な型共有では backend 側に GAS 型のスコープを限定し、frontend から import type する構成で十分です。
責務
@gasboost/app が担当するもの:
- GET ハンドラ登録
- POST ハンドラ登録
- RPC ハンドラ登録
- middleware の登録と実行
- GET / POST / RPC の invocation 情報を Context として middleware へ提供
- middleware 間およびハンドラとの State 共有
- middleware による処理の short circuit
- GAS リクエストのラップ
- GET / POST dispatch
- RPC dispatch
- ハンドラのグローバルランタイムへの登録
- RPC 契約の型推論
- GAS RPC の JSON シリアライズに対応した戻り値型の変換
認証、認可、logging、tracing などの個別機能は @gasboost/app 本体では扱わず、middleware を利用する別パッケージとして実装できます。
Google Apps Script が静的に認識するためのグローバル関数宣言の生成は @gasboost/vite が担当します。
GAS API
GAS API 自体は抽象化しません。
SpreadsheetApp.getActive();
HtmlService.createHtmlOutput();
ContentService.createTextOutput();通常通り直接利用できます。
License
MIT
RPC metadata
RPC の業務 input と独立した RpcMetadata = Record<string, unknown> を middleware から参照できます。
const app = new AppsScript()
.use((context, next) => {
if (context.invocation.type === "call") {
const metadata = context.invocation.metadata;
// application middleware が独自の namespace / schema を解釈する。
}
return next();
})
.call("createEmployee", ({ name }: { name: string }) => ({ name }));
await app.dispatch("createEmployee", { name: "Taro" }, {
"@my-app/tracing": { traceId: "trace" },
});登録された GAS global wrapper も (input?, metadata?) の別引数を受け取ります。metadata は call invocation ごとに保持され、InferAppsScript の input 型には含まれません。app 自体は metadata の内容を解釈せず、認証・token・session の schema を定義しません。既存の metadata 未指定の呼び出しは維持されます。
metadata だけを付ける no-input RPC では、AppsScriptTransport は GAS の引数制約に合わせて google.script.run[name](null, metadata) を送ります(Google の引数仕様)。GAS global wrapper・dispatch・middleware の invocation.input・handler が受け取る値は null です。明示的な null 入力を維持するため、サーバー側では undefined に正規化しません。metadata も input も未指定なら従来通り0引数で送り、サーバーの input は undefined です。直接 dispatch(name, undefined, metadata) を呼ぶ場合も undefined を保持します。
RPC errors (5.5.0+)
RPCから公開してよい失敗には AppsScriptError({ code, message }) を使います。
code は任意の文字列です。message もクライアントに返すため、秘密情報を含めないでください。
Domain / Application例外はPresentation middlewareで変換できます。保護対象のRPCより前に登録します。
import { AppsScript, AppsScriptError, type AppsScriptMiddleware } from "@gasboost/app";
const errors: AppsScriptMiddleware = async (_context, next) => {
try {
return await next();
} catch (cause) {
if (cause instanceof EmployeeAlreadyExistsError) {
throw new AppsScriptError({
code: "EMPLOYEE_ALREADY_EXISTS",
message: "Employee already exists",
});
}
throw cause;
}
};
const app = new AppsScript().use(errors).calls(employeeHandlers);EmployeeAlreadyExistsError と employeeHandlers はConsumer側で定義します。
通常のError・非Error throw・結果のJSON変換失敗は、RPC公開境界で
{ code: "INTERNAL_ERROR", message: "An unexpected error occurred" } に変換します。
元のメッセージ、スタック、追加プロパティは送信しません。GET / POSTは従来通りです。
Auth / Bootstrap固有コードの分類は、それぞれのライブラリまたはConsumerが担当します。
GASが独自Errorのプロパティを保持することに依存せず、dispatch() は失敗時に
GASBOOST_RPC_ERROR_V1: + JSON形式のmessageを持つErrorをthrowします。
JSONのフィールドは code と message のみで、Errorのstackは未設定です。
成功時は従来の AppsScriptResponse.contents のままです。
@gasboost/client 0.6.0以降は、GAS failure handlerとFetchの双方でこの形式を復元します。
既存の @gasboost/vite Local RPCエンドポイントが生成する
{ error: { name, message, stack } } も、新しいmessageをそのまま運べます。
このRPC wire形式の伝搬にVite側の追従変更は不要です。将来のエンドポイントは
{ error: { code, message } } を返してもClientが復元できます。
dispatch() より前のentry import等で発生するVite自身の例外は、既存のHTTPレスポンス形式を通ります。
Clientでは安全なフォールバックになりますが、そのHTTP payload自体の秘匿化は
gasboost/dev 側での別対応が必要です。
アップグレード時はAppとClientを一緒に更新してください。コードなしの旧サーバーエラーは
新Clientで安全な INTERNAL_ERROR になります。
配布パッケージと既存Vite形式・GASのmessageのみのfailure semanticsは
pnpm smoke:rpc で検証できます。
