sesame-kit 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +26 -0
  2. package/LICENSE.biz3 +21 -0
  3. package/README.ja.md +225 -0
  4. package/README.md +222 -0
  5. package/bin/sesame.js +8 -0
  6. package/clients/js/sesame-client.mjs +208 -0
  7. package/clients/python/pyproject.toml +5 -0
  8. package/clients/python/sesame_client.py +323 -0
  9. package/clients/python/setup.cfg +11 -0
  10. package/docs/architecture.ja.md +132 -0
  11. package/docs/architecture.md +105 -0
  12. package/docs/commands.ja.md +316 -0
  13. package/docs/commands.md +308 -0
  14. package/docs/library.ja.md +152 -0
  15. package/docs/library.md +152 -0
  16. package/docs/migration.ja.md +13 -0
  17. package/docs/migration.md +13 -0
  18. package/package.json +114 -0
  19. package/src/access.js +375 -0
  20. package/src/account.js +36 -0
  21. package/src/auth.js +248 -0
  22. package/src/ble/devicemodel.js +164 -0
  23. package/src/ble/index.js +185 -0
  24. package/src/ble/protocol.js +319 -0
  25. package/src/ble/session.js +235 -0
  26. package/src/ble/transport.js +279 -0
  27. package/src/cli/access.js +373 -0
  28. package/src/cli/company.js +104 -0
  29. package/src/cli/iot.js +400 -0
  30. package/src/cli/org.js +788 -0
  31. package/src/cli/presetir.js +188 -0
  32. package/src/cli/schedule.js +83 -0
  33. package/src/cli/serve.js +308 -0
  34. package/src/cli.js +1815 -0
  35. package/src/client.js +957 -0
  36. package/src/company.js +147 -0
  37. package/src/config.js +575 -0
  38. package/src/crypto.js +162 -0
  39. package/src/devices.js +228 -0
  40. package/src/index.js +55 -0
  41. package/src/iot.js +513 -0
  42. package/src/ir.js +341 -0
  43. package/src/itemcodes.js +29 -0
  44. package/src/lock.js +194 -0
  45. package/src/org.js +803 -0
  46. package/src/paths.js +30 -0
  47. package/src/presetir.js +525 -0
  48. package/src/prompts.js +74 -0
  49. package/src/schedule.js +108 -0
  50. package/src/serve/daemon.js +251 -0
  51. package/src/serve/framing/grpc.js +145 -0
  52. package/src/serve/framing/http.js +144 -0
  53. package/src/serve/framing/ndjson.js +75 -0
  54. package/src/serve/framing/socket.js +73 -0
  55. package/src/serve/framing/stdio.js +28 -0
  56. package/src/serve/framing/token.js +36 -0
  57. package/src/serve/framing/ws.js +56 -0
  58. package/src/serve/grpc-methods.generated.json +378 -0
  59. package/src/serve/jsonrpc.js +164 -0
  60. package/src/serve/registry.js +226 -0
  61. package/src/serve/rpc-params.generated.json +1746 -0
  62. package/src/serve/sesame.proto +470 -0
  63. package/src/session-ui.js +181 -0
  64. package/src/sharekey.js +130 -0
  65. package/src/tokens.js +53 -0
  66. package/src/transport.js +634 -0
  67. package/src/util.js +26 -0
  68. package/types/access.d.ts +193 -0
  69. package/types/access.d.ts.map +1 -0
  70. package/types/account.d.ts +13 -0
  71. package/types/account.d.ts.map +1 -0
  72. package/types/auth.d.ts +80 -0
  73. package/types/auth.d.ts.map +1 -0
  74. package/types/ble/devicemodel.d.ts +212 -0
  75. package/types/ble/devicemodel.d.ts.map +1 -0
  76. package/types/ble/index.d.ts +160 -0
  77. package/types/ble/index.d.ts.map +1 -0
  78. package/types/ble/protocol.d.ts +201 -0
  79. package/types/ble/protocol.d.ts.map +1 -0
  80. package/types/ble/session.d.ts +129 -0
  81. package/types/ble/session.d.ts.map +1 -0
  82. package/types/ble/transport.d.ts +67 -0
  83. package/types/ble/transport.d.ts.map +1 -0
  84. package/types/cli/access.d.ts +6 -0
  85. package/types/cli/access.d.ts.map +1 -0
  86. package/types/cli/company.d.ts +6 -0
  87. package/types/cli/company.d.ts.map +1 -0
  88. package/types/cli/iot.d.ts +6 -0
  89. package/types/cli/iot.d.ts.map +1 -0
  90. package/types/cli/org.d.ts +6 -0
  91. package/types/cli/org.d.ts.map +1 -0
  92. package/types/cli/presetir.d.ts +6 -0
  93. package/types/cli/presetir.d.ts.map +1 -0
  94. package/types/cli/schedule.d.ts +6 -0
  95. package/types/cli/schedule.d.ts.map +1 -0
  96. package/types/cli/serve.d.ts +2 -0
  97. package/types/cli/serve.d.ts.map +1 -0
  98. package/types/cli.d.ts +2 -0
  99. package/types/cli.d.ts.map +1 -0
  100. package/types/client.d.ts +463 -0
  101. package/types/client.d.ts.map +1 -0
  102. package/types/company.d.ts +94 -0
  103. package/types/company.d.ts.map +1 -0
  104. package/types/config.d.ts +111 -0
  105. package/types/config.d.ts.map +1 -0
  106. package/types/crypto.d.ts +61 -0
  107. package/types/crypto.d.ts.map +1 -0
  108. package/types/devices.d.ts +116 -0
  109. package/types/devices.d.ts.map +1 -0
  110. package/types/index.d.ts +23 -0
  111. package/types/index.d.ts.map +1 -0
  112. package/types/iot.d.ts +312 -0
  113. package/types/iot.d.ts.map +1 -0
  114. package/types/ir.d.ts +147 -0
  115. package/types/ir.d.ts.map +1 -0
  116. package/types/itemcodes.d.ts +21 -0
  117. package/types/itemcodes.d.ts.map +1 -0
  118. package/types/lock.d.ts +89 -0
  119. package/types/lock.d.ts.map +1 -0
  120. package/types/org.d.ts +468 -0
  121. package/types/org.d.ts.map +1 -0
  122. package/types/paths.d.ts +10 -0
  123. package/types/paths.d.ts.map +1 -0
  124. package/types/presetir.d.ts +286 -0
  125. package/types/presetir.d.ts.map +1 -0
  126. package/types/prompts.d.ts +39 -0
  127. package/types/prompts.d.ts.map +1 -0
  128. package/types/schedule.d.ts +71 -0
  129. package/types/schedule.d.ts.map +1 -0
  130. package/types/serve/daemon.d.ts +133 -0
  131. package/types/serve/daemon.d.ts.map +1 -0
  132. package/types/serve/framing/grpc.d.ts +14 -0
  133. package/types/serve/framing/grpc.d.ts.map +1 -0
  134. package/types/serve/framing/http.d.ts +14 -0
  135. package/types/serve/framing/http.d.ts.map +1 -0
  136. package/types/serve/framing/ndjson.d.ts +19 -0
  137. package/types/serve/framing/ndjson.d.ts.map +1 -0
  138. package/types/serve/framing/socket.d.ts +14 -0
  139. package/types/serve/framing/socket.d.ts.map +1 -0
  140. package/types/serve/framing/stdio.d.ts +11 -0
  141. package/types/serve/framing/stdio.d.ts.map +1 -0
  142. package/types/serve/framing/token.d.ts +11 -0
  143. package/types/serve/framing/token.d.ts.map +1 -0
  144. package/types/serve/framing/ws.d.ts +13 -0
  145. package/types/serve/framing/ws.d.ts.map +1 -0
  146. package/types/serve/jsonrpc.d.ts +118 -0
  147. package/types/serve/jsonrpc.d.ts.map +1 -0
  148. package/types/serve/registry.d.ts +41 -0
  149. package/types/serve/registry.d.ts.map +1 -0
  150. package/types/session-ui.d.ts +36 -0
  151. package/types/session-ui.d.ts.map +1 -0
  152. package/types/sharekey.d.ts +35 -0
  153. package/types/sharekey.d.ts.map +1 -0
  154. package/types/tokens.d.ts +20 -0
  155. package/types/tokens.d.ts.map +1 -0
  156. package/types/transport.d.ts +138 -0
  157. package/types/transport.d.ts.map +1 -0
  158. package/types/util.d.ts +20 -0
  159. package/types/util.d.ts.map +1 -0
  160. package/vendor/biz3/README.md +37 -0
  161. package/vendor/biz3/constants/cmdCode.d.ts +48 -0
  162. package/vendor/biz3/constants/cmdCode.d.ts.map +1 -0
  163. package/vendor/biz3/constants/cmdCode.js +92 -0
  164. package/vendor/biz3/constants/messageConstants.d.ts +28 -0
  165. package/vendor/biz3/constants/messageConstants.d.ts.map +1 -0
  166. package/vendor/biz3/constants/messageConstants.js +30 -0
  167. package/vendor/biz3/constants/sesameDeviceModel.d.ts +75 -0
  168. package/vendor/biz3/constants/sesameDeviceModel.d.ts.map +1 -0
  169. package/vendor/biz3/constants/sesameDeviceModel.js +77 -0
  170. package/vendor/biz3/package.json +5 -0
