From 495506935e7741fa8d7cf5d92247d09db5ca50a7 Mon Sep 17 00:00:00 2001 From: Rushil Perera Date: Mon, 1 Dec 2025 02:55:20 -0500 Subject: [PATCH] refactor!: migrate from REST API to GraphQL - Replace OpenAPI/REST endpoints with a single route. - Remove and Swagger UI configuration. - Disable OpenAPI schema extensions in Zod types. - Refactor to be request-agnostic. - Update episode URL fetching to return standardized success/failure objects. - Update project dependencies. --- package.json | 10 + patches/blurhash.patch | 30 + pnpm-lock.yaml | 762 +++++++++++++++++- pnpm-workspace.yaml | 2 + .../episodes/getEpisodeUrl/aniwatch.ts | 8 +- .../episodes/markEpisodeAsWatched/index.ts | 2 +- src/controllers/watch-status/index.ts | 1 - src/graphql/context.ts | 22 + src/graphql/index.ts | 41 + src/graphql/resolvers/image.ts | 47 ++ src/graphql/resolvers/index.ts | 27 + .../mutations/markEpisodeAsWatched.ts | 55 ++ .../resolvers/mutations/updateToken.ts | 31 + .../resolvers/mutations/updateWatchStatus.ts | 44 + .../resolvers/queries/episodeStream.ts | 16 + src/graphql/resolvers/queries/healthCheck.ts | 9 + .../resolvers/queries/popularBrowse.ts | 31 + .../resolvers/queries/popularByCategory.ts | 33 + src/graphql/resolvers/queries/search.ts | 30 + src/graphql/resolvers/queries/title.ts | 33 + src/graphql/resolvers/title.ts | 12 + src/graphql/schema.ts | 206 +++++ src/index.ts | 63 +- src/libs/anilist/anilist-do.ts | 3 + src/types/episode/fetch-url-response.ts | 11 +- src/types/episode/index.ts | 2 +- src/types/schema.ts | 26 +- vitest.config.ts | 24 + 28 files changed, 1480 insertions(+), 101 deletions(-) create mode 100644 patches/blurhash.patch create mode 100644 pnpm-workspace.yaml create mode 100644 src/graphql/context.ts create mode 100644 src/graphql/index.ts create mode 100644 src/graphql/resolvers/image.ts create mode 100644 src/graphql/resolvers/index.ts create mode 100644 src/graphql/resolvers/mutations/markEpisodeAsWatched.ts create mode 100644 src/graphql/resolvers/mutations/updateToken.ts create mode 100644 src/graphql/resolvers/mutations/updateWatchStatus.ts create mode 100644 src/graphql/resolvers/queries/episodeStream.ts create mode 100644 src/graphql/resolvers/queries/healthCheck.ts create mode 100644 src/graphql/resolvers/queries/popularBrowse.ts create mode 100644 src/graphql/resolvers/queries/popularByCategory.ts create mode 100644 src/graphql/resolvers/queries/search.ts create mode 100644 src/graphql/resolvers/queries/title.ts create mode 100644 src/graphql/resolvers/title.ts create mode 100644 src/graphql/schema.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index 31a5201..232fe34 100644 --- a/package.json +++ b/package.json @@ -23,15 +23,19 @@ "@hono/swagger-ui": "^0.5.1", "@hono/zod-openapi": "^0.19.5", "@hono/zod-validator": "^0.2.2", + "blurhash": "^2.0.5", "drizzle-orm": "^0.44.7", "gql.tada": "^1.8.10", "graphql": "^16.12.0", "graphql-request": "^7.1.2", + "graphql-yoga": "^5.17.0", "hono": "^4.7.7", "jose": "^5.10.0", + "jpeg-js": "^0.4.4", "lodash.isequal": "^4.5.0", "lodash.mapkeys": "^4.6.0", "luxon": "^3.6.1", + "pngjs": "^7.0.0", "zod": "^3.24.3" }, "devDependencies": { @@ -43,6 +47,11 @@ "@types/lodash.isequal": "^4.5.8", "@types/lodash.mapkeys": "^4.6.9", "@types/luxon": "^3.6.2", + "@types/node": "^24.10.1", + "@types/pngjs": "^6.0.5", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/runner": "^3.2.4", + "@vitest/snapshot": "^3.2.4", "cloudflare": "^5.2.0", "dotenv": "^17.2.3", "drizzle-kit": "^0.31.7", @@ -58,6 +67,7 @@ "tsx": "^4.20.6", "typescript": "^5.8.3", "util": "^0.12.5", + "vitest": "^3.2.4", "wrangler": "^4.51.0", "zx": "8.1.5" }, diff --git a/patches/blurhash.patch b/patches/blurhash.patch new file mode 100644 index 0000000..7c822a9 --- /dev/null +++ b/patches/blurhash.patch @@ -0,0 +1,30 @@ +diff --git a/CHANGELOG.md b/CHANGELOG.md +deleted file mode 100644 +index f793ae02ac3104ed8272b06e4067edde2944a1b9..0000000000000000000000000000000000000000 +diff --git a/dist/esm/index.js b/dist/esm/index.js +index 254eb7a0a33eba9f6622552cfaa88db9c01ab73a..06380b72abb031372b5b176078bb7199f62d62d1 100644 +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -1,2 +1 @@ +-var q=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],x=t=>{let e=0;for(let r=0;r{var r="";for(let n=1;n<=e;n++){let l=Math.floor(t)/Math.pow(83,e-n)%83;r+=q[Math.floor(l)]}return r};var f=t=>{let e=t/255;return e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)},h=t=>{let e=Math.max(0,Math.min(1,t));return e<=.0031308?Math.trunc(e*12.92*255+.5):Math.trunc((1.055*Math.pow(e,.4166666666666667)-.055)*255+.5)},F=t=>t<0?-1:1,M=(t,e)=>F(t)*Math.pow(Math.abs(t),e);var d=class extends Error{constructor(e){super(e),this.name="ValidationError",this.message=e}};var C=t=>{if(!t||t.length<6)throw new d("The blurhash string must be at least 6 characters");let e=x(t[0]),r=Math.floor(e/9)+1,n=e%9+1;if(t.length!==4+2*n*r)throw new d(`blurhash length mismatch: length is ${t.length} but it should be ${4+2*n*r}`)},N=t=>{try{C(t)}catch(e){return{result:!1,errorReason:e.message}}return{result:!0}},z=t=>{let e=t>>16,r=t>>8&255,n=t&255;return[f(e),f(r),f(n)]},L=(t,e)=>{let r=Math.floor(t/361),n=Math.floor(t/19)%19,l=t%19;return[M((r-9)/9,2)*e,M((n-9)/9,2)*e,M((l-9)/9,2)*e]},U=(t,e,r,n)=>{C(t),n=n|1;let l=x(t[0]),m=Math.floor(l/9)+1,b=l%9+1,i=(x(t[1])+1)/166,u=new Array(b*m);for(let o=0;o{let l=0,m=0,b=0,g=e*A;for(let u=0;u{let e=h(t[0]),r=h(t[1]),n=h(t[2]);return(e<<16)+(r<<8)+n},H=(t,e)=>{let r=Math.floor(Math.max(0,Math.min(18,Math.floor(M(t[0]/e,.5)*9+9.5)))),n=Math.floor(Math.max(0,Math.min(18,Math.floor(M(t[1]/e,.5)*9+9.5)))),l=Math.floor(Math.max(0,Math.min(18,Math.floor(M(t[2]/e,.5)*9+9.5))));return r*19*19+n*19+l},O=(t,e,r,n,l)=>{if(n<1||n>9||l<1||l>9)throw new d("BlurHash must have between 1 and 9 components");if(e*r*4!==t.length)throw new d("Width and height must match the pixels array");let m=[];for(let s=0;sa*Math.cos(Math.PI*o*B/e)*Math.cos(Math.PI*s*R/r));m.push(y)}let b=m[0],g=m.slice(1),i="",u=n-1+(l-1)*9;i+=p(u,1);let c;if(g.length>0){let s=Math.max(...g.map(a=>Math.max(...a))),o=Math.floor(Math.max(0,Math.min(82,Math.floor(s*166-.5))));c=(o+1)/166,i+=p(o,1)}else c=1,i+=p(0,1);return i+=p($(b),4),g.forEach(s=>{i+=p(H(s,c),2)}),i},S=O;export{d as ValidationError,j as decode,S as encode,N as isBlurhashValid}; +-//# sourceMappingURL=index.js.map +\ No newline at end of file ++var A=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],d=t=>{let r=0;for(let a=0;a{var a="";for(let l=1;l<=r;l++){let o=Math.floor(t)/Math.pow(83,r-l)%83;a+=A[Math.floor(o)]}return a},c=t=>{let r=t/255;return r<=.04045?r/12.92:Math.pow((r+.055)/1.055,2.4)},g=t=>{let r=Math.max(0,Math.min(1,t));return r<=.0031308?Math.trunc(r*12.92*255+.5):Math.trunc((1.055*Math.pow(r,.4166666666666667)-.055)*255+.5)},O=t=>t<0?-1:1,w=(t,r)=>O(t)*Math.pow(Math.abs(t),r),p=class extends Error{constructor(t){super(t),this.name="ValidationError",this.message=t}},B=t=>{if(!t||t.length<6)throw new p("The blurhash string must be at least 6 characters");let r=d(t[0]),a=Math.floor(r/9)+1,l=r%9+1;if(t.length!==4+2*l*a)throw new p(`blurhash length mismatch: length is ${t.length} but it should be ${4+2*l*a}`)},R=t=>{try{B(t)}catch(r){return{result:!1,errorReason:r.message}}return{result:!0}},T=t=>{let r=t>>16,a=t>>8&255,l=t&255;return[c(r),c(a),c(l)]},U=(t,r)=>{let a=Math.floor(t/361),l=Math.floor(t/19)%19,o=t%19;return[w((a-9)/9,2)*r,w((l-9)/9,2)*r,w((o-9)/9,2)*r]},j=(t,r,a,l)=>{B(t),l=l|1;let o=d(t[0]),i=Math.floor(o/9)+1,u=o%9+1,m=(d(t[1])+1)/166,n=new Array(u*i);for(let e=0;e{let o=0,i=0,u=0,m=r*$;for(let s=0;s{let r=g(t[0]),a=g(t[1]),l=g(t[2]);return(r<<16)+(a<<8)+l},F=(t,r)=>{let a=Math.floor(Math.max(0,Math.min(18,Math.floor(w(t[0]/r,.5)*9+9.5)))),l=Math.floor(Math.max(0,Math.min(18,Math.floor(w(t[1]/r,.5)*9+9.5)))),o=Math.floor(Math.max(0,Math.min(18,Math.floor(w(t[2]/r,.5)*9+9.5))));return a*19*19+l*19+o},G=(t,r,a,l,o)=>{if(l<1||l>9||o<1||o>9)throw new p("BlurHash must have between 1 and 9 components");if(Math.floor(r*a*4)!==t.length)throw new p("Width and height must match the pixels array");let i=[];for(let e=0;ef*Math.cos(Math.PI*h*v/r)*Math.cos(Math.PI*e*I/a));i.push(x)}let u=i[0],m=i.slice(1),n="",s=l-1+(o-1)*9;n+=b(s,1);let M;if(m.length>0){let e=Math.max(...m.map(f=>Math.max(...f))),h=Math.floor(Math.max(0,Math.min(82,Math.floor(e*166-.5))));M=(h+1)/166,n+=b(h,1)}else M=1,n+=b(0,1);return n+=b(D(u),4),m.forEach(e=>{n+=b(F(e,M),2)}),n},L=G;export{p as ValidationError,q as decode,L as encode,R as isBlurhashValid}; +diff --git a/dist/index.js b/dist/index.js +index fe46957ffed377f20992b86da266ce679c515802..075ab8fe648c9a34edcee9a842eb00c34eaa5179 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -1,2 +1 @@ +-var q=Object.defineProperty;var U=Object.getOwnPropertyDescriptor;var j=Object.getOwnPropertyNames;var D=Object.prototype.hasOwnProperty;var $=(t,e)=>{for(var r in e)q(t,r,{get:e[r],enumerable:!0})},H=(t,e,r,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of j(e))!D.call(t,s)&&s!==r&&q(t,s,{get:()=>e[s],enumerable:!(n=U(e,s))||n.enumerable});return t};var O=t=>H(q({},"__esModule",{value:!0}),t);var _={};$(_,{ValidationError:()=>b,decode:()=>I,encode:()=>F,isBlurhashValid:()=>V});module.exports=O(_);var C=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],x=t=>{let e=0;for(let r=0;r{var r="";for(let n=1;n<=e;n++){let s=Math.floor(t)/Math.pow(83,e-n)%83;r+=C[Math.floor(s)]}return r};var h=t=>{let e=t/255;return e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)},M=t=>{let e=Math.max(0,Math.min(1,t));return e<=.0031308?Math.trunc(e*12.92*255+.5):Math.trunc((1.055*Math.pow(e,.4166666666666667)-.055)*255+.5)},S=t=>t<0?-1:1,d=(t,e)=>S(t)*Math.pow(Math.abs(t),e);var b=class extends Error{constructor(e){super(e),this.name="ValidationError",this.message=e}};var A=t=>{if(!t||t.length<6)throw new b("The blurhash string must be at least 6 characters");let e=x(t[0]),r=Math.floor(e/9)+1,n=e%9+1;if(t.length!==4+2*n*r)throw new b(`blurhash length mismatch: length is ${t.length} but it should be ${4+2*n*r}`)},V=t=>{try{A(t)}catch(e){return{result:!1,errorReason:e.message}}return{result:!0}},W=t=>{let e=t>>16,r=t>>8&255,n=t&255;return[h(e),h(r),h(n)]},k=(t,e)=>{let r=Math.floor(t/361),n=Math.floor(t/19)%19,s=t%19;return[d((r-9)/9,2)*e,d((n-9)/9,2)*e,d((s-9)/9,2)*e]},J=(t,e,r,n)=>{A(t),n=n|1;let s=x(t[0]),m=Math.floor(s/9)+1,f=s%9+1,i=(x(t[1])+1)/166,u=new Array(f*m);for(let o=0;o{let s=0,m=0,f=0,g=e*E;for(let u=0;u{let e=M(t[0]),r=M(t[1]),n=M(t[2]);return(e<<16)+(r<<8)+n},X=(t,e)=>{let r=Math.floor(Math.max(0,Math.min(18,Math.floor(d(t[0]/e,.5)*9+9.5)))),n=Math.floor(Math.max(0,Math.min(18,Math.floor(d(t[1]/e,.5)*9+9.5)))),s=Math.floor(Math.max(0,Math.min(18,Math.floor(d(t[2]/e,.5)*9+9.5))));return r*19*19+n*19+s},Z=(t,e,r,n,s)=>{if(n<1||n>9||s<1||s>9)throw new b("BlurHash must have between 1 and 9 components");if(e*r*4!==t.length)throw new b("Width and height must match the pixels array");let m=[];for(let a=0;al*Math.cos(Math.PI*o*B/e)*Math.cos(Math.PI*a*R/r));m.push(y)}let f=m[0],g=m.slice(1),i="",u=n-1+(s-1)*9;i+=p(u,1);let c;if(g.length>0){let a=Math.max(...g.map(l=>Math.max(...l))),o=Math.floor(Math.max(0,Math.min(82,Math.floor(a*166-.5))));c=(o+1)/166,i+=p(o,1)}else c=1,i+=p(0,1);return i+=p(Q(f),4),g.forEach(a=>{i+=p(X(a,c),2)}),i},F=Z;0&&(module.exports={ValidationError,decode,encode,isBlurhashValid}); +-//# sourceMappingURL=index.js.map +\ No newline at end of file ++var q=Object.defineProperty,U=Object.getOwnPropertyDescriptor,j=Object.getOwnPropertyNames,D=Object.prototype.hasOwnProperty,$=(t,e)=>{for(var r in e)q(t,r,{get:e[r],enumerable:!0})},H=(t,e,r,a)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of j(e))!D.call(t,o)&&o!==r&&q(t,o,{get:()=>e[o],enumerable:!(a=U(e,o))||a.enumerable});return t},O=t=>H(q({},"__esModule",{value:!0}),t),_={};$(_,{ValidationError:()=>b,decode:()=>I,encode:()=>F,isBlurhashValid:()=>V}),module.exports=O(_);var C=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],x=t=>{let e=0;for(let r=0;r{var r="";for(let a=1;a<=e;a++){let o=Math.floor(t)/Math.pow(83,e-a)%83;r+=C[Math.floor(o)]}return r},h=t=>{let e=t/255;return e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)},M=t=>{let e=Math.max(0,Math.min(1,t));return e<=.0031308?Math.trunc(e*12.92*255+.5):Math.trunc((1.055*Math.pow(e,.4166666666666667)-.055)*255+.5)},S=t=>t<0?-1:1,d=(t,e)=>S(t)*Math.pow(Math.abs(t),e),b=class extends Error{constructor(t){super(t),this.name="ValidationError",this.message=t}},A=t=>{if(!t||t.length<6)throw new b("The blurhash string must be at least 6 characters");let e=x(t[0]),r=Math.floor(e/9)+1,a=e%9+1;if(t.length!==4+2*a*r)throw new b(`blurhash length mismatch: length is ${t.length} but it should be ${4+2*a*r}`)},V=t=>{try{A(t)}catch(e){return{result:!1,errorReason:e.message}}return{result:!0}},W=t=>{let e=t>>16,r=t>>8&255,a=t&255;return[h(e),h(r),h(a)]},k=(t,e)=>{let r=Math.floor(t/361),a=Math.floor(t/19)%19,o=t%19;return[d((r-9)/9,2)*e,d((a-9)/9,2)*e,d((o-9)/9,2)*e]},J=(t,e,r,a)=>{A(t),a=a|1;let o=x(t[0]),m=Math.floor(o/9)+1,c=o%9+1,g=(x(t[1])+1)/166,s=new Array(c*m);for(let l=0;l{let o=0,m=0,c=0,g=e*E;for(let f=0;f{let e=M(t[0]),r=M(t[1]),a=M(t[2]);return(e<<16)+(r<<8)+a},X=(t,e)=>{let r=Math.floor(Math.max(0,Math.min(18,Math.floor(d(t[0]/e,.5)*9+9.5)))),a=Math.floor(Math.max(0,Math.min(18,Math.floor(d(t[1]/e,.5)*9+9.5)))),o=Math.floor(Math.max(0,Math.min(18,Math.floor(d(t[2]/e,.5)*9+9.5))));return r*19*19+a*19+o},Z=(t,e,r,a,o)=>{if(a<1||a>9||o<1||o>9)throw new b("BlurHash must have between 1 and 9 components");if(Math.floor(e*r*4)!==t.length)throw new b("Width and height must match the pixels array");let m=[];for(let l=0;li*Math.cos(Math.PI*n*y/e)*Math.cos(Math.PI*l*B/r));m.push(w)}let c=m[0],g=m.slice(1),s="",f=a-1+(o-1)*9;s+=p(f,1);let u;if(g.length>0){let l=Math.max(...g.map(i=>Math.max(...i))),n=Math.floor(Math.max(0,Math.min(82,Math.floor(l*166-.5))));u=(n+1)/166,s+=p(n,1)}else u=1,s+=p(0,1);return s+=p(Q(c),4),g.forEach(l=>{s+=p(X(l,u),2)}),s},F=Z; +diff --git a/dist/index.mjs b/dist/index.mjs +index 0feea2d84b8d1ed0f05386aaf9bb1d278aed3d0a..06380b72abb031372b5b176078bb7199f62d62d1 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -1,2 +1 @@ +-var q=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],x=t=>{let e=0;for(let r=0;r{var r="";for(let n=1;n<=e;n++){let l=Math.floor(t)/Math.pow(83,e-n)%83;r+=q[Math.floor(l)]}return r};var f=t=>{let e=t/255;return e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)},h=t=>{let e=Math.max(0,Math.min(1,t));return e<=.0031308?Math.trunc(e*12.92*255+.5):Math.trunc((1.055*Math.pow(e,.4166666666666667)-.055)*255+.5)},F=t=>t<0?-1:1,M=(t,e)=>F(t)*Math.pow(Math.abs(t),e);var d=class extends Error{constructor(e){super(e),this.name="ValidationError",this.message=e}};var C=t=>{if(!t||t.length<6)throw new d("The blurhash string must be at least 6 characters");let e=x(t[0]),r=Math.floor(e/9)+1,n=e%9+1;if(t.length!==4+2*n*r)throw new d(`blurhash length mismatch: length is ${t.length} but it should be ${4+2*n*r}`)},N=t=>{try{C(t)}catch(e){return{result:!1,errorReason:e.message}}return{result:!0}},z=t=>{let e=t>>16,r=t>>8&255,n=t&255;return[f(e),f(r),f(n)]},L=(t,e)=>{let r=Math.floor(t/361),n=Math.floor(t/19)%19,l=t%19;return[M((r-9)/9,2)*e,M((n-9)/9,2)*e,M((l-9)/9,2)*e]},U=(t,e,r,n)=>{C(t),n=n|1;let l=x(t[0]),m=Math.floor(l/9)+1,b=l%9+1,i=(x(t[1])+1)/166,u=new Array(b*m);for(let o=0;o{let l=0,m=0,b=0,g=e*A;for(let u=0;u{let e=h(t[0]),r=h(t[1]),n=h(t[2]);return(e<<16)+(r<<8)+n},H=(t,e)=>{let r=Math.floor(Math.max(0,Math.min(18,Math.floor(M(t[0]/e,.5)*9+9.5)))),n=Math.floor(Math.max(0,Math.min(18,Math.floor(M(t[1]/e,.5)*9+9.5)))),l=Math.floor(Math.max(0,Math.min(18,Math.floor(M(t[2]/e,.5)*9+9.5))));return r*19*19+n*19+l},O=(t,e,r,n,l)=>{if(n<1||n>9||l<1||l>9)throw new d("BlurHash must have between 1 and 9 components");if(e*r*4!==t.length)throw new d("Width and height must match the pixels array");let m=[];for(let s=0;sa*Math.cos(Math.PI*o*B/e)*Math.cos(Math.PI*s*R/r));m.push(y)}let b=m[0],g=m.slice(1),i="",u=n-1+(l-1)*9;i+=p(u,1);let c;if(g.length>0){let s=Math.max(...g.map(a=>Math.max(...a))),o=Math.floor(Math.max(0,Math.min(82,Math.floor(s*166-.5))));c=(o+1)/166,i+=p(o,1)}else c=1,i+=p(0,1);return i+=p($(b),4),g.forEach(s=>{i+=p(H(s,c),2)}),i},S=O;export{d as ValidationError,j as decode,S as encode,N as isBlurhashValid}; +-//# sourceMappingURL=index.mjs.map +\ No newline at end of file ++var A=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],d=t=>{let r=0;for(let a=0;a{var a="";for(let l=1;l<=r;l++){let o=Math.floor(t)/Math.pow(83,r-l)%83;a+=A[Math.floor(o)]}return a},c=t=>{let r=t/255;return r<=.04045?r/12.92:Math.pow((r+.055)/1.055,2.4)},g=t=>{let r=Math.max(0,Math.min(1,t));return r<=.0031308?Math.trunc(r*12.92*255+.5):Math.trunc((1.055*Math.pow(r,.4166666666666667)-.055)*255+.5)},O=t=>t<0?-1:1,w=(t,r)=>O(t)*Math.pow(Math.abs(t),r),p=class extends Error{constructor(t){super(t),this.name="ValidationError",this.message=t}},B=t=>{if(!t||t.length<6)throw new p("The blurhash string must be at least 6 characters");let r=d(t[0]),a=Math.floor(r/9)+1,l=r%9+1;if(t.length!==4+2*l*a)throw new p(`blurhash length mismatch: length is ${t.length} but it should be ${4+2*l*a}`)},R=t=>{try{B(t)}catch(r){return{result:!1,errorReason:r.message}}return{result:!0}},T=t=>{let r=t>>16,a=t>>8&255,l=t&255;return[c(r),c(a),c(l)]},U=(t,r)=>{let a=Math.floor(t/361),l=Math.floor(t/19)%19,o=t%19;return[w((a-9)/9,2)*r,w((l-9)/9,2)*r,w((o-9)/9,2)*r]},j=(t,r,a,l)=>{B(t),l=l|1;let o=d(t[0]),i=Math.floor(o/9)+1,u=o%9+1,m=(d(t[1])+1)/166,n=new Array(u*i);for(let e=0;e{let o=0,i=0,u=0,m=r*$;for(let s=0;s{let r=g(t[0]),a=g(t[1]),l=g(t[2]);return(r<<16)+(a<<8)+l},F=(t,r)=>{let a=Math.floor(Math.max(0,Math.min(18,Math.floor(w(t[0]/r,.5)*9+9.5)))),l=Math.floor(Math.max(0,Math.min(18,Math.floor(w(t[1]/r,.5)*9+9.5)))),o=Math.floor(Math.max(0,Math.min(18,Math.floor(w(t[2]/r,.5)*9+9.5))));return a*19*19+l*19+o},G=(t,r,a,l,o)=>{if(l<1||l>9||o<1||o>9)throw new p("BlurHash must have between 1 and 9 components");if(Math.floor(r*a*4)!==t.length)throw new p("Width and height must match the pixels array");let i=[];for(let e=0;ef*Math.cos(Math.PI*h*v/r)*Math.cos(Math.PI*e*I/a));i.push(x)}let u=i[0],m=i.slice(1),n="",s=l-1+(o-1)*9;n+=b(s,1);let M;if(m.length>0){let e=Math.max(...m.map(f=>Math.max(...f))),h=Math.floor(Math.max(0,Math.min(82,Math.floor(e*166-.5))));M=(h+1)/166,n+=b(h,1)}else M=1,n+=b(0,1);return n+=b(D(u),4),m.forEach(e=>{n+=b(F(e,M),2)}),n},L=G;export{p as ValidationError,q as decode,L as encode,R as isBlurhashValid}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f95a78e..35273d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + blurhash: + hash: 814a3f2d2a39f286e4f86929789e0ada33593d88cf2fb1eb3cf2cc2425c7dfaf + path: patches/blurhash.patch + importers: .: dependencies: @@ -22,6 +27,9 @@ importers: "@hono/zod-validator": specifier: ^0.2.2 version: 0.2.2(hono@4.10.4)(zod@3.25.76) + blurhash: + specifier: ^2.0.5 + version: 2.0.5(patch_hash=814a3f2d2a39f286e4f86929789e0ada33593d88cf2fb1eb3cf2cc2425c7dfaf) drizzle-orm: specifier: ^0.44.7 version: 0.44.7(@cloudflare/workers-types@4.20251014.0)(@libsql/client@0.15.4)(bun-types@1.3.1(@types/react@19.2.2)) @@ -34,12 +42,18 @@ importers: graphql-request: specifier: ^7.1.2 version: 7.3.1(graphql@16.12.0) + graphql-yoga: + specifier: ^5.17.0 + version: 5.17.0(graphql@16.12.0) hono: specifier: ^4.7.7 version: 4.10.4 jose: specifier: ^5.10.0 version: 5.10.0 + jpeg-js: + specifier: ^0.4.4 + version: 0.4.4 lodash.isequal: specifier: ^4.5.0 version: 4.5.0 @@ -49,6 +63,9 @@ importers: luxon: specifier: ^3.6.1 version: 3.7.2 + pngjs: + specifier: ^7.0.0 + version: 7.0.0 zod: specifier: ^3.24.3 version: 3.25.76 @@ -58,7 +75,7 @@ importers: version: 1.15.0(graphql@16.12.0)(typescript@5.9.3) "@cloudflare/vitest-pool-workers": specifier: ^0.10.7 - version: 0.10.7(@cloudflare/workers-types@4.20251014.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/node@24.9.2)(msw@2.4.3(typescript@5.9.3))(tsx@4.20.6)(yaml@2.8.1)) + version: 0.10.7(@cloudflare/workers-types@4.20251014.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/node@24.10.1)(msw@2.4.3(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) "@cloudflare/workers-types": specifier: ^4.20250423.0 version: 4.20251014.0 @@ -77,6 +94,21 @@ importers: "@types/luxon": specifier: ^3.6.2 version: 3.7.1 + "@types/node": + specifier: ^24.10.1 + version: 24.10.1 + "@types/pngjs": + specifier: ^6.0.5 + version: 6.0.5 + "@vitest/coverage-v8": + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@24.10.1)(msw@2.4.3(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + "@vitest/runner": + specifier: ^3.2.4 + version: 3.2.4 + "@vitest/snapshot": + specifier: ^3.2.4 + version: 3.2.4 cloudflare: specifier: ^5.2.0 version: 5.2.0 @@ -122,6 +154,9 @@ importers: util: specifier: ^0.12.5 version: 0.12.5 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@24.10.1)(msw@2.4.3(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) wrangler: specifier: ^4.51.0 version: 4.51.0(@cloudflare/workers-types@4.20251014.0) @@ -150,6 +185,13 @@ packages: graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 typescript: ^5.0.0 + "@ampproject/remapping@2.3.0": + resolution: + { + integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==, + } + engines: { node: ">=6.0.0" } + "@asteasolutions/zod-to-openapi@7.3.4": resolution: { @@ -257,6 +299,13 @@ packages: } engines: { node: ">=6.9.0" } + "@bcoe/v8-coverage@1.0.2": + resolution: + { + integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==, + } + engines: { node: ">=18" } + "@bundled-es-modules/cookie@2.0.1": resolution: { @@ -445,6 +494,27 @@ packages: integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==, } + "@envelop/core@5.4.0": + resolution: + { + integrity: sha512-/1fat63pySE8rw/dZZArEVytLD90JApY85deDJ0/34gm+yhQ3k70CloSUevxoOE4YCGveG3s9SJJfQeeB4NAtQ==, + } + engines: { node: ">=18.0.0" } + + "@envelop/instrumentation@1.0.0": + resolution: + { + integrity: sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==, + } + engines: { node: ">=18.0.0" } + + "@envelop/types@5.2.1": + resolution: + { + integrity: sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==, + } + engines: { node: ">=18.0.0" } + "@esbuild-kit/core-utils@3.3.2": resolution: { @@ -1350,6 +1420,12 @@ packages: cpu: [x64] os: [win32] + "@fastify/busboy@3.2.0": + resolution: + { + integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==, + } + "@gql.tada/cli-utils@1.7.1": resolution: { @@ -1376,6 +1452,42 @@ packages: graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 typescript: ^5.0.0 + "@graphql-tools/executor@1.5.0": + resolution: + { + integrity: sha512-3HzAxfexmynEWwRB56t/BT+xYKEYLGPvJudR1jfs+XZX8bpfqujEhqVFoxmkpEE8BbFcKuBNoQyGkTi1eFJ+hA==, + } + engines: { node: ">=16.0.0" } + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + "@graphql-tools/merge@9.1.6": + resolution: + { + integrity: sha512-bTnP+4oom4nDjmkS3Ykbe+ljAp/RIiWP3R35COMmuucS24iQxGLa9Hn8VMkLIoaoPxgz6xk+dbC43jtkNsFoBw==, + } + engines: { node: ">=16.0.0" } + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + "@graphql-tools/schema@10.0.30": + resolution: + { + integrity: sha512-yPXU17uM/LR90t92yYQqn9mAJNOVZJc0nQtYeZyZeQZeQjwIGlTubvvoDL0fFVk+wZzs4YQOgds2NwSA4npodA==, + } + engines: { node: ">=16.0.0" } + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + "@graphql-tools/utils@10.11.0": + resolution: + { + integrity: sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q==, + } + engines: { node: ">=16.0.0" } + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + "@graphql-typed-document-node/core@3.2.0": resolution: { @@ -1384,6 +1496,27 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + "@graphql-yoga/logger@2.0.1": + resolution: + { + integrity: sha512-Nv0BoDGLMg9QBKy9cIswQ3/6aKaKjlTh87x3GiBg2Z4RrjyrM48DvOOK0pJh1C1At+b0mUIM67cwZcFTDLN4sA==, + } + engines: { node: ">=18.0.0" } + + "@graphql-yoga/subscription@5.0.5": + resolution: + { + integrity: sha512-oCMWOqFs6QV96/NZRt/ZhTQvzjkGB4YohBOpKM4jH/lDT4qb7Lex/aGCxpi/JD9njw3zBBtMqxbaC22+tFHVvw==, + } + engines: { node: ">=18.0.0" } + + "@graphql-yoga/typed-event-target@3.0.2": + resolution: + { + integrity: sha512-ZpJxMqB+Qfe3rp6uszCQoag4nSw42icURnBRfFYSOmTgEeOe4rD0vYlbA8spvCu2TlCesNTlEN9BLWtQqLxabA==, + } + engines: { node: ">=18.0.0" } + "@haverstack/axios-fetch-adapter@0.12.0": resolution: { @@ -1625,6 +1758,20 @@ packages: } engines: { node: ">=18" } + "@isaacs/cliui@8.0.2": + resolution: + { + integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==, + } + engines: { node: ">=12" } + + "@istanbuljs/schema@0.1.3": + resolution: + { + integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==, + } + engines: { node: ">=8" } + "@jridgewell/gen-mapping@0.3.13": resolution: { @@ -1638,6 +1785,12 @@ packages: } engines: { node: ">=6.0.0" } + "@jridgewell/source-map@0.3.11": + resolution: + { + integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==, + } + "@jridgewell/sourcemap-codec@1.5.5": resolution: { @@ -1811,6 +1964,13 @@ packages: integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==, } + "@pkgjs/parseargs@0.11.0": + resolution: + { + integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, + } + engines: { node: ">=14" } + "@poppinss/colors@4.1.5": resolution: { @@ -1829,6 +1989,12 @@ packages: integrity: sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==, } + "@repeaterjs/repeater@3.0.6": + resolution: + { + integrity: sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==, + } + "@rollup/rollup-android-arm-eabi@4.53.3": resolution: { @@ -2138,12 +2304,24 @@ packages: integrity: sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==, } + "@types/node@24.10.1": + resolution: + { + integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==, + } + "@types/node@24.9.2": resolution: { integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==, } + "@types/pngjs@6.0.5": + resolution: + { + integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==, + } + "@types/react@19.2.2": resolution: { @@ -2174,6 +2352,18 @@ packages: integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==, } + "@vitest/coverage-v8@3.2.4": + resolution: + { + integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==, + } + peerDependencies: + "@vitest/browser": 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + "@vitest/browser": + optional: true + "@vitest/expect@3.2.4": resolution: { @@ -2224,6 +2414,48 @@ packages: integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==, } + "@whatwg-node/disposablestack@0.0.6": + resolution: + { + integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==, + } + engines: { node: ">=18.0.0" } + + "@whatwg-node/events@0.1.2": + resolution: + { + integrity: sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ==, + } + engines: { node: ">=18.0.0" } + + "@whatwg-node/fetch@0.10.13": + resolution: + { + integrity: sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==, + } + engines: { node: ">=18.0.0" } + + "@whatwg-node/node-fetch@0.8.4": + resolution: + { + integrity: sha512-AlKLc57loGoyYlrzDbejB9EeR+pfdJdGzbYnkEuZaGekFboBwzfVYVMsy88PMriqPI1ORpiGYGgSSWpx7a2sDA==, + } + engines: { node: ">=18.0.0" } + + "@whatwg-node/promise-helpers@1.3.2": + resolution: + { + integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==, + } + engines: { node: ">=16.0.0" } + + "@whatwg-node/server@0.10.17": + resolution: + { + integrity: sha512-QxI+HQfJeI/UscFNCTcSri6nrHP25mtyAMbhEri7W2ctdb3EsorPuJz7IovSgNjvKVs73dg9Fmayewx1O2xOxA==, + } + engines: { node: ">=18.0.0" } + abort-controller@3.0.0: resolution: { @@ -2246,6 +2478,14 @@ packages: engines: { node: ">=0.4.0" } hasBin: true + acorn@8.15.0: + resolution: + { + integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==, + } + engines: { node: ">=0.4.0" } + hasBin: true + agent-base@7.1.4: resolution: { @@ -2315,6 +2555,12 @@ packages: } engines: { node: ">=12" } + ast-v8-to-istanbul@0.3.8: + resolution: + { + integrity: sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==, + } + asynckit@0.4.0: resolution: { @@ -2352,6 +2598,12 @@ packages: integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==, } + blurhash@2.0.5: + resolution: + { + integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==, + } + boolbase@1.0.0: resolution: { @@ -2552,6 +2804,12 @@ packages: } engines: { node: ">=18" } + commander@2.20.3: + resolution: + { + integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==, + } + cookie@0.7.2: resolution: { @@ -2566,6 +2824,13 @@ packages: } engines: { node: ">=18" } + cross-inspect@1.0.1: + resolution: + { + integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==, + } + engines: { node: ">=16.0.0" } + cross-spawn@7.0.6: resolution: { @@ -2799,6 +3064,12 @@ packages: } engines: { node: ">= 0.4" } + eastasianwidth@0.2.0: + resolution: + { + integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, + } + ecdsa-sig-formatter@1.0.11: resolution: { @@ -2817,6 +3088,12 @@ packages: integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==, } + emoji-regex@9.2.2: + resolution: + { + integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==, + } + encoding-sniffer@0.2.1: resolution: { @@ -3035,6 +3312,13 @@ packages: } engines: { node: ">= 0.4" } + foreground-child@3.3.1: + resolution: + { + integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==, + } + engines: { node: ">=14" } + form-data-encoder@1.7.2: resolution: { @@ -3144,6 +3428,13 @@ packages: integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==, } + glob@10.5.0: + resolution: + { + integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==, + } + hasBin: true + globals@11.12.0: resolution: { @@ -3175,6 +3466,15 @@ packages: peerDependencies: graphql: 14 - 16 + graphql-yoga@5.17.0: + resolution: + { + integrity: sha512-Ta2EmITlGzAJTffSA/BvMA5q2THRWeQrP8+Uc4WnSVTBafnQ2vpXdiNZ7Zsj57cxSw4+WEW2PTgEKGtJ4GcS3A==, + } + engines: { node: ">=18.0.0" } + peerDependencies: + graphql: ^15.2.0 || ^16.0.0 + graphql@16.12.0: resolution: { @@ -3236,6 +3536,12 @@ packages: } engines: { node: ">=16.9.0" } + html-escaper@2.0.2: + resolution: + { + integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==, + } + htmlparser2@10.0.0: resolution: { @@ -3392,6 +3698,40 @@ packages: integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, } + istanbul-lib-coverage@3.2.2: + resolution: + { + integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==, + } + engines: { node: ">=8" } + + istanbul-lib-report@3.0.1: + resolution: + { + integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==, + } + engines: { node: ">=10" } + + istanbul-lib-source-maps@5.0.6: + resolution: + { + integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==, + } + engines: { node: ">=10" } + + istanbul-reports@3.2.0: + resolution: + { + integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==, + } + engines: { node: ">=8" } + + jackspeak@3.4.3: + resolution: + { + integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==, + } + javascript-natural-sort@0.7.1: resolution: { @@ -3404,6 +3744,12 @@ packages: integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==, } + jpeg-js@0.4.4: + resolution: + { + integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==, + } + js-base64@3.7.8: resolution: { @@ -3519,6 +3865,12 @@ packages: integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==, } + lru-cache@10.4.3: + resolution: + { + integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==, + } + luxon@3.7.2: resolution: { @@ -3532,6 +3884,19 @@ packages: integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, } + magicast@0.3.5: + resolution: + { + integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==, + } + + make-dir@4.0.0: + resolution: + { + integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==, + } + engines: { node: ">=10" } + math-intrinsics@1.1.0: resolution: { @@ -3618,6 +3983,13 @@ packages: } engines: { node: ">=16 || 14 >=14.17" } + minipass@7.1.2: + resolution: + { + integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==, + } + engines: { node: ">=16 || 14 >=14.17" } + mkdirp@3.0.1: resolution: { @@ -3726,6 +4098,12 @@ packages: integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==, } + package-json-from-dist@1.0.1: + resolution: + { + integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==, + } + parse5-htmlparser2-tree-adapter@7.1.0: resolution: { @@ -3764,6 +4142,13 @@ packages: } engines: { node: ">=12" } + path-scurry@1.11.1: + resolution: + { + integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==, + } + engines: { node: ">=16 || 14 >=14.18" } + path-to-regexp@6.3.0: resolution: { @@ -3811,6 +4196,13 @@ packages: engines: { node: ">=0.10" } hasBin: true + pngjs@7.0.0: + resolution: + { + integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==, + } + engines: { node: ">=14.19.0" } + possible-typed-array-names@1.1.0: resolution: { @@ -4087,6 +4479,13 @@ packages: } engines: { node: ">=8" } + string-width@5.1.2: + resolution: + { + integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==, + } + engines: { node: ">=12" } + string-width@7.2.0: resolution: { @@ -4135,6 +4534,21 @@ packages: } engines: { node: ">=8" } + terser@5.44.1: + resolution: + { + integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==, + } + engines: { node: ">=10" } + hasBin: true + + test-exclude@7.0.1: + resolution: + { + integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==, + } + engines: { node: ">=18" } + tinybench@2.9.0: resolution: { @@ -4295,6 +4709,12 @@ packages: integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==, } + urlpattern-polyfill@10.1.0: + resolution: + { + integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==, + } + util@0.12.5: resolution: { @@ -4510,6 +4930,13 @@ packages: } engines: { node: ">=10" } + wrap-ansi@8.1.0: + resolution: + { + integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==, + } + engines: { node: ">=12" } + wrap-ansi@9.0.2: resolution: { @@ -4626,6 +5053,11 @@ snapshots: graphql: 16.12.0 typescript: 5.9.3 + "@ampproject/remapping@2.3.0": + dependencies: + "@jridgewell/gen-mapping": 0.3.13 + "@jridgewell/trace-mapping": 0.3.31 + "@asteasolutions/zod-to-openapi@7.3.4(zod@3.25.76)": dependencies: openapi3-ts: 4.5.0 @@ -4707,6 +5139,8 @@ snapshots: "@babel/helper-string-parser": 7.27.1 "@babel/helper-validator-identifier": 7.28.5 + "@bcoe/v8-coverage@1.0.2": {} + "@bundled-es-modules/cookie@2.0.1": dependencies: cookie: 0.7.2 @@ -4740,7 +5174,7 @@ snapshots: optionalDependencies: workerd: 1.20251125.0 - "@cloudflare/vitest-pool-workers@0.10.7(@cloudflare/workers-types@4.20251014.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/node@24.9.2)(msw@2.4.3(typescript@5.9.3))(tsx@4.20.6)(yaml@2.8.1))": + "@cloudflare/vitest-pool-workers@0.10.7(@cloudflare/workers-types@4.20251014.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/node@24.10.1)(msw@2.4.3(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))": dependencies: "@vitest/runner": 3.2.4 "@vitest/snapshot": 3.2.4 @@ -4749,7 +5183,7 @@ snapshots: devalue: 5.5.0 miniflare: 4.20251109.1 semver: 7.7.3 - vitest: 3.2.4(@types/node@24.9.2)(msw@2.4.3(typescript@5.9.3))(tsx@4.20.6)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.10.1)(msw@2.4.3(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) wrangler: 4.48.0(@cloudflare/workers-types@4.20251014.0) zod: 3.25.76 transitivePeerDependencies: @@ -4811,6 +5245,23 @@ snapshots: tslib: 2.8.1 optional: true + "@envelop/core@5.4.0": + dependencies: + "@envelop/instrumentation": 1.0.0 + "@envelop/types": 5.2.1 + "@whatwg-node/promise-helpers": 1.3.2 + tslib: 2.8.1 + + "@envelop/instrumentation@1.0.0": + dependencies: + "@whatwg-node/promise-helpers": 1.3.2 + tslib: 2.8.1 + + "@envelop/types@5.2.1": + dependencies: + "@whatwg-node/promise-helpers": 1.3.2 + tslib: 2.8.1 + "@esbuild-kit/core-utils@3.3.2": dependencies: esbuild: 0.18.20 @@ -5118,6 +5569,8 @@ snapshots: "@esbuild/win32-x64@0.27.0": optional: true + "@fastify/busboy@3.2.0": {} + "@gql.tada/cli-utils@1.7.1(@0no-co/graphqlsp@1.15.0(graphql@16.12.0)(typescript@5.9.3))(graphql@16.12.0)(typescript@5.9.3)": dependencies: "@0no-co/graphqlsp": 1.15.0(graphql@16.12.0)(typescript@5.9.3) @@ -5131,10 +5584,57 @@ snapshots: graphql: 16.12.0 typescript: 5.9.3 + "@graphql-tools/executor@1.5.0(graphql@16.12.0)": + dependencies: + "@graphql-tools/utils": 10.11.0(graphql@16.12.0) + "@graphql-typed-document-node/core": 3.2.0(graphql@16.12.0) + "@repeaterjs/repeater": 3.0.6 + "@whatwg-node/disposablestack": 0.0.6 + "@whatwg-node/promise-helpers": 1.3.2 + graphql: 16.12.0 + tslib: 2.8.1 + + "@graphql-tools/merge@9.1.6(graphql@16.12.0)": + dependencies: + "@graphql-tools/utils": 10.11.0(graphql@16.12.0) + graphql: 16.12.0 + tslib: 2.8.1 + + "@graphql-tools/schema@10.0.30(graphql@16.12.0)": + dependencies: + "@graphql-tools/merge": 9.1.6(graphql@16.12.0) + "@graphql-tools/utils": 10.11.0(graphql@16.12.0) + graphql: 16.12.0 + tslib: 2.8.1 + + "@graphql-tools/utils@10.11.0(graphql@16.12.0)": + dependencies: + "@graphql-typed-document-node/core": 3.2.0(graphql@16.12.0) + "@whatwg-node/promise-helpers": 1.3.2 + cross-inspect: 1.0.1 + graphql: 16.12.0 + tslib: 2.8.1 + "@graphql-typed-document-node/core@3.2.0(graphql@16.12.0)": dependencies: graphql: 16.12.0 + "@graphql-yoga/logger@2.0.1": + dependencies: + tslib: 2.8.1 + + "@graphql-yoga/subscription@5.0.5": + dependencies: + "@graphql-yoga/typed-event-target": 3.0.2 + "@repeaterjs/repeater": 3.0.6 + "@whatwg-node/events": 0.1.2 + tslib: 2.8.1 + + "@graphql-yoga/typed-event-target@3.0.2": + dependencies: + "@repeaterjs/repeater": 3.0.6 + tslib: 2.8.1 + "@haverstack/axios-fetch-adapter@0.12.0(axios@0.27.2)": dependencies: axios: 0.27.2 @@ -5266,6 +5766,17 @@ snapshots: dependencies: mute-stream: 1.0.0 + "@isaacs/cliui@8.0.2": + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + "@istanbuljs/schema@0.1.3": {} + "@jridgewell/gen-mapping@0.3.13": dependencies: "@jridgewell/sourcemap-codec": 1.5.5 @@ -5273,6 +5784,12 @@ snapshots: "@jridgewell/resolve-uri@3.1.2": {} + "@jridgewell/source-map@0.3.11": + dependencies: + "@jridgewell/gen-mapping": 0.3.13 + "@jridgewell/trace-mapping": 0.3.31 + optional: true + "@jridgewell/sourcemap-codec@1.5.5": {} "@jridgewell/trace-mapping@0.3.31": @@ -5385,6 +5902,9 @@ snapshots: "@open-draft/until@2.1.0": {} + "@pkgjs/parseargs@0.11.0": + optional: true + "@poppinss/colors@4.1.5": dependencies: kleur: 4.1.5 @@ -5397,6 +5917,8 @@ snapshots: "@poppinss/exception@1.2.2": {} + "@repeaterjs/repeater@3.0.6": {} + "@rollup/rollup-android-arm-eabi@4.53.3": optional: true @@ -5512,12 +6034,12 @@ snapshots: "@types/fs-extra@11.0.4": dependencies: "@types/jsonfile": 6.1.4 - "@types/node": 24.9.2 + "@types/node": 24.10.1 optional: true "@types/jsonfile@6.1.4": dependencies: - "@types/node": 24.9.2 + "@types/node": 24.10.1 optional: true "@types/lodash.isequal@4.5.8": @@ -5534,11 +6056,11 @@ snapshots: "@types/mute-stream@0.0.4": dependencies: - "@types/node": 22.18.13 + "@types/node": 24.10.1 "@types/node-fetch@2.6.13": dependencies: - "@types/node": 24.9.2 + "@types/node": 24.10.1 form-data: 4.0.4 "@types/node@18.19.130": @@ -5549,9 +6071,18 @@ snapshots: dependencies: undici-types: 6.21.0 + "@types/node@24.10.1": + dependencies: + undici-types: 7.16.0 + "@types/node@24.9.2": dependencies: undici-types: 7.16.0 + optional: true + + "@types/pngjs@6.0.5": + dependencies: + "@types/node": 24.10.1 "@types/react@19.2.2": dependencies: @@ -5568,6 +6099,25 @@ snapshots: "@types/node": 24.9.2 optional: true + "@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.10.1)(msw@2.4.3(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))": + dependencies: + "@ampproject/remapping": 2.3.0 + "@bcoe/v8-coverage": 1.0.2 + ast-v8-to-istanbul: 0.3.8 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@24.10.1)(msw@2.4.3(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + "@vitest/expect@3.2.4": dependencies: "@types/chai": 5.2.3 @@ -5576,14 +6126,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - "@vitest/mocker@3.2.4(msw@2.4.3(typescript@5.9.3))(vite@7.2.4(@types/node@24.9.2)(tsx@4.20.6)(yaml@2.8.1))": + "@vitest/mocker@3.2.4(msw@2.4.3(typescript@5.9.3))(vite@7.2.4(@types/node@24.10.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))": dependencies: "@vitest/spy": 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.4.3(typescript@5.9.3) - vite: 7.2.4(@types/node@24.9.2)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.4(@types/node@24.10.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) "@vitest/pretty-format@3.2.4": dependencies: @@ -5611,6 +6161,39 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + "@whatwg-node/disposablestack@0.0.6": + dependencies: + "@whatwg-node/promise-helpers": 1.3.2 + tslib: 2.8.1 + + "@whatwg-node/events@0.1.2": + dependencies: + tslib: 2.8.1 + + "@whatwg-node/fetch@0.10.13": + dependencies: + "@whatwg-node/node-fetch": 0.8.4 + urlpattern-polyfill: 10.1.0 + + "@whatwg-node/node-fetch@0.8.4": + dependencies: + "@fastify/busboy": 3.2.0 + "@whatwg-node/disposablestack": 0.0.6 + "@whatwg-node/promise-helpers": 1.3.2 + tslib: 2.8.1 + + "@whatwg-node/promise-helpers@1.3.2": + dependencies: + tslib: 2.8.1 + + "@whatwg-node/server@0.10.17": + dependencies: + "@envelop/instrumentation": 1.0.0 + "@whatwg-node/disposablestack": 0.0.6 + "@whatwg-node/fetch": 0.10.13 + "@whatwg-node/promise-helpers": 1.3.2 + tslib: 2.8.1 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -5619,6 +6202,9 @@ snapshots: acorn@8.14.0: {} + acorn@8.15.0: + optional: true + agent-base@7.1.4: {} agentkeepalive@4.6.0: @@ -5647,6 +6233,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.8: + dependencies: + "@jridgewell/trace-mapping": 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + asynckit@0.4.0: {} available-typed-arrays@1.0.7: @@ -5666,6 +6258,9 @@ snapshots: blake3-wasm@2.1.5: {} + blurhash@2.0.5(patch_hash=814a3f2d2a39f286e4f86929789e0ada33593d88cf2fb1eb3cf2cc2425c7dfaf): + {} + boolbase@1.0.0: {} brace-expansion@2.0.2: @@ -5682,7 +6277,7 @@ snapshots: bun-types@1.3.1(@types/react@19.2.2): dependencies: - "@types/node": 24.9.2 + "@types/node": 24.10.1 "@types/react": 19.2.2 cac@6.7.14: {} @@ -5801,10 +6396,17 @@ snapshots: commander@13.1.0: {} + commander@2.20.3: + optional: true + cookie@0.7.2: {} cookie@1.0.2: {} + cross-inspect@1.0.1: + dependencies: + tslib: 2.8.1 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5890,6 +6492,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -5898,6 +6502,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -6106,6 +6712,11 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data-encoder@1.7.2: {} form-data@4.0.4: @@ -6178,6 +6789,15 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@11.12.0: {} gopd@1.2.0: {} @@ -6199,6 +6819,22 @@ snapshots: "@graphql-typed-document-node/core": 3.2.0(graphql@16.12.0) graphql: 16.12.0 + graphql-yoga@5.17.0(graphql@16.12.0): + dependencies: + "@envelop/core": 5.4.0 + "@envelop/instrumentation": 1.0.0 + "@graphql-tools/executor": 1.5.0(graphql@16.12.0) + "@graphql-tools/schema": 10.0.30(graphql@16.12.0) + "@graphql-tools/utils": 10.11.0(graphql@16.12.0) + "@graphql-yoga/logger": 2.0.1 + "@graphql-yoga/subscription": 5.0.5 + "@whatwg-node/fetch": 0.10.13 + "@whatwg-node/promise-helpers": 1.3.2 + "@whatwg-node/server": 0.10.17 + graphql: 16.12.0 + lru-cache: 10.4.3 + tslib: 2.8.1 + graphql@16.12.0: {} gtoken@7.1.0: @@ -6229,6 +6865,8 @@ snapshots: hono@4.10.4: {} + html-escaper@2.0.2: {} + htmlparser2@10.0.0: dependencies: domelementtype: 2.3.0 @@ -6309,10 +6947,39 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + "@jridgewell/trace-mapping": 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + "@isaacs/cliui": 8.0.2 + optionalDependencies: + "@pkgjs/parseargs": 0.11.0 + javascript-natural-sort@0.7.1: {} jose@5.10.0: {} + jpeg-js@0.4.4: {} + js-base64@3.7.8: optional: true @@ -6395,12 +7062,24 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + luxon@3.7.2: {} magic-string@0.30.21: dependencies: "@jridgewell/sourcemap-codec": 1.5.5 + magicast@0.3.5: + dependencies: + "@babel/parser": 7.28.5 + "@babel/types": 7.28.5 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + math-intrinsics@1.1.0: {} merge-stream@2.0.0: {} @@ -6464,6 +7143,8 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minipass@7.1.2: {} + mkdirp@3.0.1: {} ms@2.1.3: {} @@ -6529,6 +7210,8 @@ snapshots: outvariant@1.4.3: {} + package-json-from-dist@1.0.1: {} + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 @@ -6548,6 +7231,11 @@ snapshots: path-key@4.0.0: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -6562,6 +7250,8 @@ snapshots: pidtree@0.6.0: {} + pngjs@7.0.0: {} + possible-typed-array-names@1.1.0: {} postcss@8.5.6: @@ -6737,6 +7427,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -6763,6 +7459,20 @@ snapshots: dependencies: has-flag: 4.0.0 + terser@5.44.1: + dependencies: + "@jridgewell/source-map": 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + optional: true + + test-exclude@7.0.1: + dependencies: + "@istanbuljs/schema": 0.1.3 + glob: 10.5.0 + minimatch: 9.0.5 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -6798,8 +7508,7 @@ snapshots: "@ts-morph/common": 0.23.0 code-block-writer: 13.0.3 - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsx@4.20.6: dependencies: @@ -6835,6 +7544,8 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + urlpattern-polyfill@10.1.0: {} + util@0.12.5: dependencies: inherits: 2.0.4 @@ -6845,13 +7556,13 @@ snapshots: uuid@9.0.1: {} - vite-node@3.2.4(@types/node@24.9.2)(tsx@4.20.6)(yaml@2.8.1): + vite-node@3.2.4(@types/node@24.10.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.2.4(@types/node@24.9.2)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.4(@types/node@24.10.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - "@types/node" - jiti @@ -6866,7 +7577,7 @@ snapshots: - tsx - yaml - vite@7.2.4(@types/node@24.9.2)(tsx@4.20.6)(yaml@2.8.1): + vite@7.2.4(@types/node@24.10.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -6875,16 +7586,17 @@ snapshots: rollup: 4.53.3 tinyglobby: 0.2.15 optionalDependencies: - "@types/node": 24.9.2 + "@types/node": 24.10.1 fsevents: 2.3.3 + terser: 5.44.1 tsx: 4.20.6 yaml: 2.8.1 - vitest@3.2.4(@types/node@24.9.2)(msw@2.4.3(typescript@5.9.3))(tsx@4.20.6)(yaml@2.8.1): + vitest@3.2.4(@types/node@24.10.1)(msw@2.4.3(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: "@types/chai": 5.2.3 "@vitest/expect": 3.2.4 - "@vitest/mocker": 3.2.4(msw@2.4.3(typescript@5.9.3))(vite@7.2.4(@types/node@24.9.2)(tsx@4.20.6)(yaml@2.8.1)) + "@vitest/mocker": 3.2.4(msw@2.4.3(typescript@5.9.3))(vite@7.2.4(@types/node@24.10.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) "@vitest/pretty-format": 3.2.4 "@vitest/runner": 3.2.4 "@vitest/snapshot": 3.2.4 @@ -6902,11 +7614,11 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.2.4(@types/node@24.9.2)(tsx@4.20.6)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.9.2)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.4(@types/node@24.10.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.10.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: - "@types/node": 24.9.2 + "@types/node": 24.10.1 transitivePeerDependencies: - jiti - less @@ -7020,6 +7732,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 @@ -7069,4 +7787,4 @@ snapshots: zx@8.1.5: optionalDependencies: "@types/fs-extra": 11.0.4 - "@types/node": 24.9.2 + "@types/node": 24.10.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..c5d6722 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +patchedDependencies: + blurhash: patches/blurhash.patch diff --git a/src/controllers/episodes/getEpisodeUrl/aniwatch.ts b/src/controllers/episodes/getEpisodeUrl/aniwatch.ts index 28ab614..4259532 100644 --- a/src/controllers/episodes/getEpisodeUrl/aniwatch.ts +++ b/src/controllers/episodes/getEpisodeUrl/aniwatch.ts @@ -8,22 +8,22 @@ export async function getSourcesFromAniwatch( console.log(`Fetching sources from aniwatch for ${watchId}`); const url = await getEpisodeUrl(watchId); if (url) { - return url; + return { success: true, result: url }; } const servers = await getEpisodeServers(watchId); if (servers.length === 0) { - return null; + return { success: false }; } for (const server of servers) { const url = await getEpisodeUrl(watchId, server.serverName); if (url) { - return url; + return { success: true, result: url }; } } - return null; + return { success: false }; } async function getEpisodeUrl(watchId: string, server?: string) { diff --git a/src/controllers/episodes/markEpisodeAsWatched/index.ts b/src/controllers/episodes/markEpisodeAsWatched/index.ts index 6bdd9be..ecf19ac 100644 --- a/src/controllers/episodes/markEpisodeAsWatched/index.ts +++ b/src/controllers/episodes/markEpisodeAsWatched/index.ts @@ -84,7 +84,7 @@ app.openapi(route, async (c) => { isComplete, ); if (isComplete) { - await updateWatchStatus(c.req, deviceId, aniListId, "COMPLETED"); + await updateWatchStatus(deviceId, aniListId, "COMPLETED"); } if (!user) { diff --git a/src/controllers/watch-status/index.ts b/src/controllers/watch-status/index.ts index 5d585ee..27b9fd9 100644 --- a/src/controllers/watch-status/index.ts +++ b/src/controllers/watch-status/index.ts @@ -66,7 +66,6 @@ const route = createRoute({ }); export async function updateWatchStatus( - req: HonoRequest, deviceId: string, titleId: number, watchStatus: WatchStatus | null, diff --git a/src/graphql/context.ts b/src/graphql/context.ts new file mode 100644 index 0000000..e30cecb --- /dev/null +++ b/src/graphql/context.ts @@ -0,0 +1,22 @@ +import type { Context as HonoContext } from "hono"; + +export interface GraphQLContext { + db: D1Database; + env: Env; + deviceId?: string; + aniListToken?: string; + honoContext: HonoContext; +} + +export function createGraphQLContext(c: HonoContext): GraphQLContext { + const deviceId = c.req.header("X-Device-ID"); + const aniListToken = c.req.header("X-AniList-Token"); + + return { + db: c.env.DB, + env: c.env, + deviceId, + aniListToken, + honoContext: c, + }; +} diff --git a/src/graphql/index.ts b/src/graphql/index.ts new file mode 100644 index 0000000..57d59f5 --- /dev/null +++ b/src/graphql/index.ts @@ -0,0 +1,41 @@ +import { createSchema, createYoga } from "graphql-yoga"; +import { Hono } from "hono"; + +import { createGraphQLContext } from "./context"; +import { resolvers } from "./resolvers"; +import { typeDefs } from "./schema"; + +const schema = createSchema({ + typeDefs, + resolvers, +}); + +const yoga = createYoga({ + schema, + graphqlEndpoint: "/graphql", + landingPage: false, // Disable landing page for production + graphiql: { + title: "Aniplay GraphQL API", + }, + context: ({ request }) => { + // Extract Hono context from the request + // graphql-yoga passes the raw request, but we need Hono context + // This will be provided when we integrate with Hono + return request as any; + }, +}); + +const app = new Hono(); + +app.all("/", async (c) => { + const graphqlContext = createGraphQLContext(c); + + // Create a custom request object that includes our GraphQL context + const request = c.req.raw.clone(); + (request as any).graphqlContext = graphqlContext; + + const response = await yoga.fetch(request, graphqlContext); + return response; +}); + +export default app; diff --git a/src/graphql/resolvers/image.ts b/src/graphql/resolvers/image.ts new file mode 100644 index 0000000..2f1d7cd --- /dev/null +++ b/src/graphql/resolvers/image.ts @@ -0,0 +1,47 @@ +import { encode } from "blurhash"; +import type { UintArrRet } from "jpeg-js"; +import type { PNGWithMetadata } from "pngjs"; + +export async function imageResolver( + parent: + | string + | null + | undefined + | { extraLarge?: string; large?: string; medium?: string }, +) { + const imageUrl = + typeof parent === "string" + ? parent + : (parent?.extraLarge ?? parent?.large ?? parent?.medium); + if (!imageUrl) { + return { url: imageUrl }; + } + + return { + url: imageUrl, + placeholder: await generateImagePlaceholder(imageUrl), + }; +} + +async function generateImagePlaceholder(imageUrl: string) { + const imageBuffer = await fetch(imageUrl).then((res) => res.arrayBuffer()); + let pixels: PNGWithMetadata | UintArrRet; + + if (imageUrl.endsWith(".png")) { + const { PNG } = await import("pngjs"); + pixels = PNG.sync.read(Buffer.from(imageBuffer)); + } else if (imageUrl.endsWith(".jpg")) { + const jpeg = await import("jpeg-js"); + pixels = jpeg.decode(imageBuffer, { formatAsRGBA: true, useTArray: true }); + } else { + throw new Error(`Unsupported image format: ${imageUrl.split(".").pop()}`); + } + + return encode( + new Uint8ClampedArray(pixels.data), + pixels.width, + pixels.height, + 4, + 3, + ); +} diff --git a/src/graphql/resolvers/index.ts b/src/graphql/resolvers/index.ts new file mode 100644 index 0000000..3f7a3e0 --- /dev/null +++ b/src/graphql/resolvers/index.ts @@ -0,0 +1,27 @@ +import { markEpisodeAsWatchedMutation } from "./mutations/markEpisodeAsWatched"; +import { updateTokenMutation } from "./mutations/updateToken"; +import { updateWatchStatusMutation } from "./mutations/updateWatchStatus"; +import { episodeStream } from "./queries/episodeStream"; +import { healthCheck } from "./queries/healthCheck"; +import { popularBrowse } from "./queries/popularBrowse"; +import { popularByCategory } from "./queries/popularByCategory"; +import { search } from "./queries/search"; +import { title } from "./queries/title"; +import { Title } from "./title"; + +export const resolvers = { + Query: { + healthCheck, + title, + search, + popularBrowse, + popularByCategory, + episodeStream, + }, + Mutation: { + updateWatchStatus: updateWatchStatusMutation, + markEpisodeAsWatched: markEpisodeAsWatchedMutation, + updateToken: updateTokenMutation, + }, + Title, +}; diff --git a/src/graphql/resolvers/mutations/markEpisodeAsWatched.ts b/src/graphql/resolvers/mutations/markEpisodeAsWatched.ts new file mode 100644 index 0000000..a566dc7 --- /dev/null +++ b/src/graphql/resolvers/mutations/markEpisodeAsWatched.ts @@ -0,0 +1,55 @@ +import { GraphQLError } from "graphql"; + +import { markEpisodeAsWatched } from "~/controllers/episodes/markEpisodeAsWatched/anilist"; + +import type { GraphQLContext } from "../../context"; + +interface MarkEpisodeAsWatchedInput { + titleId: number; + episodeNumber: number; + isComplete: boolean; +} + +interface MarkEpisodeAsWatchedArgs { + input: MarkEpisodeAsWatchedInput; +} + +export async function markEpisodeAsWatchedMutation( + _parent: unknown, + args: MarkEpisodeAsWatchedArgs, + context: GraphQLContext, +) { + const { input } = args; + const { aniListToken } = context; + + if (!aniListToken) { + throw new GraphQLError( + "AniList token is required. Please provide X-AniList-Token header.", + { + extensions: { code: "UNAUTHORIZED" }, + }, + ); + } + + try { + const user = await markEpisodeAsWatched( + aniListToken, + input.titleId, + input.episodeNumber, + input.isComplete, + ); + + if (!user) { + throw new GraphQLError("Failed to mark episode as watched", { + extensions: { code: "INTERNAL_SERVER_ERROR" }, + }); + } + + return true; + } catch (error) { + console.error("Error marking episode as watched:", error); + throw new GraphQLError("Failed to mark episode as watched", { + extensions: { code: "INTERNAL_SERVER_ERROR" }, + }); + } +} diff --git a/src/graphql/resolvers/mutations/updateToken.ts b/src/graphql/resolvers/mutations/updateToken.ts new file mode 100644 index 0000000..ee704aa --- /dev/null +++ b/src/graphql/resolvers/mutations/updateToken.ts @@ -0,0 +1,31 @@ +import { getAdminSdkCredentials } from "~/libs/gcloud/getAdminSdkCredentials"; +import { verifyFcmToken } from "~/libs/gcloud/verifyFcmToken"; +import { saveToken } from "~/models/token"; + +import type { GraphQLContext } from "../../context"; + +export async function updateTokenMutation( + _parent: unknown, + args: { token: string }, + context: GraphQLContext, +) { + const { deviceId } = context; + + try { + const isValidToken = await verifyFcmToken( + args.token, + getAdminSdkCredentials(), + ); + if (!isValidToken) { + return false; + } + + await saveToken(deviceId, args.token); + } catch (error) { + console.error("Failed to save token"); + console.error(error); + return false; + } + + return true; +} diff --git a/src/graphql/resolvers/mutations/updateWatchStatus.ts b/src/graphql/resolvers/mutations/updateWatchStatus.ts new file mode 100644 index 0000000..5a67a3e --- /dev/null +++ b/src/graphql/resolvers/mutations/updateWatchStatus.ts @@ -0,0 +1,44 @@ +import { GraphQLError } from "graphql"; + +import { updateWatchStatus } from "~/controllers/watch-status"; +import type { WatchStatus } from "~/types/title/watchStatus"; + +import type { GraphQLContext } from "../../context"; + +interface UpdateWatchStatusInput { + titleId: number; + watchStatus: WatchStatus | null; +} + +interface UpdateWatchStatusArgs { + input: UpdateWatchStatusInput; +} + +export async function updateWatchStatusMutation( + _parent: unknown, + args: UpdateWatchStatusArgs, + context: GraphQLContext, +) { + const { input } = args; + const { deviceId } = context; + + if (!deviceId) { + throw new GraphQLError( + "Device ID is required. Please provide X-Device-ID header.", + { + extensions: { code: "BAD_REQUEST" }, + }, + ); + } + + try { + await updateWatchStatus(deviceId, input.titleId, input.watchStatus); + + return true; + } catch (error) { + console.error("Error updating watch status:", error); + throw new GraphQLError("Failed to update watch status", { + extensions: { code: "INTERNAL_SERVER_ERROR" }, + }); + } +} diff --git a/src/graphql/resolvers/queries/episodeStream.ts b/src/graphql/resolvers/queries/episodeStream.ts new file mode 100644 index 0000000..d271775 --- /dev/null +++ b/src/graphql/resolvers/queries/episodeStream.ts @@ -0,0 +1,16 @@ +import { fetchEpisodeUrl } from "~/controllers/episodes/getEpisodeUrl"; + +import type { GraphQLContext } from "../../context"; + +export async function episodeStream( + _parent: unknown, + args: { id: string }, + context: GraphQLContext, +) { + const episodeUrl = await fetchEpisodeUrl({ id: args.id }); + if (!episodeUrl || !episodeUrl.success) { + throw new Error("Failed to fetch episode URL"); + } + + return { ...episodeUrl.result, url: episodeUrl.result.source }; +} diff --git a/src/graphql/resolvers/queries/healthCheck.ts b/src/graphql/resolvers/queries/healthCheck.ts new file mode 100644 index 0000000..ab848dc --- /dev/null +++ b/src/graphql/resolvers/queries/healthCheck.ts @@ -0,0 +1,9 @@ +import type { GraphQLContext } from "../../context"; + +export function healthCheck( + _parent: unknown, + _args: unknown, + _context: GraphQLContext, +): boolean { + return true; +} diff --git a/src/graphql/resolvers/queries/popularBrowse.ts b/src/graphql/resolvers/queries/popularBrowse.ts new file mode 100644 index 0000000..3c6010f --- /dev/null +++ b/src/graphql/resolvers/queries/popularBrowse.ts @@ -0,0 +1,31 @@ +import { GraphQLError } from "graphql"; + +import { fetchPopularTitlesFromAnilist } from "~/controllers/popular/browse/anilist"; + +import type { GraphQLContext } from "../../context"; + +interface PopularBrowseArgs { + limit?: number; +} + +export async function popularBrowse( + _parent: unknown, + args: PopularBrowseArgs, + _context: GraphQLContext, +) { + const { limit = 10 } = args; + + const response = await fetchPopularTitlesFromAnilist(limit); + + if (!response) { + throw new GraphQLError("Failed to fetch popular titles", { + extensions: { code: "INTERNAL_SERVER_ERROR" }, + }); + } + + return { + trending: response.trending || [], + popular: response.popular || [], + upcoming: response.upcoming || [], + }; +} diff --git a/src/graphql/resolvers/queries/popularByCategory.ts b/src/graphql/resolvers/queries/popularByCategory.ts new file mode 100644 index 0000000..9161411 --- /dev/null +++ b/src/graphql/resolvers/queries/popularByCategory.ts @@ -0,0 +1,33 @@ +import { GraphQLError } from "graphql"; + +import { fetchPopularTitlesFromAnilist } from "~/controllers/popular/category/anilist"; +import type { PopularCategory } from "~/controllers/popular/category/enum"; + +import type { GraphQLContext } from "../../context"; + +interface PopularByCategoryArgs { + category: PopularCategory; + page?: number; + limit?: number; +} + +export async function popularByCategory( + _parent: unknown, + args: PopularByCategoryArgs, + _context: GraphQLContext, +) { + const { category, page = 1, limit = 10 } = args; + + const response = await fetchPopularTitlesFromAnilist(category, page, limit); + + if (!response) { + throw new GraphQLError(`Failed to fetch ${category} titles`, { + extensions: { code: "INTERNAL_SERVER_ERROR" }, + }); + } + + return { + results: response.results || [], + hasNextPage: response.hasNextPage ?? false, + }; +} diff --git a/src/graphql/resolvers/queries/search.ts b/src/graphql/resolvers/queries/search.ts new file mode 100644 index 0000000..48590e5 --- /dev/null +++ b/src/graphql/resolvers/queries/search.ts @@ -0,0 +1,30 @@ +import { fetchSearchResultsFromAnilist } from "~/controllers/search/anilist"; + +import type { GraphQLContext } from "../../context"; + +interface SearchArgs { + query: string; + page?: number; + limit?: number; +} + +export async function search( + _parent: unknown, + args: SearchArgs, + _context: GraphQLContext, +) { + const { query, page = 1, limit = 10 } = args; + + const response = await fetchSearchResultsFromAnilist(query, page, limit); + if (!response) { + return { + results: [], + hasNextPage: false, + }; + } + + return { + results: response.results || [], + hasNextPage: response.hasNextPage ?? false, + }; +} diff --git a/src/graphql/resolvers/queries/title.ts b/src/graphql/resolvers/queries/title.ts new file mode 100644 index 0000000..bf1999f --- /dev/null +++ b/src/graphql/resolvers/queries/title.ts @@ -0,0 +1,33 @@ +import { GraphQLError } from "graphql"; + +import { fetchTitleFromAnilist } from "~/libs/anilist/getTitle"; + +import type { GraphQLContext } from "../../context"; + +interface TitleArgs { + id: number; +} + +export async function title( + _parent: unknown, + args: TitleArgs, + context: GraphQLContext, +) { + const { id } = args; + const { aniListToken } = context; + + // Fetch title + const titleData = await fetchTitleFromAnilist(id, aniListToken); + + if (!titleData) { + throw new GraphQLError(`Title with id ${id} not found`, { + extensions: { code: "NOT_FOUND" }, + }); + } + + return { + ...titleData, + title: titleData.title?.userPreferred ?? titleData.title?.english, + numEpisodes: titleData.episodes, + }; +} diff --git a/src/graphql/resolvers/title.ts b/src/graphql/resolvers/title.ts new file mode 100644 index 0000000..0f935e6 --- /dev/null +++ b/src/graphql/resolvers/title.ts @@ -0,0 +1,12 @@ +import { fetchEpisodes } from "~/controllers/episodes/getByAniListId"; +import type { Title as TitleType } from "~/types/title"; + +import { imageResolver } from "./image"; + +export const Title = { + episodes: async (parent: { id: number }) => await fetchEpisodes(parent.id), + coverImage: async (parent: TitleType) => + await imageResolver(parent.coverImage), + bannerImage: async (parent: TitleType) => + await imageResolver(parent.bannerImage), +}; diff --git a/src/graphql/schema.ts b/src/graphql/schema.ts new file mode 100644 index 0000000..217c6a3 --- /dev/null +++ b/src/graphql/schema.ts @@ -0,0 +1,206 @@ +export const typeDefs = /* GraphQL */ ` + # ==================== + # Scalars & Enums + # ==================== + + scalar JSONObject + + enum WatchStatus { + COMPLETED + CURRENT + PLANNING + DROPPED + PAUSED + REPEATING + } + + enum MediaStatus { + FINISHED + RELEASING + NOT_YET_RELEASED + CANCELLED + HIATUS + } + + enum PopularCategory { + TRENDING + POPULAR + UPCOMING + } + + # ==================== + # Title Types + # ==================== + + type Image { + url: String + placeholder: String + } + + type NextAiringEpisode { + episode: Int! + airingAt: Int! + timeUntilAiring: Int! + } + + type MediaListEntry { + status: WatchStatus + progress: Int + id: Int! + updatedAt: Int + } + + type Episode { + id: String! + number: Float! + title: String + img: String + description: String + rating: Int + updatedAt: Int! + } + + type Title { + id: Int! + idMal: Int + title: String! + description: String + numEpisodes: Int + genres: [String] + status: MediaStatus + bannerImage: Image + averageScore: Int + coverImage: Image + countryOfOrigin: String! + mediaListEntry: MediaListEntry + nextAiringEpisode: NextAiringEpisode + episodes: [Episode!]! + } + + # ==================== + # Home/Preview Title Type (simplified) + # ==================== + + type HomeTitle { + id: Int! + idMal: Int + title: String! + description: String + numEpisodes: Int + genres: [String] + status: MediaStatus + bannerImage: String + averageScore: Int + coverImage: Image + countryOfOrigin: String! + } + + # ==================== + # Response Types + # ==================== + + type SearchResult { + results: [HomeTitle!]! + hasNextPage: Boolean! + } + + type PopularBrowse { + trending: [HomeTitle!]! + popular: [HomeTitle!]! + upcoming: [HomeTitle!] + } + + type PopularResult { + results: [HomeTitle!]! + hasNextPage: Boolean! + } + + type EpisodeStream { + url: String! + subtitles: [LangUrl!]! + audio: [LangUrl!]! + intro: [Int!] + outro: [Int!] + headers: JSONObject + } + + type LangUrl { + lang: String! + url: String! + } + + # ==================== + # Input Types + # ==================== + + input UpdateWatchStatusInput { + titleId: Int! + watchStatus: WatchStatus + } + + input MarkEpisodeAsWatchedInput { + titleId: Int! + episodeNumber: Float! + } + + # ==================== + # Queries + # ==================== + + type Query { + """ + Simple health check to verify API is running + """ + healthCheck: Boolean! + + """ + Fetch a title by AniList ID + """ + title(id: Int!): Title! + + """ + Fetch an episode stream by ID + """ + episodeStream(id: String!): EpisodeStream! + + """ + Search for titles + """ + search(query: String!, page: Int = 1, limit: Int = 10): SearchResult! + + """ + Browse popular titles across all categories (trending, popular, upcoming) + """ + popularBrowse(limit: Int = 10): PopularBrowse! + + """ + Fetch paginated popular titles for a specific category + """ + popularByCategory( + category: PopularCategory! + page: Int = 1 + limit: Int = 10 + ): PopularResult! + } + + # ==================== + # Mutations + # ==================== + + type Mutation { + """ + Update watch status for a title. Device ID must be provided via X-Device-ID header. + """ + updateWatchStatus(input: UpdateWatchStatusInput!): Boolean! + + """ + Mark an episode as watched. Device ID must be provided via X-Device-ID header. + """ + markEpisodeAsWatched(input: MarkEpisodeAsWatchedInput!): Boolean! + + """ + Update the user's FCM token. Device ID must be provided via X-Device-ID header. + """ + updateToken(token: String!): Boolean! + } +`; diff --git a/src/index.ts b/src/index.ts index 0318abd..18990ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ -import { swaggerUI } from "@hono/swagger-ui"; -import { OpenAPIHono } from "@hono/zod-openapi"; +import { Hono } from "hono"; import { maybeUpdateLastConnectedAt } from "~/controllers/maybeUpdateLastConnectedAt"; import type { QueueName } from "~/libs/tasks/queueName.ts"; @@ -7,67 +6,15 @@ import type { QueueName } from "~/libs/tasks/queueName.ts"; import { onNewEpisode } from "./controllers/internal/new-episode"; import type { QueueBody } from "./libs/tasks/queueTask"; -const app = new OpenAPIHono(); +const app = new Hono(); app.use(maybeUpdateLastConnectedAt); +// GraphQL endpoint replaces all REST routes app.route( - "/", - await import("~/controllers/health-check").then( - (controller) => controller.default, - ), + "/graphql", + await import("~/graphql").then((module) => module.default), ); -app.route( - "/title", - await import("~/controllers/title").then((controller) => controller.default), -); -app.route( - "/episodes", - await import("~/controllers/episodes").then( - (controller) => controller.default, - ), -); -app.route( - "/search", - await import("~/controllers/search").then((controller) => controller.default), -); -app.route( - "/watch-status", - await import("~/controllers/watch-status").then( - (controller) => controller.default, - ), -); -app.route( - "/token", - await import("~/controllers/token").then((controller) => controller.default), -); -app.route( - "/auth", - await import("~/controllers/auth").then((controller) => controller.default), -); -app.route( - "/popular", - await import("~/controllers/popular").then( - (controller) => controller.default, - ), -); -app.route( - "/internal", - await import("~/controllers/internal").then( - (controller) => controller.default, - ), -); - -// The OpenAPI documentation will be available at /doc -app.doc("/openapi.json", { - openapi: "3.0.0", - info: { - version: "1.0.0", - title: "Aniplay API", - }, -}); - -app.get("/docs", swaggerUI({ url: "/openapi.json" })); export default { fetch: app.fetch, diff --git a/src/libs/anilist/anilist-do.ts b/src/libs/anilist/anilist-do.ts index c039f46..edeb72e 100644 --- a/src/libs/anilist/anilist-do.ts +++ b/src/libs/anilist/anilist-do.ts @@ -49,6 +49,9 @@ export class AnilistDurableObject extends DurableObject { { id }, token, ); + if (!anilistResponse) { + return null; + } // Extract next airing episode for alarm const media = anilistResponse.Media as ResultOf< diff --git a/src/types/episode/fetch-url-response.ts b/src/types/episode/fetch-url-response.ts index b15d336..8fe96dc 100644 --- a/src/types/episode/fetch-url-response.ts +++ b/src/types/episode/fetch-url-response.ts @@ -1,6 +1,10 @@ import { z } from "zod"; -import { SkippableSchema, SuccessResponseSchema } from "~/types/schema"; +import { + ErrorResponseSchema, + SkippableSchema, + SuccessResponseSchema, +} from "~/types/schema"; export type FetchUrlResponseSchema = z.infer; export const FetchUrlResponseSchema = z.object({ @@ -15,4 +19,7 @@ export const FetchUrlResponseSchema = z.object({ export type FetchUrlResponse = z.infer & { result: FetchUrlResponseSchema; }; -export const FetchUrlResponse = SuccessResponseSchema(FetchUrlResponseSchema); +export const FetchUrlResponse = z.union([ + SuccessResponseSchema(FetchUrlResponseSchema), + ErrorResponseSchema, +]); diff --git a/src/types/episode/index.ts b/src/types/episode/index.ts index 7c1c6c2..ee939ee 100644 --- a/src/types/episode/index.ts +++ b/src/types/episode/index.ts @@ -10,7 +10,7 @@ export const Episode = z.object({ img: z.string().nullish(), description: z.string().nullish(), rating: z.number().int().nullish(), - updatedAt: z.number().int().default(0).openapi({ format: "int64" }), + updatedAt: z.number().int().default(0) /* .openapi({ format: "int64" }) */, }); export type EpisodesResponse = z.infer; diff --git a/src/types/schema.ts b/src/types/schema.ts index 90eed54..ee27e05 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -2,7 +2,7 @@ import { type ZodSchema, z } from "zod"; export const SuccessResponse = { success: true } as const; export const SuccessResponseSchema = (schema?: T) => { - const success = z.literal(true).openapi({ type: "boolean" }); + const success = z.literal(true); /* .openapi({ type: "boolean" }) */ if (!schema) { return z.object({ success }); @@ -21,25 +21,27 @@ export const PaginatedResponseSchema = (schema: T) => { export const ErrorResponse = { success: false } as const; export const ErrorResponseSchema = z.object({ - success: z.literal(false).openapi({ type: "boolean" }), + success: z.literal(false) /* .openapi({ type: "boolean" }) */, }); export const NullableNumberSchema = z.number().int().nullable(); -export const AniListIdSchema = z.number().int().openapi({ format: "int64" }); -export const AniListIdQuerySchema = z - .string() - .openapi({ type: "integer", format: "int64" }); - -export const EpisodeNumberSchema = z.number().openapi({ +export const AniListIdSchema = z + .number() + .int(); /* .openapi({ format: "int64" }) */ +export const AniListIdQuerySchema = z.string(); +/* .openapi({ type: "integer", format: "int64" }) */ export const EpisodeNumberSchema = + z.number(); /* .openapi({ minimum: 0, multipleOf: 0.5, examples: [1, 2, 3.5], type: "number", format: "float", -}); +}) */ export const SkippableSchema = z - .array(z.number().openapi({ minimum: 0, type: "integer", format: "int64" })) - .nullish() - .openapi({ examples: [[200, 289]], minItems: 2, maxItems: 2 }); + .array( + z.number() /* .openapi({ minimum: 0, type: "integer", format: "int64" }) */, + ) + .nullish(); +/* .openapi({ examples: [[200, 289]], minItems: 2, maxItems: 2 }) */ diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2e0846f --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,24 @@ +import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; + +export default defineWorkersConfig({ + test: { + globals: true, + setupFiles: ["./testSetup.ts"], + poolOptions: { + workers: { + wrangler: { configPath: "./wrangler.toml" }, + }, + }, + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + exclude: [ + "node_modules/**", + "dist/**", + "**/*.spec.ts", + "**/*.d.ts", + "**/mocks/**", + ], + }, + }, +});