Skip to content

Commit

Permalink
Next.js works! Theres' enough testing I think we can GA it
Browse files Browse the repository at this point in the history
  • Loading branch information
inlined committed Feb 6, 2025
1 parent dd15fa0 commit 73afde8
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 36 deletions.
11 changes: 11 additions & 0 deletions .github/workflows/bump-js-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion js/core/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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,
};
}
}
Expand Down
12 changes: 5 additions & 7 deletions js/plugins/api-key/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
import { ContextProvider, RequestData, UserFacingError } from '@genkit-ai/core';

export interface ApiKeyContext {
auth?: {
apiKey: string;
auth: {
apiKey: string | undefined;
};
}

Expand All @@ -30,16 +30,14 @@ export function apiKey(
valueOrPolicy?: ((context: ApiKeyContext) => void | Promise<void>) | string
): ContextProvider<ApiKeyContext> {
return async function (request: RequestData): Promise<ApiKeyContext> {
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);
Expand Down
2 changes: 2 additions & 0 deletions js/plugins/firebase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 10 additions & 3 deletions js/plugins/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,16 @@
"next.js",
"react"
],
"version": "0.0.1-dev.1",
"version": "1.0.0-rc.16",
"type": "commonjs",
"main": "lib/index.js",
"scripts": {
"check": "tsc",
"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",
Expand All @@ -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",
Expand Down
100 changes: 83 additions & 17 deletions js/plugins/next/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <A extends Action<any, any, any>>(
action: A
) => (req: NextRequest) => Promise<NextResponse> =
(action) =>
const delimiter = '\n\n';
async function getContext<C extends ActionContext, T>(
request: NextRequest,
input: T,
provider: ContextProvider<C, T> | undefined
): Promise<C> {
// 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<I, O, S>,
opts?: {
context?: ContextProvider<C, I>;
}
) =>
async (req: NextRequest): Promise<NextResponse> => {
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<void> => {
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();
}
})();

Expand Down
36 changes: 28 additions & 8 deletions js/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 73afde8

Please sign in to comment.