@@ -0,0 +1,108 @@
1
+ // 予約実行スケジュール管理 (biz3Schedule)。
2
+ //
3
+ // Ported from biz3 (CANDY-HOUSE/biz3, MIT):
4
+ // - vendor reference: references_web/src/api/useManageSchedule.js
5
+ // - item フィールド: references_web/src/pages/biz/schedule-list/index.js
6
+ //
7
+ // 概要:
8
+ // biz3Schedule は「指定時刻に lock / unlock / upgrade_firmware を実行する」予約の
9
+ // 一覧取得 (getScheduleList) と取消 (cancelSchedule) の 2 op のみ。
10
+ // スケジュールの新規作成 op は biz3 web 側に存在しない (createSchedule/addSchedule とも
11
+ // grep でヒットせず: references_web/src 全体)。登録は別経路/別アプリ由来の可能性があるが
12
+ // 本ファイル群からは未確認。CLI は list + cancel に限定する。
13
+ //
14
+ // フレームの要点 (useManageSchedule.js を 1 行ずつ確認):
15
+ // - フラットな JSON。obj ラップ無し。companyID / apiKeyId は付与しない。
16
+ // - userId フィールドに gStripe.customerInfo.subUUID を **加工せず** そのまま入れる
17
+ // (大文字化やハイフン加工なし / useManageSchedule.js:13,17,52,56)。
18
+ // - subUUID が falsy なら送信自体を中止 (useManageSchedule.js:14,53 は return)。
19
+ // - 応答は action+op で dispatch される (useCallbacks.js:17-31 が action→op の 2 段照合 /
20
+ // registerCallback(action, messageData.op, cb))。よって request の op を frame に
21
+ // 乗せれば transport の `${action}:${op}` キーで一致する。
22
+
23
+ import { ACTION_TYPES } from "../vendor/biz3/constants/messageConstants.js";
24
+
25
+ // action 文字列は vendor (biz3 messageConstants) から引く (手書きしない)。
26
+ // messageConstants.js:21 BIZ3_SCHEDULE: 'biz3Schedule'
27
+ const ACT_SCHEDULE = ACTION_TYPES.BIZ3_SCHEDULE; // "biz3Schedule"
28
+ const DEFAULT_TIMEOUT_MS = 10_000;
29
+
30
+ /**
31
+ * @typedef {Object} ScheduleItem
32
+ * @property {string} scheduleId 取消に使う ID (schedule-list/index.js:49,82,102)
33
+ * @property {string} action 'lock' | 'unlock' | 'upgrade_firmware' 等
34
+ * (schedule-list/index.js:77 / UI ラベルは正規化された表示用で
35
+ * サーバ側の正式 enum は未確認)
36
+ * @property {string} displayTime '2026-01-01 09:00' または 'HH:MM' 形式 (index.js:78,31)
37
+ * @property {string} deviceName 対象デバイス名 (index.js:92)
38
+ */
39
+
40
+ /**
41
+ * サーバに登録済みの予約スケジュール一覧を取得する。
42
+ *
43
+ * フレーム: { action: "biz3Schedule", userId: <subUUID>, op: "getScheduleList" }
44
+ * 応答: { action: "biz3Schedule", op: "getScheduleList", data: [ ScheduleItem, ... ] }
45
+ * message.data は **オブジェクトでラップされず直接配列** (useManageSchedule.js:34-35
46
+ * が count=data.length / Items=data に自前で詰め替えている点に注意)。
47
+ *
48
+ * @param {import("./transport.js").Hub3WsClient} client
49
+ * @param {{subUUID:string, timeoutMs?:number}} params
50
+ * subUUID は gStripe.customerInfo.subUUID 相当 (生の文字列をそのまま userId に入れる)
51
+ * @returns {Promise<ScheduleItem[]>} スケジュール item の配列 (空なら [])
52
+ */
53
+ export async function getScheduleList(client, { subUUID, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
54
+ // biz3: subUUID が falsy なら送信自体を中止 (useManageSchedule.js:14)。
55
+ if (!subUUID) throw new Error("subUUID required (gStripe.customerInfo.subUUID)");
56
+
57
+ // フィールド順は原典 (useManageSchedule.js:15-19) のリテラル順 action, userId, op に合わせる。
58
+ const frame = {
59
+ action: ACT_SCHEDULE,
60
+ userId: subUUID,
61
+ op: "getScheduleList",
62
+ };
63
+ const resp = await client.request(frame, timeoutMs);
64
+ if (resp && resp.success === false) {
65
+ throw new Error(`getScheduleList failed: ${resp.message || JSON.stringify(resp)}`);
66
+ }
67
+ // 応答 data は配列直返し (useManageSchedule.js:34-35)。欠落時は空配列。
68
+ return Array.isArray(resp?.data) ? resp.data : [];
69
+ }
70
+
71
+ /**
72
+ * 予約スケジュールを 1 件取消す。
73
+ *
74
+ * フレーム: { action: "biz3Schedule", userId: <subUUID>, scheduleId: <scheduleId>, op: "cancelSchedule" }
75
+ * (フィールド順は原典 useManageSchedule.js:54-59 のリテラル順)
76
+ * 応答: { action: "biz3Schedule", op: "cancelSchedule", ... }
77
+ *
78
+ * 未確認: cancelSchedule 応答 data の具体構造は web コードからは判別不可。biz3 hook の switch
79
+ * には cancelSchedule ケースが無く (useManageSchedule.js:31-41 は getScheduleList のみ)、
80
+ * 完了は registerCallback の cb 受信 (= ack) でしか確認していない。UI は楽観的に isCancelling
81
+ * フラグを立てるだけ (schedule-list/index.js:47-51)。よって本実装は **ack 受信=成功** とみなし、
82
+ * resp.success が明示的に false の場合のみ throw する。成功フラグ等の data 形は実機検証要。
83
+ *
84
+ * @param {import("./transport.js").Hub3WsClient} client
85
+ * @param {{subUUID:string, scheduleId:string, timeoutMs?:number}} params
86
+ * scheduleId は getScheduleList で得た item.scheduleId をそのまま渡す。
87
+ * @returns {Promise<any>} サーバ応答 (ack)。data の構造は未確認のため raw を返す。
88
+ */
89
+ export async function cancelSchedule(client, { subUUID, scheduleId, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
90
+ // biz3: subUUID が falsy なら送信中止 (useManageSchedule.js:53)。
91
+ if (!subUUID) throw new Error("subUUID required (gStripe.customerInfo.subUUID)");
92
+ if (!scheduleId) throw new Error("scheduleId required");
93
+
94
+ const frame = {
95
+ action: ACT_SCHEDULE,
96
+ userId: subUUID,
97
+ scheduleId,
98
+ op: "cancelSchedule",
99
+ };
100
+ const resp = await client.request(frame, timeoutMs);
101
+ if (resp && resp.success === false) {
102
+ throw new Error(`cancelSchedule failed: ${resp.message || JSON.stringify(resp)}`);
103
+ }
104
+ return resp;
105
+ }
106
+
107
+ // 公開 op の allowlist (SesameHub3._bindNs / serve registry が参照する単一の真実)。
108
+ export const NAMESPACE_OPS = ["getScheduleList", "cancelSchedule"];
@@ -0,0 +1,251 @@
1
+ // Daemon — 単一常駐 hub の上に JSON-RPC を多重化する中核。
2
+ //
3
+ // 全フレーミング (stdio/UDS/HTTP/WS/gRPC) はここに Connection を addConnection し、
4
+ // 受信行を handleLine に渡すだけ。Daemon は:
5
+ // - registry 解決 + rpc.discover
6
+ // - **メソッド名単位の直列化** (複数クライアントの同一 op 応答入替を防ぐ。
7
+ // WS の FIFO リゾルバは (action:op) 単位グローバルなため)
8
+ // - 購読を **daemon が一元所有** (Map<Connection,Set<topic>>)、hub の状態 push を
9
+ // **1 本だけ**張って購読 Connection へ fan-out (リスナ爆発・IR 副作用を回避)
10
+ // - authState 管理、起動ポリシー (framing 先・connect 再試行・degraded)、graceful shutdown
11
+ //
12
+ // Connection 契約 (framing が実装): { id:string, send(obj):void, close():void }
13
+ // send はフレーミング固有の直列化 + 背圧を担う (溢れたらその接続を切る)。
14
+
15
+ import { handleMessage, makeEvent, RpcError, RPC, KIND } from "./jsonrpc.js";
16
+ import { buildRegistry, buildOpenRpcDoc } from "./registry.js";
17
+ import { TRANSPORT_ERR } from "../transport.js";
18
+
19
+ const TOPICS = ["lockState", "deviceUpdate"];
20
+
21
+ /**
22
+ * handler が投げた素の Error を kind 付き RpcError へ正規化する。判定は transport が付ける
23
+ * **構造化コード (.code = TRANSPORT_ERR.*)** で行う (跨モジュールの脆弱な文字列正規表現を排除)。
24
+ * 該当しなければそのまま返し、errorFromThrow が internal にフォールバックする。
25
+ */
26
+ function classifyError(e) {
27
+ if (e instanceof RpcError) return e;
28
+ if (e?.code === TRANSPORT_ERR.TIMEOUT) return new RpcError(String(e.message), { code: RPC.APP_ERROR, kind: KIND.TIMEOUT });
29
+ if (e?.code === TRANSPORT_ERR.CLOSED) return new RpcError(String(e.message), { code: RPC.APP_ERROR, kind: KIND.CONNECTION_LOST });
30
+ return e;
31
+ }
32
+
33
+ export class Daemon {
34
+ /**
35
+ * @param {{ hub: object, version?: string, debug?: boolean }} args
36
+ * hub は SesameHub3 (テストでは狭いインターフェースの fake)。
37
+ */
38
+ constructor({ hub, version = "0.0.0", debug = false }) {
39
+ if (!hub) throw new Error("hub required");
40
+ this.hub = hub;
41
+ this.version = version;
42
+ this._debug = debug;
43
+ this.authState = "degraded"; // ok | degraded | expired
44
+ this._registry = buildRegistry();
45
+ this._openrpc = buildOpenRpcDoc(this._registry, version);
46
+ /** @type {Map<string, Promise<any>>} メソッド名→直列化チェーン末尾 */
47
+ this._locks = new Map();
48
+ /** @type {Map<object, Set<string>>} Connection→購読 topic */
49
+ this._subs = new Map();
50
+ /** hub 状態 push の単一購読の unsubscribe (張っている時のみ非 null) */
51
+ this._stateUnsub = null;
52
+ this._stopped = false;
53
+ this._shuttingDown = false;
54
+ this._retryTimer = null;
55
+ }
56
+
57
+ _log(...a) { if (this._debug) console.error("[serve]", ...a); }
58
+
59
+ // ---- 起動ポリシー (framing は先に上がっている前提。ここは背景で接続を試みる) ----
60
+ start() {
61
+ // 再接続のたびに購読を張り直す (C1: subscribe frame 再送が無いとイベントが永久に止まる)。
62
+ if (typeof this.hub.onReconnect === "function") {
63
+ this.hub.onReconnect(() => this._reestablishStateSub());
64
+ }
65
+ this._connectLoop();
66
+ }
67
+
68
+ async _connectLoop() {
69
+ let delay = 1000;
70
+ while (!this._stopped) {
71
+ try {
72
+ await this.hub.connect();
73
+ this.authState = "ok";
74
+ this._log("cloud connected");
75
+ this._ensureStateSub();
76
+ return;
77
+ } catch (e) {
78
+ // 認証状態は error 文字列の正規表現ではなく、トークンの有無で決定的に分類する。
79
+ // トークンが無い (未ログイン) → expired (= not_authenticated を即返す。"sesame login" 案内)
80
+ // トークンはある → degraded (ネットワーク不通等。背景で再試行して復帰を拾う)
81
+ this.authState = this._hasStoredTokens() ? "degraded" : "expired";
82
+ // 接続失敗は --debug でなくても見えるように (未ログイン等を初学者が気付けるよう)。
83
+ console.error(`[serve] cloud connect failed (${this.authState}): ${String(e?.message || e).slice(0, 160)}`);
84
+ await this._sleep(delay);
85
+ delay = Math.min(delay * 2, 30000);
86
+ }
87
+ }
88
+ }
89
+
90
+ _hasStoredTokens() {
91
+ try {
92
+ const t = this.hub.tokenStore?.load?.();
93
+ return !!(t && (t.refreshToken || t.idToken));
94
+ } catch { return false; }
95
+ }
96
+
97
+ /** キャンセル可能な sleep。shutdown 時に即 resolve してループを抜けさせる。 */
98
+ _sleep(ms) {
99
+ return new Promise((resolve) => {
100
+ this._retryResolve = resolve;
101
+ this._retryTimer = setTimeout(() => { this._retryResolve = null; resolve(); }, ms);
102
+ });
103
+ }
104
+
105
+ // ---- Connection 管理 ----
106
+ addConnection(conn) { this._subs.set(conn, new Set()); }
107
+
108
+ removeConnection(conn) {
109
+ this._subs.delete(conn);
110
+ this._maybeTeardownStateSub();
111
+ }
112
+
113
+ // ---- 受信処理 ----
114
+ /** 1 メッセージを処理して応答オブジェクト (通知なら null) を返す。push はしない (HTTP POST 用)。 */
115
+ dispatchMessage(conn, raw) {
116
+ return handleMessage(raw, (method, params) => this.invoke(method, params, conn));
117
+ }
118
+
119
+ /** framing から: 1 行を処理し、応答があれば conn.send で push する。throw しない。 */
120
+ async handleLine(conn, raw) {
121
+ const res = await this.dispatchMessage(conn, raw);
122
+ if (res) {
123
+ try { conn.send(res); } catch (e) { this._log("send failed:", e?.message); }
124
+ }
125
+ }
126
+
127
+ /** メソッド実行。registry 解決 + rpc.discover + メソッド名単位の直列化。常に Promise を返す。 */
128
+ async invoke(method, params, conn) {
129
+ if (method === "rpc.discover") return this._openrpc;
130
+ if (method.startsWith("rpc.")) {
131
+ throw new RpcError(`Method not found: ${method}`, { code: RPC.METHOD_NOT_FOUND, kind: KIND.NOT_IMPLEMENTED });
132
+ }
133
+ const entry = this._registry.get(method);
134
+ if (!entry) {
135
+ throw new RpcError(`Method not found: ${method}`, { code: RPC.METHOD_NOT_FOUND, kind: KIND.NOT_IMPLEMENTED });
136
+ }
137
+ const p = params == null ? {} : params;
138
+ if (typeof p !== "object" || Array.isArray(p)) {
139
+ throw new RpcError("params must be an object", { code: RPC.INVALID_PARAMS, kind: KIND.BAD_PARAMS });
140
+ }
141
+ const run = async () => {
142
+ try {
143
+ return await entry.handler({ hub: this.hub, params: p, conn, daemon: this });
144
+ } catch (e) {
145
+ throw classifyError(e); // transport 由来の timeout/切断を kind 付きに
146
+ }
147
+ };
148
+ return this._serialize(method, run);
149
+ }
150
+
151
+ /**
152
+ * 同名メソッドを直列化する。これは応答入替の主防御ではなく (request() ベースの op は
153
+ * transport の (action:op) FIFO が保証する) **listDevices 等 onMessage 先着 resolve で
154
+ * 解決する op** を、複数クライアント同時呼び出しから守る防御。同名 op を 1 並行に絞る。
155
+ */
156
+ _serialize(key, run) {
157
+ const prev = this._locks.get(key) || Promise.resolve();
158
+ const p = prev.then(run, run); // 前段の成否に関わらず次を実行
159
+ // チェーンが無限に伸びないよう、解決したら掃除 (自分が末尾なら削除)
160
+ const tail = p.catch(() => {});
161
+ this._locks.set(key, tail);
162
+ tail.then(() => { if (this._locks.get(key) === tail) this._locks.delete(key); });
163
+ return p;
164
+ }
165
+
166
+ // ---- 購読 (daemon 一元所有) ----
167
+ subscribe(conn, topics) {
168
+ const set = this._subs.get(conn);
169
+ if (!set) throw new RpcError("connection not registered", { kind: KIND.INTERNAL });
170
+ for (const t of topics) set.add(t);
171
+ this._ensureStateSub();
172
+ return { subscribed: [...set] };
173
+ }
174
+
175
+ unsubscribe(conn, topics) {
176
+ const set = this._subs.get(conn);
177
+ if (!set) return { subscribed: [] };
178
+ for (const t of topics) set.delete(t);
179
+ this._maybeTeardownStateSub();
180
+ return { subscribed: [...set] };
181
+ }
182
+
183
+ _anySubscribers() {
184
+ for (const set of this._subs.values()) if (set.size) return true;
185
+ return false;
186
+ }
187
+
188
+ /** 状態 push の単一購読を (必要なら) 張る。hub 未接続なら接続時に再試行。 */
189
+ _ensureStateSub() {
190
+ if (this._stateUnsub || !this.hub.connected) return;
191
+ try {
192
+ // config 上の devices を items に (サーバへ subscribe frame を送るため)。
193
+ const devices = (this.hub.config && this.hub.config.devices) || {};
194
+ const items = Object.values(devices).map((d) => ({ deviceUUID: d.deviceUUID, deviceModel: d.deviceModel }));
195
+ this._stateUnsub = this.hub.onDeviceUpdate(items, (msg) => this._fanout(msg));
196
+ this._log("state subscription established");
197
+ } catch (e) {
198
+ this._log("state sub failed:", e?.message);
199
+ }
200
+ }
201
+
202
+ _reestablishStateSub() {
203
+ // 再接続後に購読者が居れば張り直す (サーバ側 subscribe frame は再送が要るため)。
204
+ // 旧 unsub を必ず呼んでから張り直す。呼ばないと transport の subscribers に古い fn が
205
+ // 残り、新 fn と二重配信になる (subscribers は再接続を跨いで保持されるため)。
206
+ if (this._stateUnsub) { try { this._stateUnsub(); } catch { /* ignore */ } this._stateUnsub = null; }
207
+ if (this._anySubscribers()) this._ensureStateSub();
208
+ }
209
+
210
+ _maybeTeardownStateSub() {
211
+ if (this._stateUnsub && !this._anySubscribers()) {
212
+ try { this._stateUnsub(); } catch { /* ignore */ }
213
+ this._stateUnsub = null;
214
+ this._log("state subscription torn down");
215
+ }
216
+ }
217
+
218
+ /**
219
+ * 状態 push を購読 Connection へ配信する。
220
+ * 注: lockState と deviceUpdate は現状どちらも biz3 の pubDeviceStateChange を源とする
221
+ * (同一ストリームの別ラベル)。両方購読している接続には **1 回だけ** 配信する
222
+ * (最初に購読している topic のラベルで) — 同一イベントの二重配信を避ける。
223
+ */
224
+ _fanout(msg) {
225
+ for (const [conn, set] of this._subs) {
226
+ const topic = TOPICS.find((t) => set.has(t));
227
+ if (topic) {
228
+ try { conn.send(makeEvent(topic, msg)); } catch { /* framing 背圧で切断済み等 */ }
229
+ }
230
+ }
231
+ }
232
+
233
+ // ---- shutdown ----
234
+ /** 冪等。受付停止 → hub.close()(=_pendingCleanups 実行) → 解決。 */
235
+ async shutdown() {
236
+ if (this._shuttingDown) return;
237
+ this._shuttingDown = true;
238
+ this._stopped = true;
239
+ if (this._retryTimer) clearTimeout(this._retryTimer);
240
+ if (this._retryResolve) { this._retryResolve(); this._retryResolve = null; } // connectLoop の sleep を即解除
241
+ if (this._stateUnsub) { try { this._stateUnsub(); } catch { /* ignore */ } this._stateUnsub = null; }
242
+ try { await this.hub.close(); } catch (e) { this._log("hub.close error:", e?.message); }
243
+ }
244
+
245
+ /** 購読可能な topic 一覧 (framing が事前検証に使う)。 */
246
+ get topics() { return TOPICS; }
247
+
248
+ /** テスト/イントロスペクション用。 */
249
+ get registry() { return this._registry; }
250
+ get openrpc() { return this._openrpc; }
251
+ }
@@ -0,0 +1,145 @@
1
+ // gRPC フレーミング: **型付き**。op ごとに型付き Request message + 型付き service メソッドを
2
+ // 生成 (scripts/gen-grpc-proto.mjs)。利用者は `LockUnlock({name})` のように型付きで呼べる。
3
+ // - request: scalar/string配列は protobuf 型、object/動的な葉は JSON 文字列 field (glue が parse)
4
+ // - response: 動的なので JsonRpc{json} で運ぶ (1 回 JSON.parse)
5
+ // - Subscribe: topics 購読 → Event{topic, json} stream
6
+ // - Invoke: 後方互換の汎用 JSON-RPC 経路
7
+ // loopback token を metadata (authorization: Bearer) か SubReq.token で要求。
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import grpc from "@grpc/grpc-js";
11
+ import protoLoader from "@grpc/proto-loader";
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, resolve } from "node:path";
14
+ import { tokenMatches } from "./token.js";
15
+ import { RpcError } from "../jsonrpc.js";
16
+
17
+ const HERE = dirname(fileURLToPath(import.meta.url));
18
+ const PROTO_PATH = resolve(HERE, "..", "sesame.proto");
19
+ const MAP_PATH = resolve(HERE, "..", "grpc-methods.generated.json");
20
+
21
+ /** server streaming で非 OK status を返す。grpc-js (1.14) では `call.destroy()` は status を
22
+ * クライアントに伝えず黙ってハングするため、`emit("error", {code, details})` で返す (実測で確認)。 */
23
+ function endStreamWithError(call, code, message) {
24
+ call.emit("error", { code, details: message });
25
+ }
26
+
27
+ /** RpcError.kind → gRPC status (gRPC の作法に沿って status でエラーを返す)。 */
28
+ function grpcStatusFor(kind) {
29
+ switch (kind) {
30
+ case "not_authenticated": return grpc.status.UNAUTHENTICATED;
31
+ case "bad_params": return grpc.status.INVALID_ARGUMENT;
32
+ case "not_implemented": return grpc.status.UNIMPLEMENTED;
33
+ case "connection_lost":
34
+ case "timeout": return grpc.status.UNAVAILABLE;
35
+ default: return grpc.status.INTERNAL;
36
+ }
37
+ }
38
+
39
+ function metaToken(call) {
40
+ const md = call.metadata?.get?.("authorization");
41
+ const raw = md && md[0] ? String(md[0]) : "";
42
+ return /^Bearer\s+(.+)$/i.exec(raw)?.[1] || "";
43
+ }
44
+
45
+ /**
46
+ * @param {import("../daemon.js").Daemon} daemon
47
+ * @param {{ bind?:string, port:number, token:string }} opts
48
+ * @returns {Promise<{ port:number, stop:()=>Promise<void> }>}
49
+ */
50
+ export async function startGrpcFraming(daemon, { bind = "127.0.0.1", port, token }) {
51
+ const pkgDef = protoLoader.loadSync(PROTO_PATH, { keepCase: true, longs: String, defaults: true });
52
+ const proto = grpc.loadPackageDefinition(pkgDef).sesame;
53
+ const methodMap = JSON.parse(readFileSync(MAP_PATH, "utf8")); // Pascal → {method, jsonFields}
54
+ const server = new grpc.Server();
55
+
56
+ const impl = {};
57
+
58
+ // 型付き unary メソッドを一括登録 (handler は generic に daemon.invoke へ委譲)。
59
+ for (const [pascal, { method, jsonFields }] of Object.entries(methodMap)) {
60
+ impl[pascal] = async (call, callback) => {
61
+ if (!tokenMatches(metaToken(call), token)) return callback({ code: grpc.status.UNAUTHENTICATED, message: "unauthorized" });
62
+ const params = { ...call.request };
63
+ for (const f of jsonFields) {
64
+ if (params[f] === undefined || params[f] === "") { delete params[f]; continue; } // 空=未指定
65
+ try { params[f] = JSON.parse(params[f]); } catch { return callback({ code: grpc.status.INVALID_ARGUMENT, message: `field "${f}" must be JSON` }); }
66
+ }
67
+ const conn = { id: "grpc", ephemeral: true, send() {}, close() {} };
68
+ daemon.addConnection(conn);
69
+ try {
70
+ const result = await daemon.invoke(method, params, conn);
71
+ callback(null, { json: JSON.stringify(result ?? null) });
72
+ } catch (e) {
73
+ const kind = e instanceof RpcError ? e.kind : "internal";
74
+ const md = new grpc.Metadata(); if (kind) md.set("kind", kind);
75
+ callback({ code: grpcStatusFor(kind), message: e?.message || "error", metadata: md });
76
+ } finally {
77
+ daemon.removeConnection(conn);
78
+ }
79
+ };
80
+ }
81
+
82
+ // 後方互換: 任意の JSON-RPC を文字列で運ぶ。
83
+ impl.Invoke = async (call, callback) => {
84
+ if (!tokenMatches(metaToken(call), token)) return callback({ code: grpc.status.UNAUTHENTICATED, message: "unauthorized" });
85
+ const conn = { id: "grpc", ephemeral: true, send() {}, close() {} };
86
+ daemon.addConnection(conn);
87
+ let out;
88
+ try { out = await daemon.dispatchMessage(conn, call.request.json || ""); }
89
+ finally { daemon.removeConnection(conn); }
90
+ callback(null, { json: out === null ? "" : JSON.stringify(out) });
91
+ };
92
+
93
+ // イベント購読ストリーム。
94
+ impl.Subscribe = (call) => {
95
+ const provided = call.request.token || metaToken(call);
96
+ if (!tokenMatches(provided, token)) { endStreamWithError(call, grpc.status.UNAUTHENTICATED, "unauthorized"); return; }
97
+ let buffered = 0;
98
+ const MAX_BUFFERED = 4 * 1024 * 1024;
99
+ call.on("drain", () => { buffered = 0; });
100
+ const conn = {
101
+ id: "grpc-sub",
102
+ send: (obj) => {
103
+ const topic = String(obj.method || "").replace(/^event\./, "");
104
+ const json = JSON.stringify(obj.params ?? null);
105
+ try { const ok = call.write({ topic, json }); if (!ok) { buffered += json.length; if (buffered > MAX_BUFFERED) conn.close(); } }
106
+ catch { /* closed */ }
107
+ },
108
+ close: () => { try { call.end(); } catch { /* ignore */ } },
109
+ };
110
+ daemon.addConnection(conn);
111
+ const topics = (call.request.topics || []).filter(Boolean);
112
+ // 不正 topic は黙殺せず INVALID_ARGUMENT でストリームを閉じる (WS/SSE と同じく拒否。
113
+ // 黙ってハングするストリームを返すと『gRPC だけイベントが来ない』のデバッグが不能になる)。
114
+ // daemon.subscribe 自体は検証しないので、ここで daemon.topics に対して明示検証する。
115
+ const bad = topics.filter((t) => !daemon.topics.includes(t));
116
+ if (bad.length) {
117
+ daemon.removeConnection(conn);
118
+ endStreamWithError(call, grpc.status.INVALID_ARGUMENT, `unknown topic(s): ${bad.join(",")}`);
119
+ return;
120
+ }
121
+ if (topics.length) daemon.subscribe(conn, topics);
122
+ call.on("cancelled", () => daemon.removeConnection(conn));
123
+ call.on("close", () => daemon.removeConnection(conn));
124
+ };
125
+
126
+ server.addService(proto.Sesame.service, impl);
127
+
128
+ const boundPort = await new Promise((resolve2, reject) => {
129
+ server.bindAsync(`${bind}:${port}`, grpc.ServerCredentials.createInsecure(), (err, p) => {
130
+ if (err) reject(err); else resolve2(p);
131
+ });
132
+ });
133
+ return {
134
+ port: boundPort,
135
+ // tryShutdown は Subscribe ストリームが開いている限り待ち続けるため、まず graceful を試み、
136
+ // 1s で畳めなければ forceShutdown で全 call を即キャンセルしてハングを断つ。
137
+ stop: () => new Promise((resolve2) => {
138
+ let done = false;
139
+ const finish = () => { if (!done) { done = true; resolve2(); } };
140
+ const t = setTimeout(() => { try { server.forceShutdown(); } catch { /* ignore */ } finish(); }, 1000);
141
+ t.unref?.();
142
+ server.tryShutdown(() => { clearTimeout(t); finish(); });
143
+ }),
144
+ };
145
+ }
@@ -0,0 +1,144 @@
1
+ // HTTP フレーミング: 全言語/ブラウザから叩ける。
2
+ // POST /rpc → 1 件の JSON-RPC を処理し応答を返す (request/response 専用、購読は持続しない)
3
+ // GET /events → SSE。?topics=lockState,deviceUpdate を購読し event を流す (購読チャネル)
4
+ // 全エンドポイントで loopback token (Authorization: Bearer / ?token=) 必須。
5
+
6
+ import http from "node:http";
7
+ import { makeError, RPC, KIND } from "../jsonrpc.js";
8
+ import { tokenMatches, extractToken } from "./token.js";
9
+
10
+ const MAX_BODY = 1_000_000; // 1MB 上限 (過大 body 拒否)
11
+
12
+ /**
13
+ * @param {import("../daemon.js").Daemon} daemon
14
+ * @param {{ bind?:string, port:number, token:string }} opts
15
+ * @returns {Promise<{ url:string, stop:()=>Promise<void> }>}
16
+ */
17
+ export async function startHttpFraming(daemon, { bind = "127.0.0.1", port, token }) {
18
+ const server = http.createServer((req, res) => {
19
+ const url = new URL(req.url, "http://localhost");
20
+
21
+ // GET / は token 不要の人間向け案内 (ブラウザで開いた初学者が迷子にならないように)。
22
+ if (req.method === "GET" && url.pathname === "/") {
23
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
24
+ res.end([
25
+ "sesame serve is running (JSON-RPC 2.0).",
26
+ "",
27
+ "Endpoints (Authorization: Bearer <token> required):",
28
+ " POST /rpc - send one JSON-RPC request, get one response",
29
+ " GET /events - SSE event stream (?topics=lockState,deviceUpdate)",
30
+ "",
31
+ "The token was printed to stderr when the daemon started.",
32
+ "List all methods:",
33
+ ` curl -s -H "Authorization: Bearer <token>" \\`,
34
+ ` -d '{"jsonrpc":"2.0","id":1,"method":"rpc.discover"}' http://${bind}:${port}/rpc`,
35
+ "",
36
+ "Watch events (SSE):",
37
+ ` curl -N -H "Authorization: Bearer <token>" "http://${bind}:${port}/events?topics=lockState"`,
38
+ "",
39
+ ].join("\n"));
40
+ return;
41
+ }
42
+
43
+ if (!tokenMatches(extractToken(req), token)) {
44
+ res.writeHead(401, {
45
+ "content-type": "application/json",
46
+ "www-authenticate": 'Bearer realm="sesame"', // クライアントに token 必須を明示
47
+ });
48
+ res.end(JSON.stringify({ error: "unauthorized", hint: "Authorization: Bearer <token> (token は serve 起動時に stderr へ表示)" }));
49
+ return;
50
+ }
51
+
52
+ if (req.method === "POST" && url.pathname === "/rpc") {
53
+ const chunks = [];
54
+ let size = 0;
55
+ let discarded = 0;
56
+ let aborted = false;
57
+ // req の error を握る (未処理だとグローバル uncaughtException 経由で daemon 全停止しうる)。
58
+ req.on("error", () => { aborted = true; });
59
+ req.on("data", (c) => {
60
+ if (aborted) {
61
+ // 413 送出後: 残り body を**破棄しつつ受け切る** (バッファには積まない=O(1) メモリ)。
62
+ // ここで socket を destroy するとクライアントの送信中 write が RST で失敗し、送った 413 を
63
+ // 受け取れず "fetch failed" になる。受け切れば応答が確実に届く。ただし悪質に巨大な
64
+ // アップロードは上限で打ち切る (DoS 防止)。
65
+ discarded += c.length;
66
+ if (discarded > MAX_BODY) { try { req.destroy(); } catch { /* ignore */ } }
67
+ return;
68
+ }
69
+ size += c.length;
70
+ if (size > MAX_BODY) { // 過大 body は 413 で明示拒否
71
+ aborted = true;
72
+ res.writeHead(413, { "content-type": "application/json", connection: "close" });
73
+ res.end(JSON.stringify({ error: "payload too large" }));
74
+ return;
75
+ }
76
+ chunks.push(c);
77
+ });
78
+ req.on("end", async () => {
79
+ if (aborted) return;
80
+ const body = Buffer.concat(chunks).toString("utf8"); // チャンク跨ぎ UTF-8 を正しく結合
81
+ // POST は短命接続 (購読は持続しない)。ephemeral=true で events.* を弾く。
82
+ const conn = { id: "http-rpc", ephemeral: true, send() {}, close() {} };
83
+ daemon.addConnection(conn);
84
+ let out;
85
+ try {
86
+ out = await daemon.dispatchMessage(conn, body);
87
+ } catch {
88
+ out = makeError(null, RPC.INTERNAL_ERROR, "internal", KIND.INTERNAL);
89
+ }
90
+ daemon.removeConnection(conn);
91
+ if (out === null) { res.writeHead(204); res.end(); return; } // 通知
92
+ res.writeHead(200, { "content-type": "application/json" });
93
+ res.end(JSON.stringify(out));
94
+ });
95
+ return;
96
+ }
97
+
98
+ if (req.method === "GET" && url.pathname === "/events") {
99
+ // topic を事前検証: 指定があるのに全部不正なら 400 (黙って無用なストリームを返さない)。
100
+ const reqTopics = (url.searchParams.get("topics") || "").split(",").map((s) => s.trim()).filter(Boolean);
101
+ const validTopics = reqTopics.filter((t) => daemon.topics.includes(t));
102
+ if (reqTopics.length && validTopics.length === 0) {
103
+ res.writeHead(400, { "content-type": "application/json" });
104
+ res.end(JSON.stringify({ error: `unknown topic(s): ${reqTopics.join(",")}`, valid: daemon.topics }));
105
+ return;
106
+ }
107
+ res.writeHead(200, {
108
+ "content-type": "text/event-stream",
109
+ "cache-control": "no-cache",
110
+ connection: "keep-alive",
111
+ });
112
+ res.write(": ok\n\n"); // 初期コメントでストリーム確立
113
+ const conn = {
114
+ id: "http-sse",
115
+ send: (obj) => { try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch { /* closed */ } },
116
+ close: () => { try { res.end(); } catch { /* ignore */ } },
117
+ };
118
+ daemon.addConnection(conn);
119
+ try { if (validTopics.length) daemon.subscribe(conn, validTopics); } catch { /* ignore */ }
120
+ // ハートビート: 中継 proxy のアイドル切断を防ぎ、死んだ接続を検知する。
121
+ const heartbeat = setInterval(() => { try { res.write(": ping\n\n"); } catch { /* closed */ } }, 25000);
122
+ req.on("close", () => { clearInterval(heartbeat); daemon.removeConnection(conn); });
123
+ return;
124
+ }
125
+
126
+ res.writeHead(404, { "content-type": "application/json" });
127
+ res.end(JSON.stringify({ error: "not found" }));
128
+ });
129
+
130
+ await new Promise((resolve, reject) => {
131
+ server.once("error", reject);
132
+ server.listen(port, bind, () => resolve());
133
+ });
134
+ const addr = server.address();
135
+ return {
136
+ url: `http://${bind}:${addr.port}`,
137
+ // SSE 購読者が keep-alive で居座ると server.close は idle 接続を閉じずハングするため、
138
+ // 全接続を能動 destroy してから close (closeAllConnections は Node 18.2+)。
139
+ stop: () => new Promise((resolve) => {
140
+ try { server.closeAllConnections?.(); } catch { /* ignore */ }
141
+ server.close(() => resolve());
142
+ }),
143
+ };
144
+ }