From 307aa976bb7460cdee68a763e4ec82f7cbe9cb24 Mon Sep 17 00:00:00 2001 From: Ian Macalinao Date: Tue, 28 Jul 2026 17:19:49 +0800 Subject: [PATCH 1/3] feat(orpc-temporal): add oRPC custom JSON serializers for Temporal types oRPC has no built-in Temporal support. A Temporal value is stringified by its toJSON() during JSON encoding and nothing rebuilds it on the receiving end, so it arrives as a plain string while TypeScript still reports it as a Temporal type. Nothing throws at the boundary, so the mismatch surfaces later as a confusing runtime failure. Add an orpc-temporal package exporting temporalRPCSerializers, covering all eight Temporal types: Instant, ZonedDateTime, PlainDate, PlainTime, PlainDateTime, PlainYearMonth, PlainMonthDay, and Duration. Values round trip through toJSON()/from(), preserving time zones, non-ISO calendar annotations, nanosecond precision, and negative durations. Serializer type IDs occupy 1000-1007, clear of oRPC's reserved 0-7 range, and createTemporalRPCSerializers({ baseType }) relocates them if they collide with a consumer's own serializers. --- .changeset/olive-donuts-shave.md | 7 + README.md | 1 + bun.lock | 62 ++- packages/orpc-temporal/.gitignore | 1 + packages/orpc-temporal/README.md | 77 ++++ packages/orpc-temporal/package.json | 55 +++ packages/orpc-temporal/src/end-to-end.test.ts | 114 +++++ packages/orpc-temporal/src/index.ts | 22 + .../src/temporal-rpc-serializers.test.ts | 392 ++++++++++++++++++ .../src/temporal-rpc-serializers.ts | 121 ++++++ packages/orpc-temporal/tsconfig.json | 6 + 11 files changed, 855 insertions(+), 3 deletions(-) create mode 100644 .changeset/olive-donuts-shave.md create mode 100644 packages/orpc-temporal/.gitignore create mode 100644 packages/orpc-temporal/README.md create mode 100644 packages/orpc-temporal/package.json create mode 100644 packages/orpc-temporal/src/end-to-end.test.ts create mode 100644 packages/orpc-temporal/src/index.ts create mode 100644 packages/orpc-temporal/src/temporal-rpc-serializers.test.ts create mode 100644 packages/orpc-temporal/src/temporal-rpc-serializers.ts create mode 100644 packages/orpc-temporal/tsconfig.json diff --git a/.changeset/olive-donuts-shave.md b/.changeset/olive-donuts-shave.md new file mode 100644 index 0000000..ee68b33 --- /dev/null +++ b/.changeset/olive-donuts-shave.md @@ -0,0 +1,7 @@ +--- +"orpc-temporal": minor +--- + +Add `orpc-temporal`, providing oRPC custom JSON serializers for all eight Temporal types. + +Without them, oRPC sends Temporal values as plain ISO strings and never rebuilds them on the receiving end, so a value arrives as a `string` even though its declared type says otherwise. Pass `temporalRPCSerializers` to both the server handler and the client link to round-trip `Instant`, `ZonedDateTime`, `PlainDate`, `PlainTime`, `PlainDateTime`, `PlainYearMonth`, `PlainMonthDay`, and `Duration`, preserving time zones, non-ISO calendars, nanosecond precision, and negative durations. diff --git a/README.md b/README.md index 2e572b5..520d71f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ For more detailed documentation, see the [API docs](https://temporal.ianm.com). | :---------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------- | | [format-temporal](packages/format-temporal) | [![npm](https://img.shields.io/npm/v/format-temporal.svg?logo=npm&label=)](https://www.npmjs.com/package/format-temporal) | [![downloads](https://img.shields.io/npm/dm/format-temporal.svg?label=)](https://www.npmjs.com/package/format-temporal) | Format Temporal objects as localized strings. | | [interval-temporal](packages/interval-temporal) | [![npm](https://img.shields.io/npm/v/interval-temporal.svg?logo=npm&label=)](https://www.npmjs.com/package/interval-temporal) | [![downloads](https://img.shields.io/npm/dm/interval-temporal.svg?label=)](https://www.npmjs.com/package/interval-temporal) | An interval type for Temporal, plus helpers for comparing and sorting Temporal objects. | +| [orpc-temporal](packages/orpc-temporal) | [![npm](https://img.shields.io/npm/v/orpc-temporal.svg?logo=npm&label=)](https://www.npmjs.com/package/orpc-temporal) | [![downloads](https://img.shields.io/npm/dm/orpc-temporal.svg?label=)](https://www.npmjs.com/package/orpc-temporal) | oRPC custom JSON serializers for Temporal types. | | [parse-temporal](packages/parse-temporal) | [![npm](https://img.shields.io/npm/v/parse-temporal.svg?logo=npm&label=)](https://www.npmjs.com/package/parse-temporal) | [![downloads](https://img.shields.io/npm/dm/parse-temporal.svg?label=)](https://www.npmjs.com/package/parse-temporal) | Parse Temporal objects from strings. | | [superjson-temporal](packages/superjson-temporal) | [![npm](https://img.shields.io/npm/v/superjson-temporal.svg?logo=npm&label=)](https://www.npmjs.com/package/superjson-temporal) | [![downloads](https://img.shields.io/npm/dm/superjson-temporal.svg?label=)](https://www.npmjs.com/package/superjson-temporal) | SuperJSON serializers/deserializers for Temporal types. | | [temporal-quarter-fns](packages/temporal-quarter-fns) | [![npm](https://img.shields.io/npm/v/temporal-quarter-fns.svg?logo=npm&label=)](https://www.npmjs.com/package/temporal-quarter-fns) | [![downloads](https://img.shields.io/npm/dm/temporal-quarter-fns.svg?label=)](https://www.npmjs.com/package/temporal-quarter-fns) | Functions for working with quarters in Temporal. | diff --git a/bun.lock b/bun.lock index 8cf1065..80dc82f 100644 --- a/bun.lock +++ b/bun.lock @@ -1,15 +1,16 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "monorepo", "devDependencies": { - "@changesets/cli": "^2.31.0", + "@changesets/cli": "^2.31.1", "oxfmt": "0.55.0", "oxlint": "1.69.0", "oxlint-tsgolint": "0.23.0", - "turbo": "^2.10.0", - "typedoc": "^0.28.19", + "turbo": "^2.10.5", + "typedoc": "^0.28.20", "typescript": "^5.9.3", }, }, @@ -46,6 +47,25 @@ "temporal-polyfill": "^0.2 || ^0.3", }, }, + "packages/orpc-temporal": { + "name": "orpc-temporal", + "version": "0.0.0", + "dependencies": { + "tslib": "^2.8.1", + }, + "devDependencies": { + "@macalinao/tsconfig": "^3.2.5", + "@orpc/client": "^1.14.12", + "@orpc/server": "^1.14.12", + "@types/bun": "latest", + "temporal-polyfill": "^0.3.0", + "typescript": "^5.9.3", + }, + "peerDependencies": { + "@orpc/client": "^1.14.0", + "temporal-polyfill": "^0.2 || ^0.3", + }, + }, "packages/parse-temporal": { "name": "parse-temporal", "version": "0.5.4", @@ -173,6 +193,28 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@orpc/client": ["@orpc/client@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-fetch": "1.14.12", "@orpc/standard-server-peer": "1.14.12" } }, "sha512-XMNjdNctSLLSaCmmWV/c4klMXU9xT0KfTFbC6lUwKNk8N5hPcue7EeP5EVVSV3cSquliRbyUbLovYqaVpuOt9Q=="], + + "@orpc/contract": ["@orpc/contract@1.14.12", "", { "dependencies": { "@orpc/client": "1.14.12", "@orpc/shared": "1.14.12", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-+rf66G4tV074uEab0lkEy9kMgt2QA7FU8NfeCSt6kyYa1eKU5C6goz4OpvNcOFfk+eTGd5K01oVUiz2LSRfYrQ=="], + + "@orpc/interop": ["@orpc/interop@1.14.12", "", {}, "sha512-NdJTDW9JfBVrY7kv6wQHLT4avDlrCucakfCHPS8OI7pUPjUCa41yWnreIdIskKTgj9iBdbjFXOe6TVRQv/D6Iw=="], + + "@orpc/server": ["@orpc/server@1.14.12", "", { "dependencies": { "@orpc/client": "1.14.12", "@orpc/contract": "1.14.12", "@orpc/interop": "1.14.12", "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-aws-lambda": "1.14.12", "@orpc/standard-server-fastify": "1.14.12", "@orpc/standard-server-fetch": "1.14.12", "@orpc/standard-server-node": "1.14.12", "@orpc/standard-server-peer": "1.14.12", "cookie": "^1.1.1" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-4uHxhkiA8KHVHaa4WwUtY08q0Qs8fcKVngJypbT20Joa/yifWoYufqtv2/sw9nwkuMAOL+kLGEI5EvZMkCXScg=="], + + "@orpc/shared": ["@orpc/shared@1.14.12", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-FHTFSEJoCcZIaJWSNr+FxRDrSO5yCYdf+6QRVM9EIKQqJJVkSjJP5AJNf9bd1Nm3jdDQrhsIBQq84HOJMfd0gg=="], + + "@orpc/standard-server": ["@orpc/standard-server@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12" } }, "sha512-9+XGxpmebDJxzneBS+K6f3EWaE5p2iYCBdMKnTRH6oU4TdJTw048DadZbfsg1dcf+FChRmkzlHhXXnqgdW2K6Q=="], + + "@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-fetch": "1.14.12", "@orpc/standard-server-node": "1.14.12" } }, "sha512-iJDzFKdsv7r83apCEm2HMWaZKOax6TCyAV31TT6p+AGau5GmayZ7yDMgoUZeADFp0KeHBZGWMNkQUnf/bIEbhw=="], + + "@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-node": "1.14.12" }, "peerDependencies": { "fastify": ">=5.6.1" }, "optionalPeers": ["fastify"] }, "sha512-C6QirJkLawEsATwEj9QX0MYKPcUZvBx+Gv+D2uS61T8aEKx1mccVtnl/2taP162yU5rt1LtJAkuisPPxSn37xw=="], + + "@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12" } }, "sha512-4VQaWHlCoROqtQzXVv1pt1jUs5c3r6OwO4h+545vT6dZoaiOoFEUimhYqSGSlt6MClJTQiuQotrkeb4NrLlniw=="], + + "@orpc/standard-server-node": ["@orpc/standard-server-node@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-fetch": "1.14.12" } }, "sha512-/BfQUiL7f3aY+fMcka3IHgnov0YgDz4dQhBJ5Ku3p/nOg5iH+OLVwbQKYfC1et2uY4EBSWyNlKGHAlzwWKMR6A=="], + + "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12" } }, "sha512-i3L1jKIL4GV6j3mi08cLE5aXxPIWk/vGHvFwN5JYjBKTltFv5MORWYqwzH1Qx9kXg9dvr73fyNm6lPlvBqoxDQ=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.55.0", "", { "os": "android", "cpu": "arm" }, "sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.55.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw=="], @@ -271,6 +313,8 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.10.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ENvPwy3x5yS7MwNYHeWjqOBXkwIMp39Pd+/zXC6PoiNzF8EIvvLZOZZ+ny6L9x4WgS5vxUii2LM5gM+zjPdnWw=="], "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rqROo9zsF/P9RqsdtbLD1nFJicjSrYyvQ9kNJC38AbxA3pAs6VAlATvtvOFx7bqOv6vicf20SP9kF33avJjy2w=="], @@ -315,6 +359,8 @@ "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -401,6 +447,10 @@ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "orpc-temporal": ["orpc-temporal@workspace:packages/orpc-temporal"], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "oxfmt": ["oxfmt@0.55.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.55.0", "@oxfmt/binding-android-arm64": "0.55.0", "@oxfmt/binding-darwin-arm64": "0.55.0", "@oxfmt/binding-darwin-x64": "0.55.0", "@oxfmt/binding-freebsd-x64": "0.55.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.55.0", "@oxfmt/binding-linux-arm-musleabihf": "0.55.0", "@oxfmt/binding-linux-arm64-gnu": "0.55.0", "@oxfmt/binding-linux-arm64-musl": "0.55.0", "@oxfmt/binding-linux-ppc64-gnu": "0.55.0", "@oxfmt/binding-linux-riscv64-gnu": "0.55.0", "@oxfmt/binding-linux-riscv64-musl": "0.55.0", "@oxfmt/binding-linux-s390x-gnu": "0.55.0", "@oxfmt/binding-linux-x64-gnu": "0.55.0", "@oxfmt/binding-linux-x64-musl": "0.55.0", "@oxfmt/binding-openharmony-arm64": "0.55.0", "@oxfmt/binding-win32-arm64-msvc": "0.55.0", "@oxfmt/binding-win32-ia32-msvc": "0.55.0", "@oxfmt/binding-win32-x64-msvc": "0.55.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A=="], @@ -443,6 +493,8 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], + "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -477,6 +529,8 @@ "superjson-temporal": ["superjson-temporal@workspace:packages/superjson-temporal"], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "temporal-polyfill": ["temporal-polyfill@0.3.0", "", { "dependencies": { "temporal-spec": "0.3.0" } }, "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g=="], "temporal-quarter-fns": ["temporal-quarter-fns@workspace:packages/temporal-quarter-fns"], @@ -495,6 +549,8 @@ "turbo": ["turbo@2.10.5", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.5", "@turbo/darwin-arm64": "2.10.5", "@turbo/linux-64": "2.10.5", "@turbo/linux-arm64": "2.10.5", "@turbo/windows-64": "2.10.5", "@turbo/windows-arm64": "2.10.5" }, "bin": { "turbo": "bin/turbo" } }, "sha512-07Y/C7OUp23l4P92PJoYtFNbHjLhftrZH5Ce7dbczS4kX2Re+wtbXvZLoxn/pUtzgsQaRCBaRuZPJp4zmAn0WQ=="], + "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], + "typedoc": ["typedoc@0.28.20", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.3.0", "minimatch": "^10.2.5", "yaml": "^2.9.0" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/packages/orpc-temporal/.gitignore b/packages/orpc-temporal/.gitignore new file mode 100644 index 0000000..9b1c8b1 --- /dev/null +++ b/packages/orpc-temporal/.gitignore @@ -0,0 +1 @@ +/dist diff --git a/packages/orpc-temporal/README.md b/packages/orpc-temporal/README.md new file mode 100644 index 0000000..4000ccf --- /dev/null +++ b/packages/orpc-temporal/README.md @@ -0,0 +1,77 @@ +# orpc-temporal + +NPM version + +[oRPC](https://orpc.unnoq.com/) custom JSON serializers for Temporal types. + +This depends on the [temporal-polyfill](https://www.npmjs.com/package/temporal-polyfill) package. + +## Why + +oRPC has no built-in support for Temporal. Without these serializers, a Temporal +value is stringified by its `toJSON()` during JSON encoding and nothing rebuilds +it on the other end, so it arrives as a plain `string`: + +```typescript +const result = await client.getEvent(); +result.startsAt instanceof Temporal.ZonedDateTime; // false — it's a string +result.startsAt.add({ hours: 1 }); // TypeError at runtime +``` + +No error is raised at the boundary, and TypeScript still reports the value as a +Temporal type, so the mismatch surfaces later as a confusing runtime failure. + +## Usage + +Pass the same serializers to both the server handler and the client link. + +```typescript +import { RPCHandler } from "@orpc/server/fetch"; +import { RPCLink } from "@orpc/client/fetch"; +import { temporalRPCSerializers } from "orpc-temporal"; + +const handler = new RPCHandler(router, { + customJsonSerializers: temporalRPCSerializers, +}); + +const link = new RPCLink({ + url: "https://example.com/rpc", + customJsonSerializers: temporalRPCSerializers, +}); +``` + +All eight Temporal types are covered: + +| Type | Wire format | +| :------------------------ | :----------------------------------------- | +| `Temporal.Instant` | `2022-01-28T18:53:00.123456789Z` | +| `Temporal.ZonedDateTime` | `2022-01-28T19:53:00+01:00[Europe/Berlin]` | +| `Temporal.PlainDate` | `2024-02-29` | +| `Temporal.PlainTime` | `01:02:03.123456789` | +| `Temporal.PlainDateTime` | `2024-02-29T01:02:03` | +| `Temporal.PlainYearMonth` | `2024-02` | +| `Temporal.PlainMonthDay` | `02-29` | +| `Temporal.Duration` | `-P1Y2M3DT4H5M6.789S` | + +Values are serialized with `toJSON()` and restored with `from()`, so time zones, +non-ISO calendar annotations (`[u-ca=hebrew]`), nanosecond precision, and +negative durations all survive the round trip. + +## Type IDs + +oRPC identifies each custom serializer by a numeric type ID that is part of the +wire format. Its built-in serializers reserve `0`–`7`, so this package uses +`1000`–`1007` to stay clear of them and of any built-ins oRPC adds later. + +If that range collides with your own custom serializers, move it — but both ends +of the connection must use the same base: + +```typescript +import { createTemporalRPCSerializers } from "orpc-temporal"; + +const serializers = createTemporalRPCSerializers({ baseType: 5000 }); +``` + +## License + +Apache-2.0 diff --git a/packages/orpc-temporal/package.json b/packages/orpc-temporal/package.json new file mode 100644 index 0000000..f948301 --- /dev/null +++ b/packages/orpc-temporal/package.json @@ -0,0 +1,55 @@ +{ + "name": "orpc-temporal", + "version": "0.0.0", + "description": "oRPC custom JSON serializers for Temporal types.", + "keywords": [ + "orpc", + "temporal" + ], + "homepage": "https://github.com/macalinao/temporal-utils", + "bugs": { + "url": "https://github.com/macalinao/temporal-utils/issues" + }, + "license": "Apache-2.0", + "author": "Ian Macalinao ", + "repository": "github:macalinao/temporal-utils", + "files": [ + "dist/", + "src/", + "README.md" + ], + "type": "module", + "sideEffects": false, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "tsc", + "clean": "tsc --build --clean && rm -fr dist/", + "test": "bun test src/" + }, + "dependencies": { + "tslib": "^2.8.1" + }, + "devDependencies": { + "@macalinao/tsconfig": "^3.2.5", + "@orpc/client": "^1.14.12", + "@orpc/server": "^1.14.12", + "@types/bun": "latest", + "temporal-polyfill": "^0.3.0", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@orpc/client": "^1.14.0", + "temporal-polyfill": "^0.2 || ^0.3" + } +} diff --git a/packages/orpc-temporal/src/end-to-end.test.ts b/packages/orpc-temporal/src/end-to-end.test.ts new file mode 100644 index 0000000..a1bf165 --- /dev/null +++ b/packages/orpc-temporal/src/end-to-end.test.ts @@ -0,0 +1,114 @@ +import type { RouterClient } from "@orpc/server"; +import { beforeAll, describe, expect, test } from "bun:test"; +import { createORPCClient } from "@orpc/client"; +import { RPCLink } from "@orpc/client/fetch"; +import { os } from "@orpc/server"; +import { RPCHandler } from "@orpc/server/fetch"; +import { Temporal } from "temporal-polyfill"; +import { temporalRPCSerializers } from "./temporal-rpc-serializers.js"; + +interface Payload { + instant: Temporal.Instant; + zonedDateTime: Temporal.ZonedDateTime; + plainDate: Temporal.PlainDate; + plainTime: Temporal.PlainTime; + plainDateTime: Temporal.PlainDateTime; + plainYearMonth: Temporal.PlainYearMonth; + plainMonthDay: Temporal.PlainMonthDay; + duration: Temporal.Duration; +} + +const router = { + /** Returns its input unchanged, exercising both request and response paths. */ + echo: os.handler(({ input }) => input as Payload), + /** Proves the server received real Temporal instances, not strings. */ + addDay: os.handler(({ input }) => + (input as Temporal.PlainDate).add({ days: 1 }), + ), +}; + +type Client = RouterClient; + +/** Wires an RPCLink straight into an RPCHandler, skipping the network. */ +const createClient = (): Client => { + const handler = new RPCHandler(router, { + customJsonSerializers: temporalRPCSerializers, + }); + + const link = new RPCLink({ + url: "http://localhost/rpc", + customJsonSerializers: temporalRPCSerializers, + fetch: async (request) => { + const { matched, response } = await handler.handle(request, { + prefix: "/rpc", + }); + if (!matched) { + throw new Error("no procedure matched"); + } + return response; + }, + }); + + return createORPCClient(link); +}; + +describe("end-to-end over RPCHandler and RPCLink", () => { + let client: Client; + let sent: Payload; + + beforeAll(() => { + client = createClient(); + const zoned = Temporal.ZonedDateTime.from( + "2022-01-28T19:53+01:00[Europe/Berlin]", + ); + sent = { + instant: Temporal.Instant.from("2022-01-28T18:53:00.123456789Z"), + zonedDateTime: zoned, + plainDate: Temporal.PlainDate.from("2024-02-29"), + plainTime: Temporal.PlainTime.from("01:02:03.123456789"), + plainDateTime: Temporal.PlainDateTime.from("2024-02-29T01:02:03.000009"), + plainYearMonth: Temporal.PlainYearMonth.from("2024-02"), + plainMonthDay: Temporal.PlainMonthDay.from("02-29"), + duration: Temporal.Duration.from("-P1Y2M3DT4H5M6.789S"), + }; + }); + + test("every Temporal type survives a real request and response", async () => { + const received = await client.echo(sent); + + expect(received.instant).toBeInstanceOf(Temporal.Instant); + expect(received.zonedDateTime).toBeInstanceOf(Temporal.ZonedDateTime); + expect(received.plainDate).toBeInstanceOf(Temporal.PlainDate); + expect(received.plainTime).toBeInstanceOf(Temporal.PlainTime); + expect(received.plainDateTime).toBeInstanceOf(Temporal.PlainDateTime); + expect(received.plainYearMonth).toBeInstanceOf(Temporal.PlainYearMonth); + expect(received.plainMonthDay).toBeInstanceOf(Temporal.PlainMonthDay); + expect(received.duration).toBeInstanceOf(Temporal.Duration); + + expect(received.instant.equals(sent.instant)).toBe(true); + expect(received.zonedDateTime.equals(sent.zonedDateTime)).toBe(true); + expect(received.zonedDateTime.timeZoneId).toEqual("Europe/Berlin"); + expect(received.plainDate.equals(sent.plainDate)).toBe(true); + expect(received.plainTime.equals(sent.plainTime)).toBe(true); + expect(received.plainDateTime.equals(sent.plainDateTime)).toBe(true); + expect(received.plainYearMonth.equals(sent.plainYearMonth)).toBe(true); + expect(received.plainMonthDay.equals(sent.plainMonthDay)).toBe(true); + expect(received.duration.toString()).toEqual(sent.duration.toString()); + }); + + test("the server operates on a real Temporal instance", async () => { + const result = await client.addDay(Temporal.PlainDate.from("2024-02-28")); + + expect(result).toBeInstanceOf(Temporal.PlainDate); + expect(result.toString()).toEqual("2024-02-29"); + }); + + test("a non-ISO calendar survives the trip", async () => { + const received = await client.echo({ + ...sent, + plainDate: Temporal.PlainDate.from("2024-02-29").withCalendar("hebrew"), + }); + + expect(received.plainDate.calendarId).toEqual("hebrew"); + }); +}); diff --git a/packages/orpc-temporal/src/index.ts b/packages/orpc-temporal/src/index.ts new file mode 100644 index 0000000..a402d40 --- /dev/null +++ b/packages/orpc-temporal/src/index.ts @@ -0,0 +1,22 @@ +/** + * oRPC custom JSON serializers for Temporal types. + * + * @example + * ```typescript + * import { temporalRPCSerializers } from "orpc-temporal"; + * + * const handler = new RPCHandler(router, { + * customJsonSerializers: temporalRPCSerializers, + * }); + * ``` + * + * @module + * @see {@link https://github.com/macalinao/temporal-utils/tree/master/packages/orpc-temporal | orpc-temporal on GitHub} + */ +export type { CreateTemporalRPCSerializersOptions } from "./temporal-rpc-serializers.js"; +export { + createTemporalRPCSerializers, + DEFAULT_TEMPORAL_RPC_SERIALIZER_BASE_TYPE, + TEMPORAL_RPC_SERIALIZER_TYPE_OFFSETS, + temporalRPCSerializers, +} from "./temporal-rpc-serializers.js"; diff --git a/packages/orpc-temporal/src/temporal-rpc-serializers.test.ts b/packages/orpc-temporal/src/temporal-rpc-serializers.test.ts new file mode 100644 index 0000000..6dc4a81 --- /dev/null +++ b/packages/orpc-temporal/src/temporal-rpc-serializers.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, test } from "bun:test"; +import { + StandardRPCJsonSerializer, + StandardRPCSerializer, +} from "@orpc/client/standard"; +import { Temporal } from "temporal-polyfill"; +import { + createTemporalRPCSerializers, + DEFAULT_TEMPORAL_RPC_SERIALIZER_BASE_TYPE, + TEMPORAL_RPC_SERIALIZER_TYPE_OFFSETS, + temporalRPCSerializers, +} from "./temporal-rpc-serializers.js"; + +const serializer = new StandardRPCSerializer( + new StandardRPCJsonSerializer({ + customJsonSerializers: temporalRPCSerializers, + }), +); + +/** + * Sends a value through the full oRPC wire cycle: serialize, encode as JSON + * text, decode, deserialize. The `JSON.parse(JSON.stringify(...))` step is what + * makes this a real round trip rather than an in-memory pass-through. + */ +const roundTrip = (value: T): T => { + const wire = JSON.parse( + JSON.stringify(serializer.serialize(value)), + ) as unknown; + return serializer.deserialize(wire) as T; +}; + +describe("temporalRPCSerializers", () => { + describe("round-trips every Temporal type", () => { + const zoned = Temporal.ZonedDateTime.from( + "2022-01-28T19:53+01:00[Europe/Berlin]", + ); + + const cases = { + instant: zoned.toInstant(), + zonedDateTime: zoned, + plainDate: zoned.toPlainDate(), + plainTime: zoned.toPlainTime(), + plainDateTime: zoned.toPlainDateTime(), + plainYearMonth: zoned.toPlainDate().toPlainYearMonth(), + plainMonthDay: zoned.toPlainDate().toPlainMonthDay(), + duration: Temporal.Duration.from("P1Y2M3DT4H5M6.789S"), + }; + + for (const [name, value] of Object.entries(cases)) { + test(name, () => { + const result = roundTrip(value); + + expect(result).toBeInstanceOf( + (value as object).constructor as new () => unknown, + ); + expect(result.toString()).toEqual(value.toString()); + }); + } + + test("all eight types survive a single nested payload", () => { + const result = roundTrip({ nested: { deep: cases }, list: [cases] }); + + expect(result.nested.deep.instant).toBeInstanceOf(Temporal.Instant); + expect(result.list[0]?.plainDate).toBeInstanceOf(Temporal.PlainDate); + expect(result.nested.deep.zonedDateTime.equals(zoned)).toBe(true); + expect(result.list[0]?.duration.toString()).toEqual( + cases.duration.toString(), + ); + }); + }); + + describe("preserves time zone and calendar", () => { + test("ZonedDateTime keeps its time zone rather than collapsing to an offset", () => { + const value = Temporal.ZonedDateTime.from( + "2022-01-28T19:53+01:00[Europe/Berlin]", + ); + const result = roundTrip(value); + + expect(result.timeZoneId).toEqual("Europe/Berlin"); + expect(result.equals(value)).toBe(true); + }); + + test("ZonedDateTime keeps a non-ISO calendar", () => { + const value = Temporal.ZonedDateTime.from( + "2022-01-28T19:53+01:00[Europe/Berlin][u-ca=hebrew]", + ); + const result = roundTrip(value); + + expect(result.calendarId).toEqual("hebrew"); + expect(result.timeZoneId).toEqual("Europe/Berlin"); + expect(result.equals(value)).toBe(true); + }); + + test("ZonedDateTime survives a DST fall-back instant", () => { + // 2:30am occurs twice on this date in New York; the offset in the string + // is what disambiguates it. + const value = Temporal.ZonedDateTime.from( + "2023-11-05T01:30:00-05:00[America/New_York]", + ); + const result = roundTrip(value); + + expect(result.offset).toEqual("-05:00"); + expect(result.epochNanoseconds).toEqual(value.epochNanoseconds); + }); + + test.each([ + ["hebrew"], + ["japanese"], + ["islamic-umalqura"], + ["chinese"], + ] as const)("PlainDate keeps the %s calendar", (calendar) => { + const value = + Temporal.PlainDate.from("2024-02-29").withCalendar(calendar); + const result = roundTrip(value); + + expect(result.calendarId).toEqual(calendar); + expect(result.equals(value)).toBe(true); + }); + + test("PlainDateTime keeps a non-ISO calendar", () => { + const value = Temporal.PlainDateTime.from( + "2024-02-29T01:02:03", + ).withCalendar("hebrew"); + const result = roundTrip(value); + + expect(result.calendarId).toEqual("hebrew"); + expect(result.equals(value)).toBe(true); + }); + + test("PlainYearMonth keeps a non-ISO calendar", () => { + const value = Temporal.PlainDate.from("2024-02-29") + .withCalendar("hebrew") + .toPlainYearMonth(); + const result = roundTrip(value); + + expect(result.calendarId).toEqual("hebrew"); + expect(result.equals(value)).toBe(true); + }); + + test("PlainMonthDay keeps a non-ISO calendar", () => { + const value = Temporal.PlainDate.from("2024-02-29") + .withCalendar("hebrew") + .toPlainMonthDay(); + const result = roundTrip(value); + + expect(result.calendarId).toEqual("hebrew"); + expect(result.equals(value)).toBe(true); + }); + }); + + describe("preserves sub-second precision", () => { + test("Instant keeps nanoseconds", () => { + const value = Temporal.Instant.from("2022-01-28T18:53:00.123456789Z"); + const result = roundTrip(value); + + expect(result.epochNanoseconds).toEqual(value.epochNanoseconds); + expect(result.equals(value)).toBe(true); + }); + + test("PlainTime keeps nanoseconds", () => { + const value = Temporal.PlainTime.from("01:02:03.123456789"); + const result = roundTrip(value); + + expect(result.millisecond).toEqual(123); + expect(result.microsecond).toEqual(456); + expect(result.nanosecond).toEqual(789); + expect(result.equals(value)).toBe(true); + }); + + test("PlainDateTime keeps nanoseconds", () => { + const value = Temporal.PlainDateTime.from( + "2024-02-29T01:02:03.123456789", + ); + const result = roundTrip(value); + + expect(result.nanosecond).toEqual(789); + expect(result.equals(value)).toBe(true); + }); + + test("Duration keeps a single nanosecond", () => { + const value = Temporal.Duration.from({ nanoseconds: 1 }); + const result = roundTrip(value); + + expect(result.nanoseconds).toEqual(1); + expect(result.total("nanoseconds")).toEqual(1); + }); + }); + + describe("Duration edge cases", () => { + test("negative duration keeps its sign", () => { + const value = Temporal.Duration.from("-P1Y2M3DT4H5M6.789S"); + const result = roundTrip(value); + + expect(result.sign).toEqual(-1); + expect(result.toString()).toEqual(value.toString()); + expect(Temporal.Duration.compare(result, value)).toEqual(0); + }); + + test("negative duration keeps each unit negative", () => { + const value = Temporal.Duration.from({ hours: -4, minutes: -5 }); + const result = roundTrip(value); + + expect(result.hours).toEqual(-4); + expect(result.minutes).toEqual(-5); + }); + + test("zero duration round-trips", () => { + const value = Temporal.Duration.from({ seconds: 0 }); + const result = roundTrip(value); + + expect(result.sign).toEqual(0); + expect(result.toString()).toEqual("PT0S"); + }); + + test("unbalanced units are not silently normalized", () => { + // P1M and P30D are different durations; the wire format must not + // collapse one into the other. + const value = Temporal.Duration.from({ months: 1 }); + const result = roundTrip(value); + + expect(result.months).toEqual(1); + expect(result.days).toEqual(0); + }); + + test("weeks are preserved separately from days", () => { + const value = Temporal.Duration.from({ weeks: 2 }); + const result = roundTrip(value); + + expect(result.weeks).toEqual(2); + expect(result.days).toEqual(0); + }); + + test("large duration round-trips", () => { + const value = Temporal.Duration.from({ hours: 1_000_000 }); + const result = roundTrip(value); + + expect(result.hours).toEqual(1_000_000); + }); + }); + + describe("edge case dates", () => { + test("leap day round-trips", () => { + const value = Temporal.PlainDate.from("2024-02-29"); + expect(roundTrip(value).equals(value)).toBe(true); + }); + + test("negative (BCE) year round-trips", () => { + const value = Temporal.PlainDate.from("-000753-04-21"); + const result = roundTrip(value); + + expect(result.year).toEqual(-753); + expect(result.equals(value)).toBe(true); + }); + + test("midnight PlainTime round-trips", () => { + const value = Temporal.PlainTime.from("00:00:00"); + expect(roundTrip(value).equals(value)).toBe(true); + }); + + test("Instant before the epoch round-trips", () => { + const value = Temporal.Instant.from("1900-01-01T00:00:00Z"); + expect(roundTrip(value).equals(value)).toBe(true); + }); + }); + + describe("coexists with oRPC built-in types", () => { + test("built-ins still work alongside Temporal values", () => { + const value = { + plainDate: Temporal.PlainDate.from("2024-02-29"), + date: new Date("2024-02-29T00:00:00.000Z"), + big: 9_007_199_254_740_993n, + set: new Set([1, 2]), + map: new Map([["a", 1]]), + url: new URL("https://example.com/"), + regexp: /abc/giu, + undef: undefined, + nan: Number.NaN, + }; + const result = roundTrip(value); + + expect(result.plainDate).toBeInstanceOf(Temporal.PlainDate); + expect(result.date).toBeInstanceOf(Date); + expect(result.date.getTime()).toEqual(value.date.getTime()); + expect(result.big).toEqual(value.big); + expect(result.set).toEqual(value.set); + expect(result.map).toEqual(value.map); + expect(result.url.toString()).toEqual(value.url.toString()); + expect(result.regexp.source).toEqual(value.regexp.source); + expect(result.undef).toBeUndefined(); + expect(result.nan).toBeNaN(); + }); + + test("a Date is not captured by the Temporal serializers", () => { + const result = roundTrip({ date: new Date(0) }); + + expect(result.date).toBeInstanceOf(Date); + }); + }); + + describe("type IDs", () => { + test("are unique", () => { + const types = temporalRPCSerializers.map((s) => s.type); + + expect(new Set(types).size).toEqual(types.length); + }); + + test("cover all eight Temporal types", () => { + expect(temporalRPCSerializers).toHaveLength(8); + expect(Object.keys(TEMPORAL_RPC_SERIALIZER_TYPE_OFFSETS)).toHaveLength(8); + }); + + test("do not collide with oRPC's reserved built-in range of 0-7", () => { + for (const { type } of temporalRPCSerializers) { + expect(type).toBeGreaterThan(7); + } + }); + + test("are stable, since they are part of the wire format", () => { + const base = DEFAULT_TEMPORAL_RPC_SERIALIZER_BASE_TYPE; + + expect(base).toEqual(1000); + expect(temporalRPCSerializers.map((s) => s.type)).toEqual([ + base, + base + 1, + base + 2, + base + 3, + base + 4, + base + 5, + base + 6, + base + 7, + ]); + }); + + test("oRPC accepts the serializers without complaining about duplicates", () => { + expect( + () => + new StandardRPCJsonSerializer({ + customJsonSerializers: temporalRPCSerializers, + }), + ).not.toThrow(); + }); + }); + + describe("createTemporalRPCSerializers", () => { + test("defaults to the standard base type", () => { + expect(createTemporalRPCSerializers().map((s) => s.type)).toEqual( + temporalRPCSerializers.map((s) => s.type), + ); + }); + + test("shifts every type ID by a custom base", () => { + const shifted = createTemporalRPCSerializers({ baseType: 5000 }); + + expect(shifted.map((s) => s.type)).toEqual([ + 5000, 5001, 5002, 5003, 5004, 5005, 5006, 5007, + ]); + }); + + test("a custom base still round-trips when both ends agree", () => { + const custom = new StandardRPCSerializer( + new StandardRPCJsonSerializer({ + customJsonSerializers: createTemporalRPCSerializers({ + baseType: 5000, + }), + }), + ); + const value = Temporal.PlainDate.from("2024-02-29"); + const wire = JSON.parse( + JSON.stringify(custom.serialize(value)), + ) as unknown; + const result = custom.deserialize(wire); + + expect(result).toBeInstanceOf(Temporal.PlainDate); + expect((result as Temporal.PlainDate).equals(value)).toBe(true); + }); + }); + + describe("without the serializers", () => { + test("Temporal values silently arrive as strings", () => { + // This is the gap the package closes: oRPC has no Temporal support of its + // own, so `toJSON()` runs during JSON encoding and nothing rebuilds the + // instance. No error is thrown — the value just has the wrong type. + const bare = new StandardRPCSerializer(new StandardRPCJsonSerializer()); + const value = { plainDate: Temporal.PlainDate.from("2024-02-29") }; + const wire = JSON.parse(JSON.stringify(bare.serialize(value))) as unknown; + const result = bare.deserialize(wire) as { plainDate: unknown }; + + expect(typeof result.plainDate).toEqual("string"); + expect(result.plainDate).not.toBeInstanceOf(Temporal.PlainDate); + }); + }); +}); diff --git a/packages/orpc-temporal/src/temporal-rpc-serializers.ts b/packages/orpc-temporal/src/temporal-rpc-serializers.ts new file mode 100644 index 0000000..36bc180 --- /dev/null +++ b/packages/orpc-temporal/src/temporal-rpc-serializers.ts @@ -0,0 +1,121 @@ +import type { StandardRPCCustomJsonSerializer } from "@orpc/client/standard"; +import { Temporal } from "temporal-polyfill"; + +/** + * Every Temporal class serialized by this package exposes `toJSON()` and a + * static `from()` that accepts the string it produces. + */ +interface TemporalConstructor string }> { + new (...args: never[]): TInstance; + from: (value: string) => TInstance; +} + +/** + * oRPC reserves type IDs `0`–`7` for its built-in serializers (bigint, Date, + * NaN, undefined, URL, RegExp, Set, Map). Temporal IDs start well above that + * range so they keep working if oRPC adds more built-ins. + */ +export const DEFAULT_TEMPORAL_RPC_SERIALIZER_BASE_TYPE = 1000; + +/** + * Offset of each Temporal type from the base type ID. + * + * These offsets are part of the wire format: a client and a server must agree + * on them, so they are stable across releases. Only the base type ID is + * configurable — see {@link createTemporalRPCSerializers}. + */ +export const TEMPORAL_RPC_SERIALIZER_TYPE_OFFSETS = { + INSTANT: 0, + ZONED_DATE_TIME: 1, + PLAIN_DATE: 2, + PLAIN_TIME: 3, + PLAIN_DATE_TIME: 4, + PLAIN_YEAR_MONTH: 5, + PLAIN_MONTH_DAY: 6, + DURATION: 7, +} as const; + +/** + * Options for {@link createTemporalRPCSerializers}. + */ +export interface CreateTemporalRPCSerializersOptions { + /** + * First type ID to assign. Each Temporal type occupies one ID starting here, + * so this reserves the range `baseType` through `baseType + 7`. + * + * Override this only if the default range collides with your own custom + * serializers. Both ends of the connection must use the same value. + * + * @defaultValue {@link DEFAULT_TEMPORAL_RPC_SERIALIZER_BASE_TYPE} + */ + baseType?: number; +} + +const serializerFor = string }>( + type: number, + cls: TemporalConstructor, +): StandardRPCCustomJsonSerializer => ({ + type, + condition: (data) => data instanceof cls, + // Temporal's `toJSON()` emits an ISO 8601 string that round-trips through + // `from()` with the calendar and time zone annotations intact. + serialize: (data: TInstance) => data.toJSON(), + deserialize: (serialized: string) => cls.from(serialized), +}); + +/** + * Creates oRPC custom JSON serializers for all eight Temporal types, using a + * custom base type ID. + * + * Prefer the ready-made {@link temporalRPCSerializers} unless you need to move + * the type IDs to avoid a collision. + * + * @param options - Type ID configuration. + * @returns Serializers to pass to oRPC's `customJsonSerializers` option. + */ +export function createTemporalRPCSerializers( + options: CreateTemporalRPCSerializersOptions = {}, +): StandardRPCCustomJsonSerializer[] { + const { baseType = DEFAULT_TEMPORAL_RPC_SERIALIZER_BASE_TYPE } = options; + const offsets = TEMPORAL_RPC_SERIALIZER_TYPE_OFFSETS; + + return [ + serializerFor(baseType + offsets.INSTANT, Temporal.Instant), + serializerFor(baseType + offsets.ZONED_DATE_TIME, Temporal.ZonedDateTime), + serializerFor(baseType + offsets.PLAIN_DATE, Temporal.PlainDate), + serializerFor(baseType + offsets.PLAIN_TIME, Temporal.PlainTime), + serializerFor(baseType + offsets.PLAIN_DATE_TIME, Temporal.PlainDateTime), + serializerFor(baseType + offsets.PLAIN_YEAR_MONTH, Temporal.PlainYearMonth), + serializerFor(baseType + offsets.PLAIN_MONTH_DAY, Temporal.PlainMonthDay), + serializerFor(baseType + offsets.DURATION, Temporal.Duration), + ]; +} + +/** + * oRPC custom JSON serializers for all eight Temporal types: + * `Instant`, `ZonedDateTime`, `PlainDate`, `PlainTime`, `PlainDateTime`, + * `PlainYearMonth`, `PlainMonthDay`, and `Duration`. + * + * Pass the same serializers to both the server handler and the client link. + * Without them oRPC sends Temporal values as plain ISO strings and the + * receiving end never rebuilds them — the value arrives as a `string` even + * though its declared type says otherwise. + * + * @example + * ```typescript + * import { RPCHandler } from "@orpc/server/fetch"; + * import { RPCLink } from "@orpc/client/fetch"; + * import { temporalRPCSerializers } from "orpc-temporal"; + * + * const handler = new RPCHandler(router, { + * customJsonSerializers: temporalRPCSerializers, + * }); + * + * const link = new RPCLink({ + * url: "https://example.com/rpc", + * customJsonSerializers: temporalRPCSerializers, + * }); + * ``` + */ +export const temporalRPCSerializers: StandardRPCCustomJsonSerializer[] = + createTemporalRPCSerializers(); diff --git a/packages/orpc-temporal/tsconfig.json b/packages/orpc-temporal/tsconfig.json new file mode 100644 index 0000000..0094e6f --- /dev/null +++ b/packages/orpc-temporal/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": ["@macalinao/tsconfig/tsconfig.base.json"], + "compilerOptions": { + "types": ["bun"] + } +} From ef50cd99416db4c994bbf778d30aa9b3636206df Mon Sep 17 00:00:00 2001 From: Ian Macalinao Date: Tue, 28 Jul 2026 17:21:30 +0800 Subject: [PATCH 2/3] chore: add patch changeset to republish format-temporal The current format-temporal release has a publishing issue, so it needs a new version cut even though its code is unchanged. --- .changeset/tidy-pumas-repeat.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tidy-pumas-repeat.md diff --git a/.changeset/tidy-pumas-repeat.md b/.changeset/tidy-pumas-repeat.md new file mode 100644 index 0000000..0f61af1 --- /dev/null +++ b/.changeset/tidy-pumas-repeat.md @@ -0,0 +1,5 @@ +--- +"format-temporal": patch +--- + +Republish to fix an issue with the previous release. No functional changes. From 07759da8f0491016f09c1cceed8044f317226d16 Mon Sep 17 00:00:00 2001 From: Ian Macalinao Date: Tue, 28 Jul 2026 17:40:39 +0800 Subject: [PATCH 3/3] Revert "feat(orpc-temporal): add oRPC custom JSON serializers for Temporal types" This reverts commit 307aa97. Dropping the custom RPC serializer approach in favour of the direction taken in #70: Temporal values stay plain ISO strings on the wire, with temporal-zod validators reviving them on parse. Only the format-temporal republish changeset remains on this branch. --- .changeset/olive-donuts-shave.md | 7 - README.md | 1 - bun.lock | 62 +-- packages/orpc-temporal/.gitignore | 1 - packages/orpc-temporal/README.md | 77 ---- packages/orpc-temporal/package.json | 55 --- packages/orpc-temporal/src/end-to-end.test.ts | 114 ----- packages/orpc-temporal/src/index.ts | 22 - .../src/temporal-rpc-serializers.test.ts | 392 ------------------ .../src/temporal-rpc-serializers.ts | 121 ------ packages/orpc-temporal/tsconfig.json | 6 - 11 files changed, 3 insertions(+), 855 deletions(-) delete mode 100644 .changeset/olive-donuts-shave.md delete mode 100644 packages/orpc-temporal/.gitignore delete mode 100644 packages/orpc-temporal/README.md delete mode 100644 packages/orpc-temporal/package.json delete mode 100644 packages/orpc-temporal/src/end-to-end.test.ts delete mode 100644 packages/orpc-temporal/src/index.ts delete mode 100644 packages/orpc-temporal/src/temporal-rpc-serializers.test.ts delete mode 100644 packages/orpc-temporal/src/temporal-rpc-serializers.ts delete mode 100644 packages/orpc-temporal/tsconfig.json diff --git a/.changeset/olive-donuts-shave.md b/.changeset/olive-donuts-shave.md deleted file mode 100644 index ee68b33..0000000 --- a/.changeset/olive-donuts-shave.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"orpc-temporal": minor ---- - -Add `orpc-temporal`, providing oRPC custom JSON serializers for all eight Temporal types. - -Without them, oRPC sends Temporal values as plain ISO strings and never rebuilds them on the receiving end, so a value arrives as a `string` even though its declared type says otherwise. Pass `temporalRPCSerializers` to both the server handler and the client link to round-trip `Instant`, `ZonedDateTime`, `PlainDate`, `PlainTime`, `PlainDateTime`, `PlainYearMonth`, `PlainMonthDay`, and `Duration`, preserving time zones, non-ISO calendars, nanosecond precision, and negative durations. diff --git a/README.md b/README.md index 520d71f..2e572b5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ For more detailed documentation, see the [API docs](https://temporal.ianm.com). | :---------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------- | | [format-temporal](packages/format-temporal) | [![npm](https://img.shields.io/npm/v/format-temporal.svg?logo=npm&label=)](https://www.npmjs.com/package/format-temporal) | [![downloads](https://img.shields.io/npm/dm/format-temporal.svg?label=)](https://www.npmjs.com/package/format-temporal) | Format Temporal objects as localized strings. | | [interval-temporal](packages/interval-temporal) | [![npm](https://img.shields.io/npm/v/interval-temporal.svg?logo=npm&label=)](https://www.npmjs.com/package/interval-temporal) | [![downloads](https://img.shields.io/npm/dm/interval-temporal.svg?label=)](https://www.npmjs.com/package/interval-temporal) | An interval type for Temporal, plus helpers for comparing and sorting Temporal objects. | -| [orpc-temporal](packages/orpc-temporal) | [![npm](https://img.shields.io/npm/v/orpc-temporal.svg?logo=npm&label=)](https://www.npmjs.com/package/orpc-temporal) | [![downloads](https://img.shields.io/npm/dm/orpc-temporal.svg?label=)](https://www.npmjs.com/package/orpc-temporal) | oRPC custom JSON serializers for Temporal types. | | [parse-temporal](packages/parse-temporal) | [![npm](https://img.shields.io/npm/v/parse-temporal.svg?logo=npm&label=)](https://www.npmjs.com/package/parse-temporal) | [![downloads](https://img.shields.io/npm/dm/parse-temporal.svg?label=)](https://www.npmjs.com/package/parse-temporal) | Parse Temporal objects from strings. | | [superjson-temporal](packages/superjson-temporal) | [![npm](https://img.shields.io/npm/v/superjson-temporal.svg?logo=npm&label=)](https://www.npmjs.com/package/superjson-temporal) | [![downloads](https://img.shields.io/npm/dm/superjson-temporal.svg?label=)](https://www.npmjs.com/package/superjson-temporal) | SuperJSON serializers/deserializers for Temporal types. | | [temporal-quarter-fns](packages/temporal-quarter-fns) | [![npm](https://img.shields.io/npm/v/temporal-quarter-fns.svg?logo=npm&label=)](https://www.npmjs.com/package/temporal-quarter-fns) | [![downloads](https://img.shields.io/npm/dm/temporal-quarter-fns.svg?label=)](https://www.npmjs.com/package/temporal-quarter-fns) | Functions for working with quarters in Temporal. | diff --git a/bun.lock b/bun.lock index 80dc82f..8cf1065 100644 --- a/bun.lock +++ b/bun.lock @@ -1,16 +1,15 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "monorepo", "devDependencies": { - "@changesets/cli": "^2.31.1", + "@changesets/cli": "^2.31.0", "oxfmt": "0.55.0", "oxlint": "1.69.0", "oxlint-tsgolint": "0.23.0", - "turbo": "^2.10.5", - "typedoc": "^0.28.20", + "turbo": "^2.10.0", + "typedoc": "^0.28.19", "typescript": "^5.9.3", }, }, @@ -47,25 +46,6 @@ "temporal-polyfill": "^0.2 || ^0.3", }, }, - "packages/orpc-temporal": { - "name": "orpc-temporal", - "version": "0.0.0", - "dependencies": { - "tslib": "^2.8.1", - }, - "devDependencies": { - "@macalinao/tsconfig": "^3.2.5", - "@orpc/client": "^1.14.12", - "@orpc/server": "^1.14.12", - "@types/bun": "latest", - "temporal-polyfill": "^0.3.0", - "typescript": "^5.9.3", - }, - "peerDependencies": { - "@orpc/client": "^1.14.0", - "temporal-polyfill": "^0.2 || ^0.3", - }, - }, "packages/parse-temporal": { "name": "parse-temporal", "version": "0.5.4", @@ -193,28 +173,6 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@orpc/client": ["@orpc/client@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-fetch": "1.14.12", "@orpc/standard-server-peer": "1.14.12" } }, "sha512-XMNjdNctSLLSaCmmWV/c4klMXU9xT0KfTFbC6lUwKNk8N5hPcue7EeP5EVVSV3cSquliRbyUbLovYqaVpuOt9Q=="], - - "@orpc/contract": ["@orpc/contract@1.14.12", "", { "dependencies": { "@orpc/client": "1.14.12", "@orpc/shared": "1.14.12", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-+rf66G4tV074uEab0lkEy9kMgt2QA7FU8NfeCSt6kyYa1eKU5C6goz4OpvNcOFfk+eTGd5K01oVUiz2LSRfYrQ=="], - - "@orpc/interop": ["@orpc/interop@1.14.12", "", {}, "sha512-NdJTDW9JfBVrY7kv6wQHLT4avDlrCucakfCHPS8OI7pUPjUCa41yWnreIdIskKTgj9iBdbjFXOe6TVRQv/D6Iw=="], - - "@orpc/server": ["@orpc/server@1.14.12", "", { "dependencies": { "@orpc/client": "1.14.12", "@orpc/contract": "1.14.12", "@orpc/interop": "1.14.12", "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-aws-lambda": "1.14.12", "@orpc/standard-server-fastify": "1.14.12", "@orpc/standard-server-fetch": "1.14.12", "@orpc/standard-server-node": "1.14.12", "@orpc/standard-server-peer": "1.14.12", "cookie": "^1.1.1" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-4uHxhkiA8KHVHaa4WwUtY08q0Qs8fcKVngJypbT20Joa/yifWoYufqtv2/sw9nwkuMAOL+kLGEI5EvZMkCXScg=="], - - "@orpc/shared": ["@orpc/shared@1.14.12", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-FHTFSEJoCcZIaJWSNr+FxRDrSO5yCYdf+6QRVM9EIKQqJJVkSjJP5AJNf9bd1Nm3jdDQrhsIBQq84HOJMfd0gg=="], - - "@orpc/standard-server": ["@orpc/standard-server@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12" } }, "sha512-9+XGxpmebDJxzneBS+K6f3EWaE5p2iYCBdMKnTRH6oU4TdJTw048DadZbfsg1dcf+FChRmkzlHhXXnqgdW2K6Q=="], - - "@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-fetch": "1.14.12", "@orpc/standard-server-node": "1.14.12" } }, "sha512-iJDzFKdsv7r83apCEm2HMWaZKOax6TCyAV31TT6p+AGau5GmayZ7yDMgoUZeADFp0KeHBZGWMNkQUnf/bIEbhw=="], - - "@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-node": "1.14.12" }, "peerDependencies": { "fastify": ">=5.6.1" }, "optionalPeers": ["fastify"] }, "sha512-C6QirJkLawEsATwEj9QX0MYKPcUZvBx+Gv+D2uS61T8aEKx1mccVtnl/2taP162yU5rt1LtJAkuisPPxSn37xw=="], - - "@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12" } }, "sha512-4VQaWHlCoROqtQzXVv1pt1jUs5c3r6OwO4h+545vT6dZoaiOoFEUimhYqSGSlt6MClJTQiuQotrkeb4NrLlniw=="], - - "@orpc/standard-server-node": ["@orpc/standard-server-node@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-fetch": "1.14.12" } }, "sha512-/BfQUiL7f3aY+fMcka3IHgnov0YgDz4dQhBJ5Ku3p/nOg5iH+OLVwbQKYfC1et2uY4EBSWyNlKGHAlzwWKMR6A=="], - - "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12" } }, "sha512-i3L1jKIL4GV6j3mi08cLE5aXxPIWk/vGHvFwN5JYjBKTltFv5MORWYqwzH1Qx9kXg9dvr73fyNm6lPlvBqoxDQ=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.55.0", "", { "os": "android", "cpu": "arm" }, "sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.55.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw=="], @@ -313,8 +271,6 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.10.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ENvPwy3x5yS7MwNYHeWjqOBXkwIMp39Pd+/zXC6PoiNzF8EIvvLZOZZ+ny6L9x4WgS5vxUii2LM5gM+zjPdnWw=="], "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rqROo9zsF/P9RqsdtbLD1nFJicjSrYyvQ9kNJC38AbxA3pAs6VAlATvtvOFx7bqOv6vicf20SP9kF33avJjy2w=="], @@ -359,8 +315,6 @@ "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -447,10 +401,6 @@ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], - "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], - - "orpc-temporal": ["orpc-temporal@workspace:packages/orpc-temporal"], - "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "oxfmt": ["oxfmt@0.55.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.55.0", "@oxfmt/binding-android-arm64": "0.55.0", "@oxfmt/binding-darwin-arm64": "0.55.0", "@oxfmt/binding-darwin-x64": "0.55.0", "@oxfmt/binding-freebsd-x64": "0.55.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.55.0", "@oxfmt/binding-linux-arm-musleabihf": "0.55.0", "@oxfmt/binding-linux-arm64-gnu": "0.55.0", "@oxfmt/binding-linux-arm64-musl": "0.55.0", "@oxfmt/binding-linux-ppc64-gnu": "0.55.0", "@oxfmt/binding-linux-riscv64-gnu": "0.55.0", "@oxfmt/binding-linux-riscv64-musl": "0.55.0", "@oxfmt/binding-linux-s390x-gnu": "0.55.0", "@oxfmt/binding-linux-x64-gnu": "0.55.0", "@oxfmt/binding-linux-x64-musl": "0.55.0", "@oxfmt/binding-openharmony-arm64": "0.55.0", "@oxfmt/binding-win32-arm64-msvc": "0.55.0", "@oxfmt/binding-win32-ia32-msvc": "0.55.0", "@oxfmt/binding-win32-x64-msvc": "0.55.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A=="], @@ -493,8 +443,6 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], - "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -529,8 +477,6 @@ "superjson-temporal": ["superjson-temporal@workspace:packages/superjson-temporal"], - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - "temporal-polyfill": ["temporal-polyfill@0.3.0", "", { "dependencies": { "temporal-spec": "0.3.0" } }, "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g=="], "temporal-quarter-fns": ["temporal-quarter-fns@workspace:packages/temporal-quarter-fns"], @@ -549,8 +495,6 @@ "turbo": ["turbo@2.10.5", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.5", "@turbo/darwin-arm64": "2.10.5", "@turbo/linux-64": "2.10.5", "@turbo/linux-arm64": "2.10.5", "@turbo/windows-64": "2.10.5", "@turbo/windows-arm64": "2.10.5" }, "bin": { "turbo": "bin/turbo" } }, "sha512-07Y/C7OUp23l4P92PJoYtFNbHjLhftrZH5Ce7dbczS4kX2Re+wtbXvZLoxn/pUtzgsQaRCBaRuZPJp4zmAn0WQ=="], - "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], - "typedoc": ["typedoc@0.28.20", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.3.0", "minimatch": "^10.2.5", "yaml": "^2.9.0" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/packages/orpc-temporal/.gitignore b/packages/orpc-temporal/.gitignore deleted file mode 100644 index 9b1c8b1..0000000 --- a/packages/orpc-temporal/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/dist diff --git a/packages/orpc-temporal/README.md b/packages/orpc-temporal/README.md deleted file mode 100644 index 4000ccf..0000000 --- a/packages/orpc-temporal/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# orpc-temporal - -NPM version - -[oRPC](https://orpc.unnoq.com/) custom JSON serializers for Temporal types. - -This depends on the [temporal-polyfill](https://www.npmjs.com/package/temporal-polyfill) package. - -## Why - -oRPC has no built-in support for Temporal. Without these serializers, a Temporal -value is stringified by its `toJSON()` during JSON encoding and nothing rebuilds -it on the other end, so it arrives as a plain `string`: - -```typescript -const result = await client.getEvent(); -result.startsAt instanceof Temporal.ZonedDateTime; // false — it's a string -result.startsAt.add({ hours: 1 }); // TypeError at runtime -``` - -No error is raised at the boundary, and TypeScript still reports the value as a -Temporal type, so the mismatch surfaces later as a confusing runtime failure. - -## Usage - -Pass the same serializers to both the server handler and the client link. - -```typescript -import { RPCHandler } from "@orpc/server/fetch"; -import { RPCLink } from "@orpc/client/fetch"; -import { temporalRPCSerializers } from "orpc-temporal"; - -const handler = new RPCHandler(router, { - customJsonSerializers: temporalRPCSerializers, -}); - -const link = new RPCLink({ - url: "https://example.com/rpc", - customJsonSerializers: temporalRPCSerializers, -}); -``` - -All eight Temporal types are covered: - -| Type | Wire format | -| :------------------------ | :----------------------------------------- | -| `Temporal.Instant` | `2022-01-28T18:53:00.123456789Z` | -| `Temporal.ZonedDateTime` | `2022-01-28T19:53:00+01:00[Europe/Berlin]` | -| `Temporal.PlainDate` | `2024-02-29` | -| `Temporal.PlainTime` | `01:02:03.123456789` | -| `Temporal.PlainDateTime` | `2024-02-29T01:02:03` | -| `Temporal.PlainYearMonth` | `2024-02` | -| `Temporal.PlainMonthDay` | `02-29` | -| `Temporal.Duration` | `-P1Y2M3DT4H5M6.789S` | - -Values are serialized with `toJSON()` and restored with `from()`, so time zones, -non-ISO calendar annotations (`[u-ca=hebrew]`), nanosecond precision, and -negative durations all survive the round trip. - -## Type IDs - -oRPC identifies each custom serializer by a numeric type ID that is part of the -wire format. Its built-in serializers reserve `0`–`7`, so this package uses -`1000`–`1007` to stay clear of them and of any built-ins oRPC adds later. - -If that range collides with your own custom serializers, move it — but both ends -of the connection must use the same base: - -```typescript -import { createTemporalRPCSerializers } from "orpc-temporal"; - -const serializers = createTemporalRPCSerializers({ baseType: 5000 }); -``` - -## License - -Apache-2.0 diff --git a/packages/orpc-temporal/package.json b/packages/orpc-temporal/package.json deleted file mode 100644 index f948301..0000000 --- a/packages/orpc-temporal/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "orpc-temporal", - "version": "0.0.0", - "description": "oRPC custom JSON serializers for Temporal types.", - "keywords": [ - "orpc", - "temporal" - ], - "homepage": "https://github.com/macalinao/temporal-utils", - "bugs": { - "url": "https://github.com/macalinao/temporal-utils/issues" - }, - "license": "Apache-2.0", - "author": "Ian Macalinao ", - "repository": "github:macalinao/temporal-utils", - "files": [ - "dist/", - "src/", - "README.md" - ], - "type": "module", - "sideEffects": false, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, - "scripts": { - "build": "tsc", - "clean": "tsc --build --clean && rm -fr dist/", - "test": "bun test src/" - }, - "dependencies": { - "tslib": "^2.8.1" - }, - "devDependencies": { - "@macalinao/tsconfig": "^3.2.5", - "@orpc/client": "^1.14.12", - "@orpc/server": "^1.14.12", - "@types/bun": "latest", - "temporal-polyfill": "^0.3.0", - "typescript": "^5.9.3" - }, - "peerDependencies": { - "@orpc/client": "^1.14.0", - "temporal-polyfill": "^0.2 || ^0.3" - } -} diff --git a/packages/orpc-temporal/src/end-to-end.test.ts b/packages/orpc-temporal/src/end-to-end.test.ts deleted file mode 100644 index a1bf165..0000000 --- a/packages/orpc-temporal/src/end-to-end.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { RouterClient } from "@orpc/server"; -import { beforeAll, describe, expect, test } from "bun:test"; -import { createORPCClient } from "@orpc/client"; -import { RPCLink } from "@orpc/client/fetch"; -import { os } from "@orpc/server"; -import { RPCHandler } from "@orpc/server/fetch"; -import { Temporal } from "temporal-polyfill"; -import { temporalRPCSerializers } from "./temporal-rpc-serializers.js"; - -interface Payload { - instant: Temporal.Instant; - zonedDateTime: Temporal.ZonedDateTime; - plainDate: Temporal.PlainDate; - plainTime: Temporal.PlainTime; - plainDateTime: Temporal.PlainDateTime; - plainYearMonth: Temporal.PlainYearMonth; - plainMonthDay: Temporal.PlainMonthDay; - duration: Temporal.Duration; -} - -const router = { - /** Returns its input unchanged, exercising both request and response paths. */ - echo: os.handler(({ input }) => input as Payload), - /** Proves the server received real Temporal instances, not strings. */ - addDay: os.handler(({ input }) => - (input as Temporal.PlainDate).add({ days: 1 }), - ), -}; - -type Client = RouterClient; - -/** Wires an RPCLink straight into an RPCHandler, skipping the network. */ -const createClient = (): Client => { - const handler = new RPCHandler(router, { - customJsonSerializers: temporalRPCSerializers, - }); - - const link = new RPCLink({ - url: "http://localhost/rpc", - customJsonSerializers: temporalRPCSerializers, - fetch: async (request) => { - const { matched, response } = await handler.handle(request, { - prefix: "/rpc", - }); - if (!matched) { - throw new Error("no procedure matched"); - } - return response; - }, - }); - - return createORPCClient(link); -}; - -describe("end-to-end over RPCHandler and RPCLink", () => { - let client: Client; - let sent: Payload; - - beforeAll(() => { - client = createClient(); - const zoned = Temporal.ZonedDateTime.from( - "2022-01-28T19:53+01:00[Europe/Berlin]", - ); - sent = { - instant: Temporal.Instant.from("2022-01-28T18:53:00.123456789Z"), - zonedDateTime: zoned, - plainDate: Temporal.PlainDate.from("2024-02-29"), - plainTime: Temporal.PlainTime.from("01:02:03.123456789"), - plainDateTime: Temporal.PlainDateTime.from("2024-02-29T01:02:03.000009"), - plainYearMonth: Temporal.PlainYearMonth.from("2024-02"), - plainMonthDay: Temporal.PlainMonthDay.from("02-29"), - duration: Temporal.Duration.from("-P1Y2M3DT4H5M6.789S"), - }; - }); - - test("every Temporal type survives a real request and response", async () => { - const received = await client.echo(sent); - - expect(received.instant).toBeInstanceOf(Temporal.Instant); - expect(received.zonedDateTime).toBeInstanceOf(Temporal.ZonedDateTime); - expect(received.plainDate).toBeInstanceOf(Temporal.PlainDate); - expect(received.plainTime).toBeInstanceOf(Temporal.PlainTime); - expect(received.plainDateTime).toBeInstanceOf(Temporal.PlainDateTime); - expect(received.plainYearMonth).toBeInstanceOf(Temporal.PlainYearMonth); - expect(received.plainMonthDay).toBeInstanceOf(Temporal.PlainMonthDay); - expect(received.duration).toBeInstanceOf(Temporal.Duration); - - expect(received.instant.equals(sent.instant)).toBe(true); - expect(received.zonedDateTime.equals(sent.zonedDateTime)).toBe(true); - expect(received.zonedDateTime.timeZoneId).toEqual("Europe/Berlin"); - expect(received.plainDate.equals(sent.plainDate)).toBe(true); - expect(received.plainTime.equals(sent.plainTime)).toBe(true); - expect(received.plainDateTime.equals(sent.plainDateTime)).toBe(true); - expect(received.plainYearMonth.equals(sent.plainYearMonth)).toBe(true); - expect(received.plainMonthDay.equals(sent.plainMonthDay)).toBe(true); - expect(received.duration.toString()).toEqual(sent.duration.toString()); - }); - - test("the server operates on a real Temporal instance", async () => { - const result = await client.addDay(Temporal.PlainDate.from("2024-02-28")); - - expect(result).toBeInstanceOf(Temporal.PlainDate); - expect(result.toString()).toEqual("2024-02-29"); - }); - - test("a non-ISO calendar survives the trip", async () => { - const received = await client.echo({ - ...sent, - plainDate: Temporal.PlainDate.from("2024-02-29").withCalendar("hebrew"), - }); - - expect(received.plainDate.calendarId).toEqual("hebrew"); - }); -}); diff --git a/packages/orpc-temporal/src/index.ts b/packages/orpc-temporal/src/index.ts deleted file mode 100644 index a402d40..0000000 --- a/packages/orpc-temporal/src/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * oRPC custom JSON serializers for Temporal types. - * - * @example - * ```typescript - * import { temporalRPCSerializers } from "orpc-temporal"; - * - * const handler = new RPCHandler(router, { - * customJsonSerializers: temporalRPCSerializers, - * }); - * ``` - * - * @module - * @see {@link https://github.com/macalinao/temporal-utils/tree/master/packages/orpc-temporal | orpc-temporal on GitHub} - */ -export type { CreateTemporalRPCSerializersOptions } from "./temporal-rpc-serializers.js"; -export { - createTemporalRPCSerializers, - DEFAULT_TEMPORAL_RPC_SERIALIZER_BASE_TYPE, - TEMPORAL_RPC_SERIALIZER_TYPE_OFFSETS, - temporalRPCSerializers, -} from "./temporal-rpc-serializers.js"; diff --git a/packages/orpc-temporal/src/temporal-rpc-serializers.test.ts b/packages/orpc-temporal/src/temporal-rpc-serializers.test.ts deleted file mode 100644 index 6dc4a81..0000000 --- a/packages/orpc-temporal/src/temporal-rpc-serializers.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - StandardRPCJsonSerializer, - StandardRPCSerializer, -} from "@orpc/client/standard"; -import { Temporal } from "temporal-polyfill"; -import { - createTemporalRPCSerializers, - DEFAULT_TEMPORAL_RPC_SERIALIZER_BASE_TYPE, - TEMPORAL_RPC_SERIALIZER_TYPE_OFFSETS, - temporalRPCSerializers, -} from "./temporal-rpc-serializers.js"; - -const serializer = new StandardRPCSerializer( - new StandardRPCJsonSerializer({ - customJsonSerializers: temporalRPCSerializers, - }), -); - -/** - * Sends a value through the full oRPC wire cycle: serialize, encode as JSON - * text, decode, deserialize. The `JSON.parse(JSON.stringify(...))` step is what - * makes this a real round trip rather than an in-memory pass-through. - */ -const roundTrip = (value: T): T => { - const wire = JSON.parse( - JSON.stringify(serializer.serialize(value)), - ) as unknown; - return serializer.deserialize(wire) as T; -}; - -describe("temporalRPCSerializers", () => { - describe("round-trips every Temporal type", () => { - const zoned = Temporal.ZonedDateTime.from( - "2022-01-28T19:53+01:00[Europe/Berlin]", - ); - - const cases = { - instant: zoned.toInstant(), - zonedDateTime: zoned, - plainDate: zoned.toPlainDate(), - plainTime: zoned.toPlainTime(), - plainDateTime: zoned.toPlainDateTime(), - plainYearMonth: zoned.toPlainDate().toPlainYearMonth(), - plainMonthDay: zoned.toPlainDate().toPlainMonthDay(), - duration: Temporal.Duration.from("P1Y2M3DT4H5M6.789S"), - }; - - for (const [name, value] of Object.entries(cases)) { - test(name, () => { - const result = roundTrip(value); - - expect(result).toBeInstanceOf( - (value as object).constructor as new () => unknown, - ); - expect(result.toString()).toEqual(value.toString()); - }); - } - - test("all eight types survive a single nested payload", () => { - const result = roundTrip({ nested: { deep: cases }, list: [cases] }); - - expect(result.nested.deep.instant).toBeInstanceOf(Temporal.Instant); - expect(result.list[0]?.plainDate).toBeInstanceOf(Temporal.PlainDate); - expect(result.nested.deep.zonedDateTime.equals(zoned)).toBe(true); - expect(result.list[0]?.duration.toString()).toEqual( - cases.duration.toString(), - ); - }); - }); - - describe("preserves time zone and calendar", () => { - test("ZonedDateTime keeps its time zone rather than collapsing to an offset", () => { - const value = Temporal.ZonedDateTime.from( - "2022-01-28T19:53+01:00[Europe/Berlin]", - ); - const result = roundTrip(value); - - expect(result.timeZoneId).toEqual("Europe/Berlin"); - expect(result.equals(value)).toBe(true); - }); - - test("ZonedDateTime keeps a non-ISO calendar", () => { - const value = Temporal.ZonedDateTime.from( - "2022-01-28T19:53+01:00[Europe/Berlin][u-ca=hebrew]", - ); - const result = roundTrip(value); - - expect(result.calendarId).toEqual("hebrew"); - expect(result.timeZoneId).toEqual("Europe/Berlin"); - expect(result.equals(value)).toBe(true); - }); - - test("ZonedDateTime survives a DST fall-back instant", () => { - // 2:30am occurs twice on this date in New York; the offset in the string - // is what disambiguates it. - const value = Temporal.ZonedDateTime.from( - "2023-11-05T01:30:00-05:00[America/New_York]", - ); - const result = roundTrip(value); - - expect(result.offset).toEqual("-05:00"); - expect(result.epochNanoseconds).toEqual(value.epochNanoseconds); - }); - - test.each([ - ["hebrew"], - ["japanese"], - ["islamic-umalqura"], - ["chinese"], - ] as const)("PlainDate keeps the %s calendar", (calendar) => { - const value = - Temporal.PlainDate.from("2024-02-29").withCalendar(calendar); - const result = roundTrip(value); - - expect(result.calendarId).toEqual(calendar); - expect(result.equals(value)).toBe(true); - }); - - test("PlainDateTime keeps a non-ISO calendar", () => { - const value = Temporal.PlainDateTime.from( - "2024-02-29T01:02:03", - ).withCalendar("hebrew"); - const result = roundTrip(value); - - expect(result.calendarId).toEqual("hebrew"); - expect(result.equals(value)).toBe(true); - }); - - test("PlainYearMonth keeps a non-ISO calendar", () => { - const value = Temporal.PlainDate.from("2024-02-29") - .withCalendar("hebrew") - .toPlainYearMonth(); - const result = roundTrip(value); - - expect(result.calendarId).toEqual("hebrew"); - expect(result.equals(value)).toBe(true); - }); - - test("PlainMonthDay keeps a non-ISO calendar", () => { - const value = Temporal.PlainDate.from("2024-02-29") - .withCalendar("hebrew") - .toPlainMonthDay(); - const result = roundTrip(value); - - expect(result.calendarId).toEqual("hebrew"); - expect(result.equals(value)).toBe(true); - }); - }); - - describe("preserves sub-second precision", () => { - test("Instant keeps nanoseconds", () => { - const value = Temporal.Instant.from("2022-01-28T18:53:00.123456789Z"); - const result = roundTrip(value); - - expect(result.epochNanoseconds).toEqual(value.epochNanoseconds); - expect(result.equals(value)).toBe(true); - }); - - test("PlainTime keeps nanoseconds", () => { - const value = Temporal.PlainTime.from("01:02:03.123456789"); - const result = roundTrip(value); - - expect(result.millisecond).toEqual(123); - expect(result.microsecond).toEqual(456); - expect(result.nanosecond).toEqual(789); - expect(result.equals(value)).toBe(true); - }); - - test("PlainDateTime keeps nanoseconds", () => { - const value = Temporal.PlainDateTime.from( - "2024-02-29T01:02:03.123456789", - ); - const result = roundTrip(value); - - expect(result.nanosecond).toEqual(789); - expect(result.equals(value)).toBe(true); - }); - - test("Duration keeps a single nanosecond", () => { - const value = Temporal.Duration.from({ nanoseconds: 1 }); - const result = roundTrip(value); - - expect(result.nanoseconds).toEqual(1); - expect(result.total("nanoseconds")).toEqual(1); - }); - }); - - describe("Duration edge cases", () => { - test("negative duration keeps its sign", () => { - const value = Temporal.Duration.from("-P1Y2M3DT4H5M6.789S"); - const result = roundTrip(value); - - expect(result.sign).toEqual(-1); - expect(result.toString()).toEqual(value.toString()); - expect(Temporal.Duration.compare(result, value)).toEqual(0); - }); - - test("negative duration keeps each unit negative", () => { - const value = Temporal.Duration.from({ hours: -4, minutes: -5 }); - const result = roundTrip(value); - - expect(result.hours).toEqual(-4); - expect(result.minutes).toEqual(-5); - }); - - test("zero duration round-trips", () => { - const value = Temporal.Duration.from({ seconds: 0 }); - const result = roundTrip(value); - - expect(result.sign).toEqual(0); - expect(result.toString()).toEqual("PT0S"); - }); - - test("unbalanced units are not silently normalized", () => { - // P1M and P30D are different durations; the wire format must not - // collapse one into the other. - const value = Temporal.Duration.from({ months: 1 }); - const result = roundTrip(value); - - expect(result.months).toEqual(1); - expect(result.days).toEqual(0); - }); - - test("weeks are preserved separately from days", () => { - const value = Temporal.Duration.from({ weeks: 2 }); - const result = roundTrip(value); - - expect(result.weeks).toEqual(2); - expect(result.days).toEqual(0); - }); - - test("large duration round-trips", () => { - const value = Temporal.Duration.from({ hours: 1_000_000 }); - const result = roundTrip(value); - - expect(result.hours).toEqual(1_000_000); - }); - }); - - describe("edge case dates", () => { - test("leap day round-trips", () => { - const value = Temporal.PlainDate.from("2024-02-29"); - expect(roundTrip(value).equals(value)).toBe(true); - }); - - test("negative (BCE) year round-trips", () => { - const value = Temporal.PlainDate.from("-000753-04-21"); - const result = roundTrip(value); - - expect(result.year).toEqual(-753); - expect(result.equals(value)).toBe(true); - }); - - test("midnight PlainTime round-trips", () => { - const value = Temporal.PlainTime.from("00:00:00"); - expect(roundTrip(value).equals(value)).toBe(true); - }); - - test("Instant before the epoch round-trips", () => { - const value = Temporal.Instant.from("1900-01-01T00:00:00Z"); - expect(roundTrip(value).equals(value)).toBe(true); - }); - }); - - describe("coexists with oRPC built-in types", () => { - test("built-ins still work alongside Temporal values", () => { - const value = { - plainDate: Temporal.PlainDate.from("2024-02-29"), - date: new Date("2024-02-29T00:00:00.000Z"), - big: 9_007_199_254_740_993n, - set: new Set([1, 2]), - map: new Map([["a", 1]]), - url: new URL("https://example.com/"), - regexp: /abc/giu, - undef: undefined, - nan: Number.NaN, - }; - const result = roundTrip(value); - - expect(result.plainDate).toBeInstanceOf(Temporal.PlainDate); - expect(result.date).toBeInstanceOf(Date); - expect(result.date.getTime()).toEqual(value.date.getTime()); - expect(result.big).toEqual(value.big); - expect(result.set).toEqual(value.set); - expect(result.map).toEqual(value.map); - expect(result.url.toString()).toEqual(value.url.toString()); - expect(result.regexp.source).toEqual(value.regexp.source); - expect(result.undef).toBeUndefined(); - expect(result.nan).toBeNaN(); - }); - - test("a Date is not captured by the Temporal serializers", () => { - const result = roundTrip({ date: new Date(0) }); - - expect(result.date).toBeInstanceOf(Date); - }); - }); - - describe("type IDs", () => { - test("are unique", () => { - const types = temporalRPCSerializers.map((s) => s.type); - - expect(new Set(types).size).toEqual(types.length); - }); - - test("cover all eight Temporal types", () => { - expect(temporalRPCSerializers).toHaveLength(8); - expect(Object.keys(TEMPORAL_RPC_SERIALIZER_TYPE_OFFSETS)).toHaveLength(8); - }); - - test("do not collide with oRPC's reserved built-in range of 0-7", () => { - for (const { type } of temporalRPCSerializers) { - expect(type).toBeGreaterThan(7); - } - }); - - test("are stable, since they are part of the wire format", () => { - const base = DEFAULT_TEMPORAL_RPC_SERIALIZER_BASE_TYPE; - - expect(base).toEqual(1000); - expect(temporalRPCSerializers.map((s) => s.type)).toEqual([ - base, - base + 1, - base + 2, - base + 3, - base + 4, - base + 5, - base + 6, - base + 7, - ]); - }); - - test("oRPC accepts the serializers without complaining about duplicates", () => { - expect( - () => - new StandardRPCJsonSerializer({ - customJsonSerializers: temporalRPCSerializers, - }), - ).not.toThrow(); - }); - }); - - describe("createTemporalRPCSerializers", () => { - test("defaults to the standard base type", () => { - expect(createTemporalRPCSerializers().map((s) => s.type)).toEqual( - temporalRPCSerializers.map((s) => s.type), - ); - }); - - test("shifts every type ID by a custom base", () => { - const shifted = createTemporalRPCSerializers({ baseType: 5000 }); - - expect(shifted.map((s) => s.type)).toEqual([ - 5000, 5001, 5002, 5003, 5004, 5005, 5006, 5007, - ]); - }); - - test("a custom base still round-trips when both ends agree", () => { - const custom = new StandardRPCSerializer( - new StandardRPCJsonSerializer({ - customJsonSerializers: createTemporalRPCSerializers({ - baseType: 5000, - }), - }), - ); - const value = Temporal.PlainDate.from("2024-02-29"); - const wire = JSON.parse( - JSON.stringify(custom.serialize(value)), - ) as unknown; - const result = custom.deserialize(wire); - - expect(result).toBeInstanceOf(Temporal.PlainDate); - expect((result as Temporal.PlainDate).equals(value)).toBe(true); - }); - }); - - describe("without the serializers", () => { - test("Temporal values silently arrive as strings", () => { - // This is the gap the package closes: oRPC has no Temporal support of its - // own, so `toJSON()` runs during JSON encoding and nothing rebuilds the - // instance. No error is thrown — the value just has the wrong type. - const bare = new StandardRPCSerializer(new StandardRPCJsonSerializer()); - const value = { plainDate: Temporal.PlainDate.from("2024-02-29") }; - const wire = JSON.parse(JSON.stringify(bare.serialize(value))) as unknown; - const result = bare.deserialize(wire) as { plainDate: unknown }; - - expect(typeof result.plainDate).toEqual("string"); - expect(result.plainDate).not.toBeInstanceOf(Temporal.PlainDate); - }); - }); -}); diff --git a/packages/orpc-temporal/src/temporal-rpc-serializers.ts b/packages/orpc-temporal/src/temporal-rpc-serializers.ts deleted file mode 100644 index 36bc180..0000000 --- a/packages/orpc-temporal/src/temporal-rpc-serializers.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { StandardRPCCustomJsonSerializer } from "@orpc/client/standard"; -import { Temporal } from "temporal-polyfill"; - -/** - * Every Temporal class serialized by this package exposes `toJSON()` and a - * static `from()` that accepts the string it produces. - */ -interface TemporalConstructor string }> { - new (...args: never[]): TInstance; - from: (value: string) => TInstance; -} - -/** - * oRPC reserves type IDs `0`–`7` for its built-in serializers (bigint, Date, - * NaN, undefined, URL, RegExp, Set, Map). Temporal IDs start well above that - * range so they keep working if oRPC adds more built-ins. - */ -export const DEFAULT_TEMPORAL_RPC_SERIALIZER_BASE_TYPE = 1000; - -/** - * Offset of each Temporal type from the base type ID. - * - * These offsets are part of the wire format: a client and a server must agree - * on them, so they are stable across releases. Only the base type ID is - * configurable — see {@link createTemporalRPCSerializers}. - */ -export const TEMPORAL_RPC_SERIALIZER_TYPE_OFFSETS = { - INSTANT: 0, - ZONED_DATE_TIME: 1, - PLAIN_DATE: 2, - PLAIN_TIME: 3, - PLAIN_DATE_TIME: 4, - PLAIN_YEAR_MONTH: 5, - PLAIN_MONTH_DAY: 6, - DURATION: 7, -} as const; - -/** - * Options for {@link createTemporalRPCSerializers}. - */ -export interface CreateTemporalRPCSerializersOptions { - /** - * First type ID to assign. Each Temporal type occupies one ID starting here, - * so this reserves the range `baseType` through `baseType + 7`. - * - * Override this only if the default range collides with your own custom - * serializers. Both ends of the connection must use the same value. - * - * @defaultValue {@link DEFAULT_TEMPORAL_RPC_SERIALIZER_BASE_TYPE} - */ - baseType?: number; -} - -const serializerFor = string }>( - type: number, - cls: TemporalConstructor, -): StandardRPCCustomJsonSerializer => ({ - type, - condition: (data) => data instanceof cls, - // Temporal's `toJSON()` emits an ISO 8601 string that round-trips through - // `from()` with the calendar and time zone annotations intact. - serialize: (data: TInstance) => data.toJSON(), - deserialize: (serialized: string) => cls.from(serialized), -}); - -/** - * Creates oRPC custom JSON serializers for all eight Temporal types, using a - * custom base type ID. - * - * Prefer the ready-made {@link temporalRPCSerializers} unless you need to move - * the type IDs to avoid a collision. - * - * @param options - Type ID configuration. - * @returns Serializers to pass to oRPC's `customJsonSerializers` option. - */ -export function createTemporalRPCSerializers( - options: CreateTemporalRPCSerializersOptions = {}, -): StandardRPCCustomJsonSerializer[] { - const { baseType = DEFAULT_TEMPORAL_RPC_SERIALIZER_BASE_TYPE } = options; - const offsets = TEMPORAL_RPC_SERIALIZER_TYPE_OFFSETS; - - return [ - serializerFor(baseType + offsets.INSTANT, Temporal.Instant), - serializerFor(baseType + offsets.ZONED_DATE_TIME, Temporal.ZonedDateTime), - serializerFor(baseType + offsets.PLAIN_DATE, Temporal.PlainDate), - serializerFor(baseType + offsets.PLAIN_TIME, Temporal.PlainTime), - serializerFor(baseType + offsets.PLAIN_DATE_TIME, Temporal.PlainDateTime), - serializerFor(baseType + offsets.PLAIN_YEAR_MONTH, Temporal.PlainYearMonth), - serializerFor(baseType + offsets.PLAIN_MONTH_DAY, Temporal.PlainMonthDay), - serializerFor(baseType + offsets.DURATION, Temporal.Duration), - ]; -} - -/** - * oRPC custom JSON serializers for all eight Temporal types: - * `Instant`, `ZonedDateTime`, `PlainDate`, `PlainTime`, `PlainDateTime`, - * `PlainYearMonth`, `PlainMonthDay`, and `Duration`. - * - * Pass the same serializers to both the server handler and the client link. - * Without them oRPC sends Temporal values as plain ISO strings and the - * receiving end never rebuilds them — the value arrives as a `string` even - * though its declared type says otherwise. - * - * @example - * ```typescript - * import { RPCHandler } from "@orpc/server/fetch"; - * import { RPCLink } from "@orpc/client/fetch"; - * import { temporalRPCSerializers } from "orpc-temporal"; - * - * const handler = new RPCHandler(router, { - * customJsonSerializers: temporalRPCSerializers, - * }); - * - * const link = new RPCLink({ - * url: "https://example.com/rpc", - * customJsonSerializers: temporalRPCSerializers, - * }); - * ``` - */ -export const temporalRPCSerializers: StandardRPCCustomJsonSerializer[] = - createTemporalRPCSerializers(); diff --git a/packages/orpc-temporal/tsconfig.json b/packages/orpc-temporal/tsconfig.json deleted file mode 100644 index 0094e6f..0000000 --- a/packages/orpc-temporal/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": ["@macalinao/tsconfig/tsconfig.base.json"], - "compilerOptions": { - "types": ["bun"] - } -}