save CIDs to local metadata file, incrementally download new files, copy old #1

merged
opened by nekomimi.pet targeting main from incremental-updates
Changed files
+747 -81
hosting-service
public
editor
src
+64 -35
bun.lock
··· 25 25 "elysia": "latest", 26 26 "iron-session": "^8.0.4", 27 27 "lucide-react": "^0.546.0", 28 + "multiformats": "^13.4.1", 28 29 "react": "^19.2.0", 29 30 "react-dom": "^19.2.0", 30 31 "react-shiki": "^0.9.0", ··· 44 45 }, 45 46 "trustedDependencies": [ 46 47 "core-js", 48 + "cbor-extract", 49 + "bun", 47 50 "protobufjs", 48 51 ], 49 52 "packages": { ··· 51 54 52 55 "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], 53 56 54 - "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.1.10", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-o7hGaonA71A6p7O107VhM6UBUN/g9tTyYohMp1q0Kf6xQ4npnuZYRSHSf2g6reSfGQJ1GoFNjBObETTT1ge/jQ=="], 57 + "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="], 55 58 56 59 "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.2", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "zod": "^3.23.8" } }, "sha512-KIerCzh3qb+zZoqWbIvTlvBY0XPq0r56kwViaJY/LTe/3oPO2JaqlYKS/F4dByWBhHK6YoUOJ0sWrh6PMJl40A=="], 57 60 58 - "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.20", "", { "dependencies": { "@atproto-labs/fetch-node": "0.1.10", "@atproto-labs/handle-resolver": "0.3.2", "@atproto/did": "0.2.1" } }, "sha512-094EL61XN9M7vm22cloSOxk/gcTRaCK52vN7BYgXgdoEI8uJJMTFXenQqu+LRGwiCcjvyclcBqbaz0DzJep50Q=="], 61 + "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.21", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.2", "@atproto/did": "0.2.1" } }, "sha512-fuJy5Px5pGF3lJX/ATdurbT8tbmaFWtf+PPxAQDFy7ot2no3t+iaAgymhyxYymrssOuWs6BwOP8tyF3VrfdwtQ=="], 59 62 60 63 "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.2", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/handle-resolver": "0.3.2" } }, "sha512-MYxO9pe0WsFyi5HFdKAwqIqHfiF2kBPoVhAIuH/4PYHzGr799ED47xLhNMxR3ZUYrJm5+TQzWXypGZ0Btw1Ffw=="], 61 64 ··· 65 68 66 69 "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], 67 70 68 - "@atproto/api": ["@atproto/api@0.17.3", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-pdQXhUAapNPdmN00W0vX5ta/aMkHqfgBHATt20X02XwxQpY2AnrPm2Iog4FyjsZqoHooAtCNV/NWJ4xfddJzsg=="], 71 + "@atproto/api": ["@atproto/api@0.17.7", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw=="], 69 72 70 73 "@atproto/common": ["@atproto/common@0.4.12", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@ipld/dag-cbor": "^7.0.3", "cbor-x": "^1.5.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, "sha512-NC+TULLQiqs6MvNymhQS5WDms3SlbIKGLf4n33tpftRJcalh507rI+snbcUb7TLIkKw7VO17qMqxEXtIdd5auQ=="], 71 74 ··· 81 84 82 85 "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 83 86 84 - "@atproto/lex-cli": ["@atproto/lex-cli@0.9.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "chalk": "^4.1.2", "commander": "^9.4.0", "prettier": "^3.2.5", "ts-morph": "^24.0.0", "yesno": "^0.4.0", "zod": "^3.23.8" }, "bin": { "lex": "dist/index.js" } }, "sha512-zun4jhD1dbjD7IHtLIjh/1UsMx+6E8+OyOT2GXYAKIj9N6wmLKM/v2OeQBKxcyqUmtRi57lxWnGikWjjU7pplQ=="], 87 + "@atproto/lex-cli": ["@atproto/lex-cli@0.9.6", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "chalk": "^4.1.2", "commander": "^9.4.0", "prettier": "^3.2.5", "ts-morph": "^24.0.0", "yesno": "^0.4.0", "zod": "^3.23.8" }, "bin": { "lex": "dist/index.js" } }, "sha512-EedEKmURoSP735YwSDHsFrLOhZ4P2it8goCHv5ApWi/R9DFpOKOpmYfIXJ9MAprK8cw+yBnjDJbzpLJy7UXlTg=="], 85 88 86 89 "@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="], 87 90 88 - "@atproto/oauth-client": ["@atproto/oauth-client@0.5.7", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.2", "@atproto-labs/identity-resolver": "0.3.2", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.4.2", "@atproto/xrpc": "0.7.5", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-pDvbvy9DCxrAJv7bAbBUzWrHZKhFy091HvEMZhr+EyZA6gSCGYmmQJG/coDj0oICSVQeafAZd+IxR0YUCWwmEg=="], 91 + "@atproto/oauth-client": ["@atproto/oauth-client@0.5.8", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.2", "@atproto-labs/identity-resolver": "0.3.2", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.5.0", "@atproto/xrpc": "0.7.5", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-7YEym6d97+Dd73qGdkQTXi5La8xvCQxwRUDzzlR/NVAARa9a4YP7MCmqBJVeP2anT0By+DSAPyPDLTsxcjIcCg=="], 89 92 90 - "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.9", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/handle-resolver-node": "0.1.20", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.7", "@atproto/oauth-types": "0.4.2" } }, "sha512-JdzwDQ8Gczl0lgfJNm7lG7omkJ4yu99IuGkkRWixpEvKY/jY/mDZaho+3pfd29SrUvwQOOx4Bm4l7DGeYwxxyA=="], 93 + "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.10", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/handle-resolver-node": "0.1.21", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.8", "@atproto/oauth-types": "0.5.0" } }, "sha512-6khKlJqu1Ed5rt3rzcTD5hymB6JUjKdOHWYXwiphw4inkAIo6GxLCighI4eGOqZorYk2j8ueeTNB6KsgH0kcRw=="], 91 94 92 - "@atproto/oauth-types": ["@atproto/oauth-types@0.4.2", "", { "dependencies": { "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-gcfNTyFsPJcYDf79M0iKHykWqzxloscioKoerdIN3MTS3htiNOSgZjm2p8ho7pdrElLzea3qktuhTQI39j1XFQ=="], 95 + "@atproto/oauth-types": ["@atproto/oauth-types@0.5.0", "", { "dependencies": { "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-33xz7HcXhbl+XRqbIMVu3GE02iK1nKe2oMWENASsfZEYbCz2b9ZOarOFuwi7g4LKqpGowGp0iRKsQHFcq4SDaQ=="], 93 96 94 97 "@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="], 95 98 ··· 113 116 114 117 "@elysiajs/cors": ["@elysiajs/cors@1.4.0", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-pb0SCzBfFbFSYA/U40HHO7R+YrcXBJXOWgL20eSViK33ol1e20ru2/KUaZYo5IMUn63yaTJI/bQERuQ+77ND8g=="], 115 118 116 - "@elysiajs/eden": ["@elysiajs/eden@1.4.3", "", { "peerDependencies": { "elysia": ">= 1.4.0-exp.0" } }, "sha512-mX0v5cTvTJiDsDWNEEyuoqudOvW5J+tXsvp/ZOJXJF3iCIEJI0Brvm78ymPrvwiOG4nUr3lS8BxUfbNf32DSXA=="], 119 + "@elysiajs/eden": ["@elysiajs/eden@1.4.4", "", { "peerDependencies": { "elysia": ">= 1.4.0-exp.0" } }, "sha512-/LVqflmgUcCiXb8rz1iRq9Rx3SWfIV/EkoNqDFGMx+TvOyo8QHAygFXAVQz7RHs+jk6n6mEgpI6KlKBANoErsQ=="], 117 120 118 121 "@elysiajs/openapi": ["@elysiajs/openapi@1.4.11", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-d75bMxYJpN6qSDi/z9L1S7SLk1S/8Px+cTb3W2lrYzU8uQ5E0kXdy1oOMJEfTyVsz3OA19NP9KNxE7ztSbLBLg=="], 119 122 120 123 "@elysiajs/opentelemetry": ["@elysiajs/opentelemetry@1.4.6", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-jR7t4M6ZvMnBqzzHsNTL6y3sNq9jbGi2vKxbkizi/OO5tlvlKl/rnBGyFjZUjQ1Hte7rCz+2kfmgOQMhkjk+Og=="], 121 124 122 - "@elysiajs/static": ["@elysiajs/static@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lAEvdxeBhU/jX/hTzfoP+1AtqhsKNCwW4Q+tfNwAShWU6s4ZPQxR1hLoHBveeApofJt4HWEq/tBGvfFz3ODuKg=="], 125 + "@elysiajs/static": ["@elysiajs/static@1.4.6", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-cd61aY/DHOVhlnBjzTBX8E1XANIrsCH8MwEGHeLMaZzNrz0gD4Q8Qsde2dFMzu81I7ZDaaZ2Rim9blSLtUrYBg=="], 123 126 124 - "@grpc/grpc-js": ["@grpc/grpc-js@1.14.0", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg=="], 127 + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.1", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ=="], 125 128 126 129 "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], 127 130 ··· 187 190 188 191 "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg=="], 189 192 190 - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="], 193 + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.38.0", "", {}, "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg=="], 191 194 192 - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WeXSaL29ylJEZMYHHW28QZ6rgAbxQ1KuNSZD9gvd3fPlo0s6s2PglvPArjjP07nmvIK9m4OffN0k4M98O7WmAg=="], 195 + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-licBDIbbLP5L5/S0+bwtJynso94XD3KyqSP48K59Sq7Mude6C7dR5ZujZm4Ut4BwZqUFfNOfYNMWBU5nlL7t1A=="], 193 196 194 - "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CFKjoUWQH0Oz3UHYfKbdKLq0wGryrFsTJEYq839qAwHQSECvVZYAnxVVDYUDa0yQFonhO2qSHY41f6HK+b7xtw=="], 197 + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-hn8lLzsYyyh6ULo2E8v2SqtrWOkdQKJwapeVy1rDw7juTTeHY3KDudGWf4mVYteC9riZU6HD88Fn3nGwyX0eIg=="], 195 198 196 - "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+FSr/ub5vA/EkD3fMhHJUzYioSf/sXd50OGxNDAntVxcDu4tXL/81Ka3R/gkZmjznpLFIzovU/1Ts+b7dlkrfw=="], 199 + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UHxdtbyxdtNJUNcXtIrjx3Lmq8ji3KywlXtIHV/0vn9A8W5mulqOcryqUWMFVH9JTIIzmNn6Q/qVmXHTME63Ww=="], 197 200 198 - "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WHthS/eLkCNcp9pk4W8aubRl9fIUgt2XhHyLrP0GClB1FVvmodu/zIOtG0NXNpzlzB8+gglOkGo4dPjfVf4Z+g=="], 201 + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5uZzxzvHU/z+3cZwN/A0H8G+enQ+9FkeJVZkE2fwK2XhiJZFUGAuWajCpy7GepvOWlqV7VjPaKi2+Qmr4IX7nQ=="], 199 202 200 - "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HT5sr7N8NDYbQRjAnT7ISpx64y+ewZZRQozOJb0+KQObKvg4UUNXGm4Pn1xA4/WPMZDDazjO8E2vtOQw1nJlAQ=="], 203 + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-OD9DYkjes7WXieBn4zQZGXWhRVZhIEWMDGCetZ3H4vxIuweZ++iul/CNX5jdpNXaJ17myb1ROMvmRbrqW44j3w=="], 201 204 202 - "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sGEWoJQXO4GDr0x4t/yJQ/Bq1yNkOdX9tHbZZ+DBGJt3z3r7jeb4Digv8xQUk6gdTFC9vnGHuin+KW3/yD1Aww=="], 205 + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EoEuRP9bxAxVKuvi6tZ0ZENjueP4lvjz0mKsMzdG0kwg/2apGKiirH1l0RIcdmvfDGGuDmNiv/XBpkoXq1x8ug=="], 203 206 204 - "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-OmlEH3nlxQyv7HOvTH21vyNAZGv9DIPnrTznzvKiOQxkOphhCyKvPTlF13ydw4s/i18iwaUrhHy+YG9HSSxa4Q=="], 207 + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m9Ov9YH8KjRLui87eNtQQFKVnjGsNk3xgbrR9c8d2FS3NfZSxmVjSeBvEsDjzNf1TXLDriHb/NYOlpiMf/QzDg=="], 205 208 206 - "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rtzUEzCynl3Rhgn/iR9DQezSFiZMcAXAbU+xfROqsweMGKwvwIA2ckyyckO08psEP8XcUZTs3LT9CH7PnaMiEA=="], 209 + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-3TuOsRVoG8K+soQWRo+Cp5ACpRs6rTFSu5tAqc/6WrqwbNWmqjov/eWJPTgz3gPXnC7uNKVG7RxxAmV8r2EYTQ=="], 207 210 208 - "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hrr7mDvUjMX1tuJaXz448tMsgKIqGJBY8+rJqztKOw1U5+a/v2w5HuIIW1ce7ut0ZwEn+KIDvAujlPvpH33vpQ=="], 211 + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-q8Hto8hcpofPJjvuvjuwyYvhOaAzPw1F5vRUUeOJDmDwZ4lZhANFM0rUwchMzfWUJCD6jg8/EVQ8MiixnZWU0A=="], 209 212 210 - "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xXwtpZVVP7T+vkxcF/TUVVOGRjEfkByO4mKveKYb4xnHWV4u4NnV0oNmzyMKkvmj10to5j2h0oZxA4ZVVv4gfA=="], 213 + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nZJUa5NprPYQ4Ii4cMwtP9PzlJJTp1XhxJ+A9eSn1Jfr6YygVWyN2KLjenyI93IcuBouBAaepDAVZZjH2lFBhg=="], 211 214 212 - "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/jVZ8eYjpYHLDFNoT86cP+AjuWvpkzFY+0R0a1bdeu0sQ6ILuy1FV6hz1hUAP390E09VCo5oP76fnx29giHTtA=="], 215 + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-s00T99MjB+xLOWq+t+wVaVBrry+oBOZNiTJijt+bmkp/MJptYS3FGvs7a+nkjLNzoNDoWQcXgKew6AaHES37Bg=="], 213 216 214 217 "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], 215 218 ··· 253 256 254 257 "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], 255 258 256 - "@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], 259 + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], 257 260 258 261 "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], 259 262 ··· 265 268 266 269 "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], 267 270 268 - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 271 + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], 269 272 270 273 "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], 271 274 ··· 299 302 300 303 "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], 301 304 302 - "@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="], 305 + "@tanstack/query-core": ["@tanstack/query-core@5.90.7", "", {}, "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ=="], 303 306 304 - "@tanstack/react-query": ["@tanstack/react-query@5.90.2", "", { "dependencies": { "@tanstack/query-core": "5.90.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw=="], 307 + "@tanstack/react-query": ["@tanstack/react-query@5.90.7", "", { "dependencies": { "@tanstack/query-core": "5.90.7" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ=="], 305 308 306 309 "@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], 307 310 ··· 321 324 322 325 "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], 323 326 324 - "@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], 327 + "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], 325 328 326 329 "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], 327 330 328 - "@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="], 331 + "@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="], 329 332 330 333 "@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="], 331 334 ··· 363 366 364 367 "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], 365 368 366 - "bun": ["bun@1.3.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.0", "@oven/bun-darwin-x64": "1.3.0", "@oven/bun-darwin-x64-baseline": "1.3.0", "@oven/bun-linux-aarch64": "1.3.0", "@oven/bun-linux-aarch64-musl": "1.3.0", "@oven/bun-linux-x64": "1.3.0", "@oven/bun-linux-x64-baseline": "1.3.0", "@oven/bun-linux-x64-musl": "1.3.0", "@oven/bun-linux-x64-musl-baseline": "1.3.0", "@oven/bun-windows-x64": "1.3.0", "@oven/bun-windows-x64-baseline": "1.3.0" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-YI7mFs7iWc/VsGsh2aw6eAPD2cjzn1j+LKdYVk09x1CrdTWKYIHyd+dG5iQoN9//3hCDoZj8U6vKpZzEf5UARA=="], 369 + "bun": ["bun@1.3.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.2", "@oven/bun-darwin-x64": "1.3.2", "@oven/bun-darwin-x64-baseline": "1.3.2", "@oven/bun-linux-aarch64": "1.3.2", "@oven/bun-linux-aarch64-musl": "1.3.2", "@oven/bun-linux-x64": "1.3.2", "@oven/bun-linux-x64-baseline": "1.3.2", "@oven/bun-linux-x64-musl": "1.3.2", "@oven/bun-linux-x64-musl-baseline": "1.3.2", "@oven/bun-windows-x64": "1.3.2", "@oven/bun-windows-x64-baseline": "1.3.2" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-x75mPJiEfhO1j4Tfc65+PtW6ZyrAB6yTZInydnjDZXF9u9PRAnr6OK3v0Q9dpDl0dxRHkXlYvJ8tteJxc8t4Sw=="], 367 370 368 371 "bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="], 369 372 370 - "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], 373 + "bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], 371 374 372 375 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 373 376 ··· 443 446 444 447 "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], 445 448 446 - "elysia": ["elysia@1.4.11", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-cphuzQj0fRw1ICRvwHy2H3xQio9bycaZUVHnDHJQnKqBfMNlZ+Hzj6TMmt9lc0Az0mvbCnPXWVF7y1MCRhUuOA=="], 449 + "elysia": ["elysia@1.4.15", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-RaDqqZdLuC4UJetfVRQ4Z5aVpGgEtQ+pZnsbI4ZzEaf3l/MzuHcqSVoL/Fue3d6qE4RV9HMB2rAZaHyPIxkyzg=="], 447 450 448 451 "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 449 452 ··· 579 582 580 583 "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], 581 584 585 + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], 586 + 582 587 "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], 583 588 584 589 "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], ··· 637 642 638 643 "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 639 644 640 - "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 645 + "multiformats": ["multiformats@13.4.1", "", {}, "sha512-VqO6OSvLrFVAYYjgsr8tyv62/rCQhPgsZUXLTqoFLSgdkgiUYKYeArbt1uWLlEpkjxQe+P0+sHlbPEte1Bi06Q=="], 641 646 642 647 "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], 643 648 ··· 777 782 778 783 "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], 779 784 780 - "tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="], 785 + "tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], 781 786 782 787 "thread-stream": ["thread-stream@2.7.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw=="], 783 788 784 789 "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], 785 790 786 - "tlds": ["tlds@1.260.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ=="], 791 + "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], 787 792 788 793 "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], 789 794 ··· 809 814 810 815 "undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="], 811 816 812 - "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], 817 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 813 818 814 819 "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], 815 820 ··· 853 858 854 859 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], 855 860 861 + "@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 862 + 863 + "@atproto/common/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 864 + 865 + "@atproto/common-web/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 866 + 867 + "@atproto/jwk/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 868 + 869 + "@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 870 + 871 + "@atproto/oauth-client/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 872 + 873 + "@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 874 + 875 + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 876 + 877 + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 878 + 879 + "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], 880 + 881 + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 882 + 856 883 "@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 857 884 858 885 "express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="], ··· 870 897 "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], 871 898 872 899 "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 900 + 901 + "uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 873 902 874 903 "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 875 904
+120 -23
hosting-service/src/lib/utils.ts
··· 13 13 cachedAt: number; 14 14 did: string; 15 15 rkey: string; 16 + // Map of file path to blob CID for incremental updates 17 + fileCids?: Record<string, string>; 16 18 } 17 19 18 20 /** ··· 200 202 throw new Error('Invalid record structure: root missing entries array'); 201 203 } 202 204 205 + // Get existing cache metadata to check for incremental updates 206 + const existingMetadata = await getCacheMetadata(did, rkey); 207 + const existingFileCids = existingMetadata?.fileCids || {}; 208 + 203 209 // Use a temporary directory with timestamp to avoid collisions 204 210 const tempSuffix = `.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; 205 211 const tempDir = `${CACHE_DIR}/${did}/${rkey}${tempSuffix}`; 206 212 const finalDir = `${CACHE_DIR}/${did}/${rkey}`; 207 213 208 214 try { 209 - // Download to temporary directory 210 - await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '', tempSuffix); 211 - await saveCacheMetadata(did, rkey, recordCid, tempSuffix); 215 + // Collect file CIDs from the new record 216 + const newFileCids: Record<string, string> = {}; 217 + collectFileCidsFromEntries(record.root.entries, '', newFileCids); 218 + 219 + // Download/copy files to temporary directory (with incremental logic) 220 + await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '', tempSuffix, existingFileCids, finalDir); 221 + await saveCacheMetadata(did, rkey, recordCid, tempSuffix, newFileCids); 212 222 213 223 // Atomically replace old cache with new cache 214 224 // On POSIX systems (Linux/macOS), rename is atomic ··· 245 255 } 246 256 } 247 257 258 + /** 259 + * Recursively collect file CIDs from entries for incremental update tracking 260 + */ 261 + function collectFileCidsFromEntries(entries: Entry[], pathPrefix: string, fileCids: Record<string, string>): void { 262 + for (const entry of entries) { 263 + const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name; 264 + const node = entry.node; 265 + 266 + if ('type' in node && node.type === 'directory' && 'entries' in node) { 267 + collectFileCidsFromEntries(node.entries, currentPath, fileCids); 268 + } else if ('type' in node && node.type === 'file' && 'blob' in node) { 269 + const fileNode = node as File; 270 + const cid = extractBlobCid(fileNode.blob); 271 + if (cid) { 272 + fileCids[currentPath] = cid; 273 + } 274 + } 275 + } 276 + } 277 + 248 278 async function cacheFiles( 249 279 did: string, 250 280 site: string, 251 281 entries: Entry[], 252 282 pdsEndpoint: string, 253 283 pathPrefix: string, 254 - dirSuffix: string = '' 284 + dirSuffix: string = '', 285 + existingFileCids: Record<string, string> = {}, 286 + existingCacheDir?: string 255 287 ): Promise<void> { 256 - // Collect all file blob download tasks first 288 + // Collect file tasks, separating unchanged files from new/changed files 257 289 const downloadTasks: Array<() => Promise<void>> = []; 258 - 290 + const copyTasks: Array<() => Promise<void>> = []; 291 + 259 292 function collectFileTasks( 260 293 entries: Entry[], 261 294 currentPathPrefix: string ··· 268 301 collectFileTasks(node.entries, currentPath); 269 302 } else if ('type' in node && node.type === 'file' && 'blob' in node) { 270 303 const fileNode = node as File; 271 - downloadTasks.push(() => cacheFileBlob( 272 - did, 273 - site, 274 - currentPath, 275 - fileNode.blob, 276 - pdsEndpoint, 277 - fileNode.encoding, 278 - fileNode.mimeType, 279 - fileNode.base64, 280 - dirSuffix 281 - )); 304 + const cid = extractBlobCid(fileNode.blob); 305 + 306 + // Check if file is unchanged (same CID as existing cache) 307 + if (cid && existingFileCids[currentPath] === cid && existingCacheDir) { 308 + // File unchanged - copy from existing cache instead of downloading 309 + copyTasks.push(() => copyExistingFile( 310 + did, 311 + site, 312 + currentPath, 313 + dirSuffix, 314 + existingCacheDir 315 + )); 316 + } else { 317 + // File new or changed - download it 318 + downloadTasks.push(() => cacheFileBlob( 319 + did, 320 + site, 321 + currentPath, 322 + fileNode.blob, 323 + pdsEndpoint, 324 + fileNode.encoding, 325 + fileNode.mimeType, 326 + fileNode.base64, 327 + dirSuffix 328 + )); 329 + } 282 330 } 283 331 } 284 332 } 285 333 286 334 collectFileTasks(entries, pathPrefix); 287 335 288 - // Execute downloads concurrently with a limit of 3 at a time 289 - const concurrencyLimit = 3; 290 - for (let i = 0; i < downloadTasks.length; i += concurrencyLimit) { 291 - const batch = downloadTasks.slice(i, i + concurrencyLimit); 336 + console.log(`[Incremental Update] Files to copy: ${copyTasks.length}, Files to download: ${downloadTasks.length}`); 337 + 338 + // Copy unchanged files in parallel (fast local operations) 339 + const copyLimit = 10; 340 + for (let i = 0; i < copyTasks.length; i += copyLimit) { 341 + const batch = copyTasks.slice(i, i + copyLimit); 342 + await Promise.all(batch.map(task => task())); 343 + } 344 + 345 + // Download new/changed files concurrently with a limit of 3 at a time 346 + const downloadLimit = 3; 347 + for (let i = 0; i < downloadTasks.length; i += downloadLimit) { 348 + const batch = downloadTasks.slice(i, i + downloadLimit); 292 349 await Promise.all(batch.map(task => task())); 350 + } 351 + } 352 + 353 + /** 354 + * Copy an unchanged file from existing cache to new cache location 355 + */ 356 + async function copyExistingFile( 357 + did: string, 358 + site: string, 359 + filePath: string, 360 + dirSuffix: string, 361 + existingCacheDir: string 362 + ): Promise<void> { 363 + const { copyFile } = await import('fs/promises'); 364 + 365 + const sourceFile = `${existingCacheDir}/${filePath}`; 366 + const destFile = `${CACHE_DIR}/${did}/${site}${dirSuffix}/${filePath}`; 367 + const destDir = destFile.substring(0, destFile.lastIndexOf('/')); 368 + 369 + // Create destination directory if needed 370 + if (destDir && !existsSync(destDir)) { 371 + mkdirSync(destDir, { recursive: true }); 372 + } 373 + 374 + try { 375 + // Copy the file 376 + await copyFile(sourceFile, destFile); 377 + 378 + // Copy metadata file if it exists 379 + const sourceMetaFile = `${sourceFile}.meta`; 380 + const destMetaFile = `${destFile}.meta`; 381 + if (existsSync(sourceMetaFile)) { 382 + await copyFile(sourceMetaFile, destMetaFile); 383 + } 384 + 385 + console.log(`[Incremental] Copied unchanged file: ${filePath}`); 386 + } catch (err) { 387 + console.error(`[Incremental] Failed to copy file ${filePath}, will attempt download:`, err); 388 + throw err; 293 389 } 294 390 } 295 391 ··· 404 500 return existsSync(`${CACHE_DIR}/${did}/${site}`); 405 501 } 406 502 407 - async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = ''): Promise<void> { 503 + async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = '', fileCids?: Record<string, string>): Promise<void> { 408 504 const metadata: CacheMetadata = { 409 505 recordCid, 410 506 cachedAt: Date.now(), 411 507 did, 412 - rkey 508 + rkey, 509 + fileCids 413 510 }; 414 511 415 512 const metadataPath = `${CACHE_DIR}/${did}/${rkey}${dirSuffix}/.metadata.json`;
+3 -1
hosting-service/tsconfig.json
··· 24 24 25 25 /* Code doesn't run in DOM */ 26 26 "lib": ["es2022"], 27 - } 27 + }, 28 + "include": ["src/**/*"], 29 + "exclude": ["node_modules", "cache", "dist"] 28 30 }
+3
package.json
··· 29 29 "elysia": "latest", 30 30 "iron-session": "^8.0.4", 31 31 "lucide-react": "^0.546.0", 32 + "multiformats": "^13.4.1", 32 33 "react": "^19.2.0", 33 34 "react-dom": "^19.2.0", 34 35 "react-shiki": "^0.9.0", ··· 46 47 }, 47 48 "module": "src/index.js", 48 49 "trustedDependencies": [ 50 + "bun", 51 + "cbor-extract", 49 52 "core-js", 50 53 "protobufjs" 51 54 ]
+2 -2
public/editor/editor.tsx
··· 748 748 749 749 <div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50"> 750 750 <div className="flex items-start gap-2"> 751 - <AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" /> 751 + <AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" /> 752 752 <div className="flex-1 space-y-1"> 753 753 <p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400"> 754 754 Note about sites.wisp.place URLs ··· 1120 1120 {skippedFiles.length > 0 && ( 1121 1121 <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 1122 1122 <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 1123 - <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" /> 1123 + <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" /> 1124 1124 <div className="flex-1"> 1125 1125 <span className="font-medium"> 1126 1126 {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
-7
src/lib/db.ts
··· 244 244 245 245 const stateStore = { 246 246 async set(key: string, data: any) { 247 - console.debug('[stateStore] set', key) 248 247 const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT; 249 248 await db` 250 249 INSERT INTO oauth_states (key, data, created_at, expires_at) ··· 253 252 `; 254 253 }, 255 254 async get(key: string) { 256 - console.debug('[stateStore] get', key) 257 255 const now = Math.floor(Date.now() / 1000); 258 256 const result = await db` 259 257 SELECT data, expires_at ··· 265 263 // Check if expired 266 264 const expiresAt = Number(result[0].expires_at); 267 265 if (expiresAt && now > expiresAt) { 268 - console.debug('[stateStore] State expired, deleting', key); 269 266 await db`DELETE FROM oauth_states WHERE key = ${key}`; 270 267 return undefined; 271 268 } ··· 273 270 return JSON.parse(result[0].data); 274 271 }, 275 272 async del(key: string) { 276 - console.debug('[stateStore] del', key) 277 273 await db`DELETE FROM oauth_states WHERE key = ${key}`; 278 274 } 279 275 }; 280 276 281 277 const sessionStore = { 282 278 async set(sub: string, data: any) { 283 - console.debug('[sessionStore] set', sub) 284 279 const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT; 285 280 await db` 286 281 INSERT INTO oauth_sessions (sub, data, updated_at, expires_at) ··· 292 287 `; 293 288 }, 294 289 async get(sub: string) { 295 - console.debug('[sessionStore] get', sub) 296 290 const now = Math.floor(Date.now() / 1000); 297 291 const result = await db` 298 292 SELECT data, expires_at ··· 312 306 return JSON.parse(result[0].data); 313 307 }, 314 308 async del(sub: string) { 315 - console.debug('[sessionStore] del', sub) 316 309 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 317 310 } 318 311 };
-1
src/lib/oauth-client.ts
··· 58 58 `; 59 59 }, 60 60 async get(sub: string) { 61 - console.debug('[sessionStore] get', sub) 62 61 const now = Math.floor(Date.now() / 1000); 63 62 const result = await db` 64 63 SELECT data, expires_at
+360
src/lib/wisp-utils.test.ts
··· 5 5 processUploadedFiles, 6 6 createManifest, 7 7 updateFileBlobs, 8 + computeCID, 9 + extractBlobMap, 8 10 type UploadedFile, 9 11 type FileUploadResult, 10 12 } from './wisp-utils' ··· 637 639 } 638 640 }) 639 641 }) 642 + 643 + describe('computeCID', () => { 644 + test('should compute CID for gzipped+base64 encoded content', () => { 645 + // This simulates the actual flow: gzip -> base64 -> compute CID 646 + const originalContent = Buffer.from('Hello, World!') 647 + const gzipped = compressFile(originalContent) 648 + const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 649 + 650 + const cid = computeCID(base64Content) 651 + 652 + // CID should be a valid CIDv1 string starting with 'bafkrei' 653 + expect(cid).toMatch(/^bafkrei[a-z0-9]+$/) 654 + expect(cid.length).toBeGreaterThan(10) 655 + }) 656 + 657 + test('should compute deterministic CIDs for identical content', () => { 658 + const content = Buffer.from('Test content for CID calculation') 659 + const gzipped = compressFile(content) 660 + const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 661 + 662 + const cid1 = computeCID(base64Content) 663 + const cid2 = computeCID(base64Content) 664 + 665 + expect(cid1).toBe(cid2) 666 + }) 667 + 668 + test('should compute different CIDs for different content', () => { 669 + const content1 = Buffer.from('Content A') 670 + const content2 = Buffer.from('Content B') 671 + 672 + const gzipped1 = compressFile(content1) 673 + const gzipped2 = compressFile(content2) 674 + 675 + const base64Content1 = Buffer.from(gzipped1.toString('base64'), 'binary') 676 + const base64Content2 = Buffer.from(gzipped2.toString('base64'), 'binary') 677 + 678 + const cid1 = computeCID(base64Content1) 679 + const cid2 = computeCID(base64Content2) 680 + 681 + expect(cid1).not.toBe(cid2) 682 + }) 683 + 684 + test('should handle empty content', () => { 685 + const emptyContent = Buffer.from('') 686 + const gzipped = compressFile(emptyContent) 687 + const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 688 + 689 + const cid = computeCID(base64Content) 690 + 691 + expect(cid).toMatch(/^bafkrei[a-z0-9]+$/) 692 + }) 693 + 694 + test('should compute same CID as PDS for base64-encoded content', () => { 695 + // Test that binary encoding produces correct bytes for CID calculation 696 + const testContent = Buffer.from('<!DOCTYPE html><html><body>Hello</body></html>') 697 + const gzipped = compressFile(testContent) 698 + const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 699 + 700 + // Compute CID twice to ensure consistency 701 + const cid1 = computeCID(base64Content) 702 + const cid2 = computeCID(base64Content) 703 + 704 + expect(cid1).toBe(cid2) 705 + expect(cid1).toMatch(/^bafkrei/) 706 + }) 707 + 708 + test('should use binary encoding for base64 strings', () => { 709 + // This test verifies we're using the correct encoding method 710 + // For base64 strings, 'binary' encoding ensures each character becomes exactly one byte 711 + const content = Buffer.from('Test content') 712 + const gzipped = compressFile(content) 713 + const base64String = gzipped.toString('base64') 714 + 715 + // Using binary encoding (what we use in production) 716 + const base64Content = Buffer.from(base64String, 'binary') 717 + 718 + // Verify the length matches the base64 string length 719 + expect(base64Content.length).toBe(base64String.length) 720 + 721 + // Verify CID is computed correctly 722 + const cid = computeCID(base64Content) 723 + expect(cid).toMatch(/^bafkrei/) 724 + }) 725 + }) 726 + 727 + describe('extractBlobMap', () => { 728 + test('should extract blob map from flat directory structure', () => { 729 + const mockCid = CID.parse(TEST_CID_STRING) 730 + const mockBlob = new BlobRef(mockCid, 'text/html', 100) 731 + 732 + const directory: Directory = { 733 + $type: 'place.wisp.fs#directory', 734 + type: 'directory', 735 + entries: [ 736 + { 737 + name: 'index.html', 738 + node: { 739 + $type: 'place.wisp.fs#file', 740 + type: 'file', 741 + blob: mockBlob, 742 + }, 743 + }, 744 + ], 745 + } 746 + 747 + const blobMap = extractBlobMap(directory) 748 + 749 + expect(blobMap.size).toBe(1) 750 + expect(blobMap.has('index.html')).toBe(true) 751 + 752 + const entry = blobMap.get('index.html') 753 + expect(entry?.cid).toBe(TEST_CID_STRING) 754 + expect(entry?.blobRef).toBe(mockBlob) 755 + }) 756 + 757 + test('should extract blob map from nested directory structure', () => { 758 + const mockCid1 = CID.parse(TEST_CID_STRING) 759 + const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 760 + 761 + const mockBlob1 = new BlobRef(mockCid1, 'text/html', 100) 762 + const mockBlob2 = new BlobRef(mockCid2, 'text/css', 50) 763 + 764 + const directory: Directory = { 765 + $type: 'place.wisp.fs#directory', 766 + type: 'directory', 767 + entries: [ 768 + { 769 + name: 'index.html', 770 + node: { 771 + $type: 'place.wisp.fs#file', 772 + type: 'file', 773 + blob: mockBlob1, 774 + }, 775 + }, 776 + { 777 + name: 'assets', 778 + node: { 779 + $type: 'place.wisp.fs#directory', 780 + type: 'directory', 781 + entries: [ 782 + { 783 + name: 'styles.css', 784 + node: { 785 + $type: 'place.wisp.fs#file', 786 + type: 'file', 787 + blob: mockBlob2, 788 + }, 789 + }, 790 + ], 791 + }, 792 + }, 793 + ], 794 + } 795 + 796 + const blobMap = extractBlobMap(directory) 797 + 798 + expect(blobMap.size).toBe(2) 799 + expect(blobMap.has('index.html')).toBe(true) 800 + expect(blobMap.has('assets/styles.css')).toBe(true) 801 + 802 + expect(blobMap.get('index.html')?.cid).toBe(TEST_CID_STRING) 803 + expect(blobMap.get('assets/styles.css')?.cid).toBe('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 804 + }) 805 + 806 + test('should handle deeply nested directory structures', () => { 807 + const mockCid = CID.parse(TEST_CID_STRING) 808 + const mockBlob = new BlobRef(mockCid, 'text/javascript', 200) 809 + 810 + const directory: Directory = { 811 + $type: 'place.wisp.fs#directory', 812 + type: 'directory', 813 + entries: [ 814 + { 815 + name: 'src', 816 + node: { 817 + $type: 'place.wisp.fs#directory', 818 + type: 'directory', 819 + entries: [ 820 + { 821 + name: 'lib', 822 + node: { 823 + $type: 'place.wisp.fs#directory', 824 + type: 'directory', 825 + entries: [ 826 + { 827 + name: 'utils.js', 828 + node: { 829 + $type: 'place.wisp.fs#file', 830 + type: 'file', 831 + blob: mockBlob, 832 + }, 833 + }, 834 + ], 835 + }, 836 + }, 837 + ], 838 + }, 839 + }, 840 + ], 841 + } 842 + 843 + const blobMap = extractBlobMap(directory) 844 + 845 + expect(blobMap.size).toBe(1) 846 + expect(blobMap.has('src/lib/utils.js')).toBe(true) 847 + expect(blobMap.get('src/lib/utils.js')?.cid).toBe(TEST_CID_STRING) 848 + }) 849 + 850 + test('should handle empty directory', () => { 851 + const directory: Directory = { 852 + $type: 'place.wisp.fs#directory', 853 + type: 'directory', 854 + entries: [], 855 + } 856 + 857 + const blobMap = extractBlobMap(directory) 858 + 859 + expect(blobMap.size).toBe(0) 860 + }) 861 + 862 + test('should correctly extract CID from BlobRef instances (not plain objects)', () => { 863 + // This test verifies the fix: AT Protocol SDK returns BlobRef instances, 864 + // not plain objects with $type and $link properties 865 + const mockCid = CID.parse(TEST_CID_STRING) 866 + const mockBlob = new BlobRef(mockCid, 'application/octet-stream', 500) 867 + 868 + const directory: Directory = { 869 + $type: 'place.wisp.fs#directory', 870 + type: 'directory', 871 + entries: [ 872 + { 873 + name: 'test.bin', 874 + node: { 875 + $type: 'place.wisp.fs#file', 876 + type: 'file', 877 + blob: mockBlob, 878 + }, 879 + }, 880 + ], 881 + } 882 + 883 + const blobMap = extractBlobMap(directory) 884 + 885 + // The fix: we call .toString() on the CID instance instead of accessing $link 886 + expect(blobMap.get('test.bin')?.cid).toBe(TEST_CID_STRING) 887 + expect(blobMap.get('test.bin')?.blobRef.ref.toString()).toBe(TEST_CID_STRING) 888 + }) 889 + 890 + test('should handle multiple files in same directory', () => { 891 + const mockCid1 = CID.parse(TEST_CID_STRING) 892 + const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 893 + const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq') 894 + 895 + const mockBlob1 = new BlobRef(mockCid1, 'image/png', 1000) 896 + const mockBlob2 = new BlobRef(mockCid2, 'image/png', 2000) 897 + const mockBlob3 = new BlobRef(mockCid3, 'image/png', 3000) 898 + 899 + const directory: Directory = { 900 + $type: 'place.wisp.fs#directory', 901 + type: 'directory', 902 + entries: [ 903 + { 904 + name: 'images', 905 + node: { 906 + $type: 'place.wisp.fs#directory', 907 + type: 'directory', 908 + entries: [ 909 + { 910 + name: 'logo.png', 911 + node: { 912 + $type: 'place.wisp.fs#file', 913 + type: 'file', 914 + blob: mockBlob1, 915 + }, 916 + }, 917 + { 918 + name: 'banner.png', 919 + node: { 920 + $type: 'place.wisp.fs#file', 921 + type: 'file', 922 + blob: mockBlob2, 923 + }, 924 + }, 925 + { 926 + name: 'icon.png', 927 + node: { 928 + $type: 'place.wisp.fs#file', 929 + type: 'file', 930 + blob: mockBlob3, 931 + }, 932 + }, 933 + ], 934 + }, 935 + }, 936 + ], 937 + } 938 + 939 + const blobMap = extractBlobMap(directory) 940 + 941 + expect(blobMap.size).toBe(3) 942 + expect(blobMap.has('images/logo.png')).toBe(true) 943 + expect(blobMap.has('images/banner.png')).toBe(true) 944 + expect(blobMap.has('images/icon.png')).toBe(true) 945 + }) 946 + 947 + test('should handle mixed directory and file structure', () => { 948 + const mockCid1 = CID.parse(TEST_CID_STRING) 949 + const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 950 + const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq') 951 + 952 + const directory: Directory = { 953 + $type: 'place.wisp.fs#directory', 954 + type: 'directory', 955 + entries: [ 956 + { 957 + name: 'index.html', 958 + node: { 959 + $type: 'place.wisp.fs#file', 960 + type: 'file', 961 + blob: new BlobRef(mockCid1, 'text/html', 100), 962 + }, 963 + }, 964 + { 965 + name: 'assets', 966 + node: { 967 + $type: 'place.wisp.fs#directory', 968 + type: 'directory', 969 + entries: [ 970 + { 971 + name: 'styles.css', 972 + node: { 973 + $type: 'place.wisp.fs#file', 974 + type: 'file', 975 + blob: new BlobRef(mockCid2, 'text/css', 50), 976 + }, 977 + }, 978 + ], 979 + }, 980 + }, 981 + { 982 + name: 'README.md', 983 + node: { 984 + $type: 'place.wisp.fs#file', 985 + type: 'file', 986 + blob: new BlobRef(mockCid3, 'text/markdown', 200), 987 + }, 988 + }, 989 + ], 990 + } 991 + 992 + const blobMap = extractBlobMap(directory) 993 + 994 + expect(blobMap.size).toBe(3) 995 + expect(blobMap.has('index.html')).toBe(true) 996 + expect(blobMap.has('assets/styles.css')).toBe(true) 997 + expect(blobMap.has('README.md')).toBe(true) 998 + }) 999 + })
+65 -2
src/lib/wisp-utils.ts
··· 2 2 import type { Record, Directory, File, Entry } from "../lexicons/types/place/wisp/fs"; 3 3 import { validateRecord } from "../lexicons/types/place/wisp/fs"; 4 4 import { gzipSync } from 'zlib'; 5 + import { CID } from 'multiformats/cid'; 6 + import { sha256 } from 'multiformats/hashes/sha2'; 7 + import * as raw from 'multiformats/codecs/raw'; 8 + import { createHash } from 'crypto'; 9 + import * as mf from 'multiformats'; 5 10 6 11 export interface UploadedFile { 7 12 name: string; ··· 48 53 } 49 54 50 55 /** 51 - * Compress a file using gzip 56 + * Compress a file using gzip with deterministic output 57 + * Sets mtime to 0 to ensure identical content produces identical compressed output 52 58 */ 53 59 export function compressFile(content: Buffer): Buffer { 54 - return gzipSync(content, { level: 9 }); 60 + return gzipSync(content, { 61 + level: 9, 62 + mtime: 0 // Fixed timestamp for deterministic compression 63 + }); 55 64 } 56 65 57 66 /** ··· 65 74 const directoryMap = new Map<string, UploadedFile[]>(); 66 75 67 76 for (const file of files) { 77 + // Skip undefined/null files (defensive) 78 + if (!file || !file.name) { 79 + console.error('Skipping undefined or invalid file in processUploadedFiles'); 80 + continue; 81 + } 82 + 68 83 // Remove any base folder name from the path 69 84 const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 70 85 const parts = normalizedPath.split('/'); ··· 239 254 240 255 return result; 241 256 } 257 + 258 + /** 259 + * Compute CID (Content Identifier) for blob content 260 + * Uses the same algorithm as AT Protocol: CIDv1 with raw codec and SHA-256 261 + * Based on @atproto/common/src/ipld.ts sha256RawToCid implementation 262 + */ 263 + export function computeCID(content: Buffer): string { 264 + // Use node crypto to compute sha256 hash (same as AT Protocol) 265 + const hash = createHash('sha256').update(content).digest(); 266 + // Create digest object from hash bytes 267 + const digest = mf.digest.create(sha256.code, hash); 268 + // Create CIDv1 with raw codec 269 + const cid = CID.createV1(raw.code, digest); 270 + return cid.toString(); 271 + } 272 + 273 + /** 274 + * Extract blob information from a directory tree 275 + * Returns a map of file paths to their blob refs and CIDs 276 + */ 277 + export function extractBlobMap( 278 + directory: Directory, 279 + currentPath: string = '' 280 + ): Map<string, { blobRef: BlobRef; cid: string }> { 281 + const blobMap = new Map<string, { blobRef: BlobRef; cid: string }>(); 282 + 283 + for (const entry of directory.entries) { 284 + const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 285 + 286 + if ('type' in entry.node && entry.node.type === 'file') { 287 + const fileNode = entry.node as File; 288 + // AT Protocol SDK returns BlobRef class instances, not plain objects 289 + // The ref is a CID instance that can be converted to string 290 + if (fileNode.blob && fileNode.blob.ref) { 291 + const cidString = fileNode.blob.ref.toString(); 292 + blobMap.set(fullPath, { 293 + blobRef: fileNode.blob, 294 + cid: cidString 295 + }); 296 + } 297 + } else if ('type' in entry.node && entry.node.type === 'directory') { 298 + const subMap = extractBlobMap(entry.node as Directory, fullPath); 299 + subMap.forEach((value, key) => blobMap.set(key, value)); 300 + } 301 + } 302 + 303 + return blobMap; 304 + }
+130 -10
src/routes/wisp.ts
··· 9 9 createManifest, 10 10 updateFileBlobs, 11 11 shouldCompressFile, 12 - compressFile 12 + compressFile, 13 + computeCID, 14 + extractBlobMap 13 15 } from '../lib/wisp-utils' 14 16 import { upsertSite } from '../lib/db' 15 17 import { logger } from '../lib/observability' ··· 48 50 siteName: string; 49 51 files: File | File[] 50 52 }; 53 + 54 + console.log('=== UPLOAD FILES START ==='); 55 + console.log('Site name:', siteName); 56 + console.log('Files received:', Array.isArray(files) ? files.length : 'single file'); 51 57 52 58 try { 53 59 if (!siteName) { ··· 106 112 107 113 // Create agent with OAuth session 108 114 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 115 + console.log('Agent created for DID:', auth.did); 116 + 117 + // Try to fetch existing record to enable incremental updates 118 + let existingBlobMap = new Map<string, { blobRef: any; cid: string }>(); 119 + console.log('Attempting to fetch existing record...'); 120 + try { 121 + const rkey = siteName; 122 + const existingRecord = await agent.com.atproto.repo.getRecord({ 123 + repo: auth.did, 124 + collection: 'place.wisp.fs', 125 + rkey: rkey 126 + }); 127 + console.log('Existing record found!'); 128 + 129 + if (existingRecord.data.value && typeof existingRecord.data.value === 'object' && 'root' in existingRecord.data.value) { 130 + const manifest = existingRecord.data.value as any; 131 + existingBlobMap = extractBlobMap(manifest.root); 132 + console.log(`Found existing manifest with ${existingBlobMap.size} files for incremental update`); 133 + logger.info(`Found existing manifest with ${existingBlobMap.size} files for incremental update`); 134 + } 135 + } catch (error: any) { 136 + console.log('No existing record found or error:', error?.message || error); 137 + // Record doesn't exist yet, this is a new site 138 + if (error?.status !== 400 && error?.error !== 'RecordNotFound') { 139 + logger.warn('Failed to fetch existing record, proceeding with full upload', error); 140 + } 141 + } 109 142 110 143 // Convert File objects to UploadedFile format 111 144 // Elysia gives us File objects directly, handle both single file and array ··· 113 146 const uploadedFiles: UploadedFile[] = []; 114 147 const skippedFiles: Array<{ name: string; reason: string }> = []; 115 148 116 - 149 + console.log('Processing files, count:', fileArray.length); 117 150 118 151 for (let i = 0; i < fileArray.length; i++) { 119 152 const file = fileArray[i]; 153 + console.log(`Processing file ${i + 1}/${fileArray.length}:`, file.name, file.size, 'bytes'); 120 154 121 155 // Skip files that are too large (limit to 100MB per file) 122 156 const maxSize = MAX_FILE_SIZE; // 100MB ··· 135 169 // Compress and base64 encode ALL files 136 170 const compressedContent = compressFile(originalContent); 137 171 // Base64 encode the gzipped content to prevent PDS content sniffing 138 - const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8'); 172 + // Convert base64 string to bytes using binary encoding (each char becomes exactly one byte) 173 + // This is what PDS receives and computes CID on 174 + const base64Content = Buffer.from(compressedContent.toString('base64'), 'binary'); 139 175 const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1); 176 + console.log(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); 140 177 logger.info(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); 141 178 142 179 uploadedFiles.push({ 143 180 name: file.name, 144 - content: base64Content, 181 + content: base64Content, // This is the gzipped+base64 content that will be uploaded and CID-computed 145 182 mimeType: originalMimeType, 146 183 size: base64Content.length, 147 184 compressed: true, ··· 206 243 } 207 244 208 245 // Process files into directory structure 209 - const { directory, fileCount } = processUploadedFiles(uploadedFiles); 246 + console.log('Processing uploaded files into directory structure...'); 247 + console.log('uploadedFiles array length:', uploadedFiles.length); 248 + console.log('uploadedFiles contents:', uploadedFiles.map((f, i) => `${i}: ${f?.name || 'UNDEFINED'}`)); 210 249 211 - // Upload files as blobs in parallel 250 + // Filter out any undefined/null/invalid entries (defensive) 251 + const validUploadedFiles = uploadedFiles.filter((f, i) => { 252 + if (!f) { 253 + console.error(`Filtering out undefined/null file at index ${i}`); 254 + return false; 255 + } 256 + if (!f.name) { 257 + console.error(`Filtering out file with no name at index ${i}:`, f); 258 + return false; 259 + } 260 + if (!f.content) { 261 + console.error(`Filtering out file with no content at index ${i}:`, f.name); 262 + return false; 263 + } 264 + return true; 265 + }); 266 + if (validUploadedFiles.length !== uploadedFiles.length) { 267 + console.warn(`Filtered out ${uploadedFiles.length - validUploadedFiles.length} invalid files`); 268 + } 269 + console.log('validUploadedFiles length:', validUploadedFiles.length); 270 + 271 + const { directory, fileCount } = processUploadedFiles(validUploadedFiles); 272 + console.log('Directory structure created, file count:', fileCount); 273 + 274 + // Upload files as blobs in parallel (or reuse existing blobs with matching CIDs) 275 + console.log('Starting blob upload/reuse phase...'); 212 276 // For compressed files, we upload as octet-stream and store the original MIME type in metadata 213 277 // For text/html files, we also use octet-stream as a workaround for PDS image pipeline issues 214 - const uploadPromises = uploadedFiles.map(async (file, i) => { 278 + const uploadPromises = validUploadedFiles.map(async (file, i) => { 215 279 try { 280 + // Skip undefined files (shouldn't happen after filter, but defensive) 281 + if (!file || !file.name) { 282 + console.error(`ERROR: Undefined file at index ${i} in validUploadedFiles!`); 283 + throw new Error(`Undefined file at index ${i}`); 284 + } 285 + 286 + // Compute CID for this file to check if it already exists 287 + // Note: file.content is already gzipped+base64 encoded 288 + const fileCID = computeCID(file.content); 289 + 290 + // Normalize the file path for comparison (remove base folder prefix like "cobblemon/") 291 + const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 292 + 293 + // Check if we have an existing blob with the same CID 294 + // Try both the normalized path and the full path 295 + const existingBlob = existingBlobMap.get(normalizedPath) || existingBlobMap.get(file.name); 296 + 297 + if (existingBlob && existingBlob.cid === fileCID) { 298 + // Reuse existing blob - no need to upload 299 + logger.info(`[File Upload] Reusing existing blob for: ${file.name} (CID: ${fileCID})`); 300 + 301 + return { 302 + result: { 303 + hash: existingBlob.cid, 304 + blobRef: existingBlob.blobRef, 305 + ...(file.compressed && { 306 + encoding: 'gzip' as const, 307 + mimeType: file.originalMimeType || file.mimeType, 308 + base64: true 309 + }) 310 + }, 311 + filePath: file.name, 312 + sentMimeType: file.mimeType, 313 + returnedMimeType: existingBlob.blobRef.mimeType, 314 + reused: true 315 + }; 316 + } 317 + 318 + // File is new or changed - upload it 216 319 // If compressed, always upload as octet-stream 217 320 // Otherwise, workaround: PDS incorrectly processes text/html through image pipeline 218 321 const uploadMimeType = file.compressed || file.mimeType.startsWith('text/html') ··· 220 323 : file.mimeType; 221 324 222 325 const compressionInfo = file.compressed ? ' (gzipped)' : ''; 223 - logger.info(`[File Upload] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`); 326 + logger.info(`[File Upload] Uploading new/changed file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo}, CID: ${fileCID})`); 224 327 225 328 const uploadResult = await agent.com.atproto.repo.uploadBlob( 226 329 file.content, ··· 244 347 }, 245 348 filePath: file.name, 246 349 sentMimeType: file.mimeType, 247 - returnedMimeType: returnedBlobRef.mimeType 350 + returnedMimeType: returnedBlobRef.mimeType, 351 + reused: false 248 352 }; 249 353 } catch (uploadError) { 250 354 logger.error('Upload failed for file', uploadError); ··· 255 359 // Wait for all uploads to complete 256 360 const uploadedBlobs = await Promise.all(uploadPromises); 257 361 362 + // Count reused vs uploaded blobs 363 + const reusedCount = uploadedBlobs.filter(b => (b as any).reused).length; 364 + const uploadedCount = uploadedBlobs.filter(b => !(b as any).reused).length; 365 + console.log(`Blob statistics: ${reusedCount} reused, ${uploadedCount} uploaded, ${uploadedBlobs.length} total`); 366 + logger.info(`Blob statistics: ${reusedCount} reused, ${uploadedCount} uploaded, ${uploadedBlobs.length} total`); 367 + 258 368 // Extract results and file paths in correct order 259 369 const uploadResults: FileUploadResult[] = uploadedBlobs.map(blob => blob.result); 260 370 const filePaths: string[] = uploadedBlobs.map(blob => blob.filePath); 261 371 262 372 // Update directory with file blobs 373 + console.log('Updating directory with blob references...'); 263 374 const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths); 264 375 265 376 // Create manifest 377 + console.log('Creating manifest...'); 266 378 const manifest = createManifest(siteName, updatedDirectory, fileCount); 379 + console.log('Manifest created successfully'); 267 380 268 381 // Use site name as rkey 269 382 const rkey = siteName; 270 383 271 384 let record; 272 385 try { 386 + console.log('Putting record to PDS with rkey:', rkey); 273 387 record = await agent.com.atproto.repo.putRecord({ 274 388 repo: auth.did, 275 389 collection: 'place.wisp.fs', 276 390 rkey: rkey, 277 391 record: manifest 278 392 }); 393 + console.log('Record successfully created on PDS:', record.data.uri); 279 394 } catch (putRecordError: any) { 395 + console.error('FAILED to create record on PDS:', putRecordError); 280 396 logger.error('Failed to create record on PDS', putRecordError); 281 397 282 398 throw putRecordError; ··· 292 408 fileCount, 293 409 siteName, 294 410 skippedFiles, 295 - uploadedCount: uploadedFiles.length 411 + uploadedCount: validUploadedFiles.length 296 412 }; 297 413 414 + console.log('=== UPLOAD FILES COMPLETE ==='); 298 415 return result; 299 416 } catch (error) { 417 + console.error('=== UPLOAD ERROR ==='); 418 + console.error('Error details:', error); 419 + console.error('Stack trace:', error instanceof Error ? error.stack : 'N/A'); 300 420 logger.error('Upload error', error, { 301 421 message: error instanceof Error ? error.message : 'Unknown error', 302 422 name: error instanceof Error ? error.name : undefined