From 73afde820f512aec34cb1c625d8d5db694855ebd Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Wed, 5 Feb 2025 21:57:10 -0800 Subject: [PATCH] Next.js works! Theres' enough testing I think we can GA it --- .github/workflows/bump-js-version.yml | 11 +++ js/core/src/error.ts | 7 +- js/plugins/api-key/src/index.ts | 12 ++-- js/plugins/firebase/package.json | 2 + js/plugins/next/package.json | 13 +++- js/plugins/next/src/index.ts | 100 +++++++++++++++++++++----- js/pnpm-lock.yaml | 36 +++++++--- 7 files changed, 145 insertions(+), 36 deletions(-) diff --git a/.github/workflows/bump-js-version.yml b/.github/workflows/bump-js-version.yml index ed1c7f327..5386dcb75 100644 --- a/.github/workflows/bump-js-version.yml +++ b/.github/workflows/bump-js-version.yml @@ -162,6 +162,17 @@ jobs: preid: ${{ inputs.preid }} commit-message: 'chore: bump genkitx-langchain version to {{version}}' tag-prefix: 'genkitx-langchain@' + - name: 'js/plugins/next version bump' + uses: 'phips28/gh-action-bump-version@master' + env: + GITHUB_TOKEN: ${{ secrets.GENKIT_RELEASER_GITHUB_TOKEN }} + PACKAGEJSON_DIR: js/plugins/next + with: + default: ${{ inputs.releaseType }} + version-type: ${{ inputs.releaseType }} + preid: ${{ inputs.preid }} + commit-message: 'chore: bump @genkit-ai/next version to {{version}}' + tag-prefix: '@genkit-ai/next@' - name: 'js/plugins/ollama version bump' uses: 'phips28/gh-action-bump-version@master' env: diff --git a/js/core/src/error.ts b/js/core/src/error.ts index 0331d3b46..0ff580578 100644 --- a/js/core/src/error.ts +++ b/js/core/src/error.ts @@ -32,6 +32,10 @@ export class GenkitError extends Error { detail?: any; code: number; + // For easy printing, we wrap the error with information like the source + // and status, but that's redundant with JSON. + originalMessage: string; + constructor({ status, message, @@ -44,6 +48,7 @@ export class GenkitError extends Error { source?: string; }) { super(`${source ? `${source}: ` : ''}${status}: ${message}`); + this.originalMessage = message; this.code = httpStatusCode(status); this.status = status; this.detail = detail; @@ -59,7 +64,7 @@ export class GenkitError extends Error { // but the actual Callable protocol value is "details" ...(this.detail === undefined ? {} : { details: this.detail }), status: this.status, - message: this.message, + message: this.originalMessage, }; } } diff --git a/js/plugins/api-key/src/index.ts b/js/plugins/api-key/src/index.ts index 54bb11536..9ba664d01 100644 --- a/js/plugins/api-key/src/index.ts +++ b/js/plugins/api-key/src/index.ts @@ -17,8 +17,8 @@ import { ContextProvider, RequestData, UserFacingError } from '@genkit-ai/core'; export interface ApiKeyContext { - auth?: { - apiKey: string; + auth: { + apiKey: string | undefined; }; } @@ -30,16 +30,14 @@ export function apiKey( valueOrPolicy?: ((context: ApiKeyContext) => void | Promise) | string ): ContextProvider { return async function (request: RequestData): Promise { - const context: ApiKeyContext = {}; - if ('authorization' in request.headers) { - context.auth = { apiKey: request.headers['authorization'] }; - } + const context: ApiKeyContext = { auth: { apiKey: undefined } }; + context.auth = { apiKey: request.headers['authorization'] }; if (typeof valueOrPolicy === 'string') { if (!context.auth) { throw new UserFacingError('UNAUTHENTICATED', 'Unauthenticated'); } if (context.auth?.apiKey != valueOrPolicy) { - throw new UserFacingError('PERMISSION_DENIED', 'Permission denied'); + throw new UserFacingError('PERMISSION_DENIED', 'Permission Denied'); } } else if (typeof valueOrPolicy === 'function') { await valueOrPolicy(context); diff --git a/js/plugins/firebase/package.json b/js/plugins/firebase/package.json index f32c5635a..51a996959 100644 --- a/js/plugins/firebase/package.json +++ b/js/plugins/firebase/package.json @@ -40,6 +40,8 @@ "genkit": "workspace:^" }, "devDependencies": { + "genkit": "workspace:*", + "@genkit-ai/api-key": "workspace:*", "@jest/globals": "^29.7.0", "@types/jest": "^29.5.12", "@types/node": "^20.11.16", diff --git a/js/plugins/next/package.json b/js/plugins/next/package.json index 267b8ca12..34f5d8228 100644 --- a/js/plugins/next/package.json +++ b/js/plugins/next/package.json @@ -14,7 +14,7 @@ "next.js", "react" ], - "version": "0.0.1-dev.1", + "version": "1.0.0-rc.16", "type": "commonjs", "main": "lib/index.js", "scripts": { @@ -22,7 +22,8 @@ "compile": "tsup-node", "build:clean": "rimraf ./lib", "build": "npm-run-all build:clean check compile", - "build:watch": "tsup-node --watch" + "build:watch": "tsup-node --watch", + "test": "jest --verbose" }, "repository": { "type": "git", @@ -34,20 +35,26 @@ "peerDependencies": { "@genkit-ai/core": "workspace:*", "genkit": "workspace:*", - "next": "^15.0.0" + "next": "^15.0.0", + "zod": "^3.24.1" }, "devDependencies": { "@genkit-ai/core": "workspace:*", + "@genkit-ai/api-key": "workspace:*", "genkit": "workspace:*", "@types/node": "^20.11.16", "@types/react": "^19", + "@types/jest": "^29.5.12", "@types/react-dom": "^19", + "@jest/globals": "^29.7.0", "npm-run-all": "^4.1.5", "rimraf": "^6.0.1", "tsup": "^8.0.2", "tsx": "^4.7.0", "typescript": "^4.9.0", "next": "^15.0.0", + "ts-jest": "^29.1.2", + "jest": "^29.7.0", "zod": "^3.24.1" }, "types": "./lib/index.d.ts", diff --git a/js/plugins/next/src/index.ts b/js/plugins/next/src/index.ts index 0a799c3f4..91080d35f 100644 --- a/js/plugins/next/src/index.ts +++ b/js/plugins/next/src/index.ts @@ -14,56 +14,122 @@ * limitations under the License. */ -import type { Action } from '@genkit-ai/core'; +import { + Action, + ActionContext, + ContextProvider, + RequestData, + getCallableJSON, + getHttpStatus, + z, +} from '@genkit-ai/core'; import { NextRequest, NextResponse } from 'next/server'; -const appRoute: >( - action: A -) => (req: NextRequest) => Promise = - (action) => +const delimiter = '\n\n'; +async function getContext( + request: NextRequest, + input: T, + provider: ContextProvider | undefined +): Promise { + // Type cast is necessary because there is no runtime way to generate a context if C is provided to appRoute + // but contextProvider is missing. When I'm less sleepy/busy I'll see if I can make this a type error. + let context = {} as C; + if (!provider) { + return context; + } + + const r: RequestData = { + method: request.method as RequestData['method'], + headers: {}, + input, + }; + request.headers.forEach((val, key) => { + r.headers[key.toLowerCase()] = val; + }); + return await provider(r); +} + +const appRoute = + < + C extends ActionContext = ActionContext, + I extends z.ZodTypeAny = z.ZodTypeAny, + O extends z.ZodTypeAny = z.ZodTypeAny, + S extends z.ZodTypeAny = z.ZodTypeAny, + >( + action: Action, + opts?: { + context?: ContextProvider; + } + ) => async (req: NextRequest): Promise => { - const { data } = await req.json(); + let context: C = {} as C; + const { data: input } = await req.json(); if (req.headers.get('accept') !== 'text/event-stream') { try { - const resp = await action.run(data); + context = await getContext(req, input, opts?.context); + } catch (e) { + console.error('Error gathering context for running action:', e); + return NextResponse.json( + { error: getCallableJSON(e) }, + { status: getHttpStatus(e) } + ); + } + try { + const resp = await action.run(input, { context }); return NextResponse.json({ result: resp.result }); } catch (e) { // For security reasons, log the error rather than responding with it. - console.error(e); - return NextResponse.json({ error: 'INTERNAL' }, { status: 500 }); + console.error('Error calling action:', e); + return NextResponse.json( + { error: getCallableJSON(e) }, + { status: getHttpStatus(e) } + ); } } - const { output, stream } = action.stream(data); + try { + context = await getContext(req, input, opts?.context); + } catch (e) { + console.error('Error gathering context for streaming action:', e); + return new NextResponse( + `error: ${JSON.stringify(getCallableJSON(e))}${delimiter}END`, + { status: getHttpStatus(e) } + ); + } + const { output, stream } = action.stream(input, { context }); const encoder = new TextEncoder(); const { readable, writable } = new TransformStream(); - // Not using a dangling Promise causes NextResponse to deadlock. + // Not using a dangling promise causes this closure to block on the stream being drained, + // which doesn't happen until the NextResponse is consumed later in the cosure. // TODO: Add ping comments at regular intervals between streaming responses to mitigate // timeouts. (async (): Promise => { const writer = writable.getWriter(); try { for await (const chunk of stream) { - console.debug('Writing chunk ' + chunk + '\n'); await writer.write( encoder.encode( - 'data: ' + JSON.stringify({ message: chunk }) + '\n\n' + `data: ${JSON.stringify({ message: chunk })}${delimiter}` ) ); } await writer.write( encoder.encode( - 'data: ' + JSON.stringify({ result: await output }) + '\n\n' + `data: ${JSON.stringify({ result: await output })}${delimiter}` ) ); - await writer.write('END'); + await writer.write(encoder.encode('END')); } catch (err) { - console.error('Error in streaming output:', err); + console.error('Error streaming action:', err); await writer.write( - encoder.encode(`error: {"error": {"message":"INTERNAL"}}` + '\n\n') + encoder.encode( + `error: ${JSON.stringify(getCallableJSON(err))}` + '\n\n' + ) ); await writer.write(encoder.encode('END')); + } finally { + await writer.close(); } })(); diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 5b183a9ea..15330a7d6 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -465,10 +465,10 @@ importers: firebase-admin: specifier: '>=12.2' version: 12.3.1(encoding@0.1.13) - genkit: - specifier: workspace:^ - version: link:../../genkit devDependencies: + '@genkit-ai/api-key': + specifier: workspace:* + version: link:../api-key '@jest/globals': specifier: ^29.7.0 version: 29.7.0 @@ -478,6 +478,9 @@ importers: '@types/node': specifier: ^20.11.16 version: 20.11.30 + genkit: + specifier: workspace:* + version: link:../../genkit jest: specifier: ^29.7.0 version: 29.7.0(@types/node@20.11.30)(ts-node@10.9.2(@types/node@20.11.30)(typescript@4.9.5)) @@ -698,9 +701,18 @@ importers: plugins/next: devDependencies: + '@genkit-ai/api-key': + specifier: workspace:* + version: link:../api-key '@genkit-ai/core': specifier: workspace:* version: link:../../core + '@jest/globals': + specifier: ^29.7.0 + version: 29.7.0 + '@types/jest': + specifier: ^29.5.12 + version: 29.5.13 '@types/node': specifier: ^20.11.16 version: 20.16.9 @@ -713,15 +725,21 @@ importers: genkit: specifier: workspace:* version: link:../../genkit + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@4.9.5)) next: specifier: ^15.0.0 - version: 15.1.6(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 15.1.6(@babel/core@7.25.7)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) npm-run-all: specifier: ^4.1.5 version: 4.1.5 rimraf: specifier: ^6.0.1 version: 6.0.1 + ts-jest: + specifier: ^29.1.2 + version: 29.2.5(@babel/core@7.25.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.7))(jest@29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@4.9.5)))(typescript@4.9.5) tsup: specifier: ^8.0.2 version: 8.3.5(postcss@8.4.47)(tsx@4.19.2)(typescript@4.9.5)(yaml@2.7.0) @@ -1496,7 +1514,7 @@ importers: version: link:../../genkit next: specifier: ^15.1.6 - version: 15.1.6(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 15.1.6(@babel/core@7.25.7)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) zod: specifier: ^3.24.1 version: 3.24.1 @@ -11593,7 +11611,7 @@ snapshots: neo-async@2.6.2: {} - next@15.1.6(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@15.1.6(@babel/core@7.25.7)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 15.1.6 '@swc/counter': 0.1.3 @@ -11603,7 +11621,7 @@ snapshots: postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.6(react@18.3.1) + styled-jsx: 5.1.6(@babel/core@7.25.7)(react@18.3.1) optionalDependencies: '@next/swc-darwin-arm64': 15.1.6 '@next/swc-darwin-x64': 15.1.6 @@ -12396,10 +12414,12 @@ snapshots: stubs@3.0.0: {} - styled-jsx@5.1.6(react@18.3.1): + styled-jsx@5.1.6(@babel/core@7.25.7)(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 + optionalDependencies: + '@babel/core': 7.25.7 sucrase@3.35.0: dependencies: