Changes
19 changed files (+623/-59)
-
-
@@ -8,6 +8,9 @@ "@bufbuild/buf": "^1.47.2","wireit": "^0.14.9", }, }, "packages/backend": { "name": "@yamori/backend", }, "packages/idb_backend": { "name": "@yamori/idb_backend", "dependencies": {
-
@@ -36,6 +39,7 @@ },"packages/pwa": { "name": "@yamori/pwa", "dependencies": { "@yamori/backend": "packages/backend", "@yamori/idb_backend": "packages/idb_backend", "@yamori/react_ui": "packages/react_ui", "react": "^19.0.0",
-
@@ -481,11 +485,13 @@ "@vitest/spy": ["@vitest/spy@2.0.5", "", { "dependencies": { "tinyspy": "^3.0.0" } }, "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA=="],"@vitest/utils": ["@vitest/utils@2.1.8", "", { "dependencies": { "@vitest/pretty-format": "2.1.8", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA=="], "@yamori/backend": ["@yamori/backend@workspace:packages/backend", {}], "@yamori/idb_backend": ["@yamori/idb_backend@workspace:packages/idb_backend", { "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@date-fns/tz": "^1.2.0", "@yamori/proto": "packages/proto", "date-fns": "^4.1.0", "idb": "^8.0.0" }, "devDependencies": { "@types/bun": "^1.1.14", "fake-indexeddb": "^6.0.0", "typescript": "^5.7.2" } }], "@yamori/proto": ["@yamori/proto@workspace:packages/proto", { "devDependencies": { "@bufbuild/protobuf": "^2.2.2", "@bufbuild/protoc-gen-es": "^2.2.2" }, "peerDependencies": { "@bufbuild/protobuf": "^2.2.0" } }], "@yamori/pwa": ["@yamori/pwa@workspace:packages/pwa", { "dependencies": { "@yamori/idb_backend": "packages/idb_backend", "@yamori/react_ui": "packages/react_ui", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "rollup-plugin-license": "^3.5.3", "typescript": "^5.7.2", "vite": "^6.0.2", "zod": "^3.24.1" } }], "@yamori/pwa": ["@yamori/pwa@workspace:packages/pwa", { "dependencies": { "@yamori/backend": "packages/backend", "@yamori/idb_backend": "packages/idb_backend", "@yamori/react_ui": "packages/react_ui", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "rollup-plugin-license": "^3.5.3", "typescript": "^5.7.2", "vite": "^6.0.2", "zod": "^3.24.1" } }], "@yamori/react_ui": ["@yamori/react_ui@workspace:packages/react_ui", { "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@date-fns/tz": "^1.2.0", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/themes": "^3.1.6", "@tanstack/react-query": "^5.62.8", "@yamori/idb_backend": "packages/idb_backend", "@yamori/proto": "packages/proto", "date-fns": "^4.1.0", "react-hook-form": "^7.54.2", "urlpattern-polyfill": "^10.0.0" }, "devDependencies": { "@storybook/addon-essentials": "^8.4.7", "@storybook/addon-interactions": "^8.4.7", "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/test": "^8.4.7", "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "react": "19.x.x", "react-dom": "^19.0.0", "typescript": "^5.7.2", "vite": "^6.0.2" }, "peerDependencies": { "react": "19.x.x", "react-dom": "19.x.x" } }],
-
-
-
@@ -0,0 +1,73 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //! Zig のビルドスクリプト。 const std = @import("std"); const ProtoGenStep = @import("gremlin").ProtoGenStep; pub fn build(b: *std.Build) void { const system_target = b.standardTargetOptions(.{}); const system_optimize = b.standardOptimizeOption(.{}); const proto_gen = ProtoGenStep.create(b, .{ .proto_sources = b.path("../proto/"), .target = b.path("src/proto"), }); // === WASM const wasm_target = b.resolveTargetQuery(.{ .os_tag = .freestanding, .cpu_arch = .wasm32, }); // ファイルサイズが大きくなってきたら ReleaseSmall に置き換えるが、 // アプリケーションであるため初回ダウンロードよりも継続的な実行速度 // を重視したビルド設定になっている。 const wasm_optimize: std.builtin.OptimizeMode = .ReleaseSmall; const wasm = b.addExecutable(.{ .name = "yamori_backend", .root_source_file = b.path("src/wasm.zig"), .target = wasm_target, .optimize = wasm_optimize, }); wasm.root_module.addImport( "gremlin", b.dependency("gremlin", .{ .target = wasm_target, .optimize = wasm_optimize, }).module("gremlin"), ); // WASM モジュールを出力するために必要。 wasm.rdynamic = true; wasm.entry = .disabled; wasm.step.dependOn(&proto_gen.step); b.installArtifact(wasm); // === サーバ const server = b.addExecutable(.{ .name = "yamori", .root_source_file = b.path("src/server.zig"), .target = system_target, .optimize = system_optimize, }); server.root_module.addImport( "gremlin", b.dependency("gremlin", .{ .target = system_target, .optimize = system_optimize, }).module("gremlin"), ); server.step.dependOn(&proto_gen.step); b.installArtifact(server); }
-
-
-
@@ -0,0 +1,58 @@{ "name": "@yamori/backend", "private": true, "type": "module", "main": "lib/worker.js", "scripts": { "check": "wireit", "make": "wireit", "clean": "rm -rf zig-out .zig-cache lib" }, "wireit": { "make:wasm": { "command": "zig build", "files": ["src/**/*.zig", "build.zig", "build.zig.zon"], "clean": false, "output": ["zig-out/**"] }, "make:js": { "command": "tsc -p tsconfig.build.jsonc", "files": ["src/**/*.ts", "tsconfig.build.jsonc"], "clean": "if-file-deleted", "output": ["lib/**"], "dependencies": ["tsconfig"], "packageLocks": ["bun.lockb"] }, "make": { "dependencies": ["make:wasm", "make:js"] }, "check": { "command": "tsc", "files": ["src/*.ts", "package.json"], "output": [], "dependencies": ["tsconfig"], "packageLocks": ["bun.lockb"] }, "tsconfig": { "files": ["tsconfig.json", "../../tsconfig.jsonc"] }, "js": { "files": ["lib/**/*.js"], "dependencies": [ { "script": "make:js", "cascade": false } ] }, "wasm": { "files": ["zig-out/bin/*.wasm"], "dependencies": [ { "script": "make:wasm", "cascade": false } ] } } }
-
-
-
@@ -0,0 +1,2 @@SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
-
@@ -0,0 +1,56 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only const std = @import("std"); const ping_request = @import("./proto/yamori/meta/v1/ping_request.proto.zig"); const ping_response = @import("./proto/yamori/meta/v1/ping_response.proto.zig"); const Core = @This(); allocator: std.mem.Allocator, pub const HandleError = error{ UnknownService, UnknownMethod, DecodeError, EncodeError, }; pub fn init(allocator: std.mem.Allocator) Core { var core: Core = undefined; core.populate(allocator); return core; } pub fn populate(core: *Core, allocator: std.mem.Allocator) void { core.allocator = allocator; } pub fn handle( self: *const Core, service: []const u8, method: []const u8, bytes: []const u8, ) HandleError![]const u8 { var arena = std.heap.ArenaAllocator.init(self.allocator); defer arena.deinit(); const allocator = arena.allocator(); if (std.mem.eql(u8, service, "yamori.meta.v1")) { if (std.mem.eql(u8, method, "Ping")) { _ = ping_request.PingRequestReader.init(allocator, bytes) catch { return HandleError.DecodeError; }; return ping_response.PingResponse.encode(&.{}, self.allocator) catch { return HandleError.EncodeError; }; } return HandleError.UnknownMethod; } return HandleError.UnknownService; }
-
-
-
@@ -0,0 +1,19 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only const std = @import("std"); const Core = @import("Core.zig"); pub fn main() !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const core = Core.init(allocator); const bytes = try core.handle("yamori.meta.v1", "Ping", &.{}); std.debug.print("{d}:{d}\n", .{ @intFromPtr(bytes.ptr), bytes.len }); }
-
-
-
@@ -0,0 +1,97 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only const std = @import("std"); const Core = @import("Core.zig"); export fn init_core() *Core { // TODO: Handle OOM var core = std.heap.wasm_allocator.create(Core) catch unreachable; core.populate(std.heap.wasm_allocator); return core; } export fn deinit_core(core: *Core) void { std.heap.wasm_allocator.destroy(core); } export fn allocate_bytes(len: usize) [*]u8 { const slice = std.heap.wasm_allocator.alloc(u8, len) catch unreachable; return slice.ptr; } export fn free_bytes(ptr: [*]u8, len: usize) void { std.heap.wasm_allocator.free(ptr[0..len]); } const Response = extern struct { ptr: [*]const u8, len: usize, pub fn populate(self: *Response, slice: []const u8) void { const dst = std.heap.wasm_allocator.alloc(u8, slice.len) catch unreachable; @memcpy(dst, slice); self.ptr = dst.ptr; self.len = dst.len; } pub fn deinit(self: *Response) void { std.heap.wasm_allocator.free(self.ptr[0..self.len]); self.* = undefined; } }; export fn create_response() *Response { const response = std.heap.wasm_allocator.create(Response) catch unreachable; return response; } export fn get_response_ptr(self: *const Response) [*]const u8 { return self.ptr; } export fn get_response_len(self: *const Response) usize { return self.len; } export fn destroy_response(response: *Response) void { response.deinit(); std.heap.wasm_allocator.destroy(response); } const HandleResult = enum(u8) { ok = 0, unknown_service = 1, unknown_method = 2, decode_error = 3, encode_error = 4, }; export fn handle( core: *const Core, service_ptr: [*]const u8, service_len: usize, method_ptr: [*]const u8, method_len: usize, bytes_ptr: [*]const u8, bytes_len: usize, resp_ptr: *Response, ) HandleResult { const resp = core.handle( service_ptr[0..service_len], method_ptr[0..method_len], bytes_ptr[0..bytes_len], ) catch |err| return switch (err) { Core.HandleError.UnknownMethod => .unknown_method, Core.HandleError.UnknownService => .unknown_service, Core.HandleError.DecodeError => .decode_error, Core.HandleError.EncodeError => .encode_error, }; resp_ptr.populate(resp); return .ok; }
-
-
-
@@ -0,0 +1,197 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only const wasmURL = new URL("../zig-out/bin/yamori_backend.wasm", import.meta.url); export interface IncomingMessage { type: "request"; id?: unknown; service: string; method: string; data: Uint8Array; } function isIncomingMessage(x: unknown): x is IncomingMessage { if (!x || typeof x !== "object") { return false; } if (!("type" in x && x.type === "request")) { return false; } return true; } export type OutgoingMessage = | { type: "response"; id?: unknown; service: string; method: string; data: Uint8Array; } | { type: "error"; id?: unknown; error: string; } | { type: "ready"; }; type CorePtr = number & { [type: symbol]: "Core" }; type ResponsePtr = number & { [type: symbol]: "Response" }; type BytesPtr = number & { [type: symbol]: "Bytes" }; type Uint8Slice = { ptr: BytesPtr; len: number }; interface WasmExports { memory: InstanceType<WebAssembly.Memory>; init_core(): CorePtr; deinit_core(ptr: CorePtr): void; allocate_bytes(len: number): BytesPtr; free_bytes(ptr: BytesPtr, len: number): void; create_response(): ResponsePtr; get_response_ptr(ptr: ResponsePtr): number; get_response_len(ptr: ResponsePtr): number; destroy_response(ptr: ResponsePtr): void; handle( core: CorePtr, service_ptr: BytesPtr, service_len: number, method_ptr: BytesPtr, method_len: number, bytes_ptr: BytesPtr, bytes_len: number, resp_ptr: ResponsePtr, ): number; } class Backend { #utf8enc: TextEncoder = new TextEncoder(); #exports: WasmExports; #core: CorePtr; constructor(instance: WebAssembly.Instance) { this.#exports = instance.exports as unknown as WasmExports; this.#core = this.#exports.init_core(); } static async init(): Promise<Backend> { const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmURL)); return new Backend(instance); } #allocateBytes(len: number): Uint8Slice { const ptr = this.#exports.allocate_bytes(len); return { ptr, len }; } #send(data: Uint8Array): Uint8Slice { const slice = this.#allocateBytes(data.length); if (slice.len === 0) { return slice; } const view = new Uint8Array(this.#exports.memory.buffer, slice.ptr, slice.len); view.set(data); return slice; } #sendUtf8(string: string): Uint8Slice { const bytes = this.#utf8enc.encode(string); return this.#send(bytes); } handle(service: string, method: string, data: Uint8Array): Uint8Array { const serviceSlice = this.#sendUtf8(service); const methodSlice = this.#sendUtf8(method); const dataSlice = this.#send(data); const resp = this.#exports.create_response(); const result = this.#exports.handle( this.#core, serviceSlice.ptr, serviceSlice.len, methodSlice.ptr, methodSlice.len, dataSlice.ptr, dataSlice.len, resp, ); try { if (result !== 0) { throw new Error("Handler returned non-zero result"); } const ptr = this.#exports.get_response_ptr(resp); const len = this.#exports.get_response_len(resp); if (len === 0) { return new Uint8Array([]); } const cloned = this.#exports.memory.buffer.slice(ptr, len); return new Uint8Array(cloned); } finally { this.#exports.destroy_response(resp); this.#exports.free_bytes(dataSlice.ptr, dataSlice.len); this.#exports.free_bytes(methodSlice.ptr, methodSlice.len); this.#exports.free_bytes(serviceSlice.ptr, serviceSlice.len); } } } Backend.init().then((backend) => { addEventListener("message", async (event) => { if (!isIncomingMessage(event.data)) { console.warn("Received invalid message: ", event); return; } const { id, service, method, data } = event.data; try { const resp = backend.handle(service, method, data); self.postMessage( { type: "response", id, service, method, data: resp, } satisfies OutgoingMessage, { transfer: [resp.buffer], }, ); } catch (error) { console.error(error); self.postMessage({ type: "error", id, error: String(error), } satisfies OutgoingMessage); } }); self.postMessage({ type: "ready", } satisfies OutgoingMessage); });
-
-
-
@@ -0,0 +1,14 @@// ビルドする際の設定。 // // SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only { "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false, "declaration": true, "incremental": true, "outDir": "./lib" }, "include": ["src/**/*.ts"] }
-
-
-
@@ -0,0 +1,10 @@{ "extends": "../../tsconfig.jsonc", "compilerOptions": { "allowImportingTsExtensions": false, "moduleResolution": "Bundler", "noEmit": true, "lib": ["ES2020", "WebWorker"] }, "include": ["src/**/*.ts"] }
-
-
-
@@ -0,0 +1,9 @@TypeScript のコンパイラ設定ファイル。 実際は JSON ではなく JSONC のためコメントを含められるが、 .jsonc にすると TypeScript 関連のツールがデフォルトで読まなくなる。逆に .json のまま コメントを含めるとフォーマッタといった TypeScript 以外の JSON を扱うツール でエラーになる。そのため拡張子と仕様に準拠している。 SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
-
@@ -6,3 +6,7 @@# What: Protobuf から自動生成されたソースコードの格納先。 # Why: `packages/proto/` ディレクトリ配下の .proto ファイルから自動生成されるため。 /src/proto # What: ビルドされた .js と .d.ts ファイル。 # Why: 編集するものではないため。 /lib
-
-
packages/backend_core/build.zig (deleted)
-
@@ -1,39 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //! Zig のビルドスクリプト。 const std = @import("std"); const ProtoGenStep = @import("gremlin").ProtoGenStep; pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const gremlin = b.dependency("gremlin", .{ .target = target, .optimize = optimize, }).module("gremlin"); const proto = ProtoGenStep.create( b, .{ .proto_sources = b.path("../proto/"), .target = b.path("src/proto"), }, ); // TODO: バックエンドをちゃんと実装する際にライブラリに変更する const exe = b.addExecutable(.{ .name = "backend_core", .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, }); exe.root_module.addImport("gremlin", gremlin); exe.step.dependOn(&proto.step); b.installArtifact(exe); }
-
-
-
packages/backend_core/src/main.zig (deleted)
-
@@ -1,17 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only const std = @import("std"); const ping_response = @import("./proto/yamori/meta/v1/ping_response.proto.zig"); pub fn main() !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const bytes = try ping_response.PingResponse.encode(&.{}, allocator); std.debug.print("{d} bytes: {s}\n", .{ bytes.len, bytes }); }
-
-
-
@@ -13,7 +13,12 @@ "make": {"command": "vite build", "files": ["src/**/*.{ts,tsx,css,html,json}", "package.json", "vite.config.ts"], "output": ["dist/**"], "dependencies": ["../idb_backend:js", "../react_ui:js"], "dependencies": [ "../idb_backend:js", "../react_ui:js", "../backend:js", "../backend:wasm" ], "packageLocks": ["bun.lockb"] }, "check": {
-
@@ -37,6 +42,14 @@ },{ "script": "../idb_backend:js", "cascade": false }, { "script": "../backend:js", "cascade": false }, { "script": "../backend:wasm", "cascade": false } ], "packageLocks": ["bun.lockb"]
-
@@ -45,6 +58,7 @@ },"dependencies": { "@yamori/idb_backend": "workspace:*", "@yamori/react_ui": "workspace:*", "@yamori/backend": "workspace:*", "react": "^19.0.0", "react-dom": "^19.0.0" },
-
-
-
@@ -5,7 +5,7 @@ <meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /> <meta http-equiv="content-security-policy" content="default-src 'none'; img-src 'self' data:; script-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; worker-src 'self'; font-src 'self'" content="default-src 'none'; img-src 'self' data:; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'; style-src 'self' 'unsafe-inline'; worker-src 'self'; font-src 'self'" /> <script type="module" src="./main.tsx"></script> <link rel="stylesheet" href="./styles.css" />
-
-
-
@@ -14,6 +14,23 @@import css from "./main.module.css"; import { Message, isValidMessage } from "./worker/message.ts"; import type { IncomingMessage, OutgoingMessage } from "@yamori/backend"; function isOutgoingMessage(x: unknown): x is OutgoingMessage { if (!x || typeof x !== "object" || !("type" in x)) { return false; } switch (x.type) { case "response": case "error": case "ready": return true; default: return false; } } async function readThirdPartyNotice() { const resp = await fetch("/third-party.txt");
-
@@ -69,3 +86,43 @@ </HistoryAPIRouterProvider></ThirdPartyNoticeProvider>, ); }); const backend = new Worker(new URL("./worker/backend.ts", import.meta.url), { type: "module", }); backend.addEventListener( "message", (event) => { if (!isOutgoingMessage(event.data) || event.data.type !== "ready") { return; } backend.addEventListener("message", (event) => { if (!isOutgoingMessage(event.data)) { return; } switch (event.data.type) { case "response": { console.dir(event.data); return; } case "error": { console.error(event.data.error); return; } } }); backend.postMessage({ type: "request", service: "yamori.meta.v1", method: "Ping", data: new Uint8Array([]), } satisfies IncomingMessage); }, { once: true, }, );
-
-
-
@@ -0,0 +1,4 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "@yamori/backend";
-