Skip to content

Commit aa57fb6

Browse files
authored
feat(server): lazy router/procedure (#43)
* lazy * router caller and builder * orpc fetch handler * lazy for implementers * back to old prefix * openapi handler for lazy * strict implementers * openapi support lazy * client + react * docs
1 parent 8f9385e commit aa57fb6

31 files changed

Lines changed: 1868 additions & 536 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
title: Lazy Router
3+
description: Enhance the performance of your oRPC router with lazy-loaded routers.
4+
---
5+
6+
Enhance the performance of your oRPC router with lazy-loaded routers.
7+
Lazy routers in oRPC allow you to defer the loading of specific router modules until they're actually needed.
8+
This can improve startup times and optimize resource usage, especially for large applications.
9+
10+
11+
## Overview
12+
13+
```typescript twoslash
14+
import { os } from '@orpc/server'
15+
16+
const pub = os.context<{ user?: { id: string } }>()
17+
18+
// Define a router with lazy loading
19+
const router = pub.router({
20+
lazy: pub.lazy(() => import('examples/server')) // Lazy-load the 'examples/server' router
21+
})
22+
23+
// Use the lazy-loaded router as if it were a regular one
24+
const output = await router.lazy.getting({ name: 'unnoq' })
25+
```
26+
27+
### Key Points:
28+
1. **Export Requirement:**
29+
The `examples/server.ts` file must export a router as the default export. Example:
30+
```typescript
31+
import { os } from '@orpc/server'
32+
33+
export default os.router({
34+
// something
35+
})
36+
```
37+
38+
2. **Seamless Functionality:**
39+
Once the lazy router is loaded, it behaves exactly like a standard router. You don't need to configure anything extra or worry about limitations.
40+
41+
42+
By using lazy routers, you can keep your application modular and efficient while maintaining the same user-friendly API you're used to.

apps/content/content/docs/server/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"context",
77
"contract",
88
"file-upload",
9+
"lazy",
910
"server-action",
1011
"caller",
1112
"error-handling",

apps/content/examples/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,5 @@ server.listen(3000, () => {
123123

124124
//
125125
//
126+
127+
export default router

packages/client/src/router.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
ContractRouter,
66
SchemaOutput,
77
} from '@orpc/contract'
8-
import type { Procedure, Router } from '@orpc/server'
8+
import type { Lazy, Procedure, Router } from '@orpc/server'
99
import type { Promisable } from '@orpc/shared'
1010
import { createProcedureClient, type ProcedureClient } from './procedure'
1111

@@ -21,13 +21,9 @@ export type RouterClientWithContractRouter<TRouter extends ContractRouter> = {
2121
}
2222

2323
export type RouterClientWithRouter<TRouter extends Router<any>> = {
24-
[K in keyof TRouter]: TRouter[K] extends Procedure<
25-
any,
26-
any,
27-
infer UInputSchema,
28-
infer UOutputSchema,
29-
infer UFuncOutput
30-
>
24+
[K in keyof TRouter]: TRouter[K] extends
25+
| Procedure<any, any, infer UInputSchema, infer UOutputSchema, infer UFuncOutput>
26+
| Lazy<Procedure<any, any, infer UInputSchema, infer UOutputSchema, infer UFuncOutput>>
3127
? ProcedureClient<UInputSchema, UOutputSchema, UFuncOutput>
3228
: TRouter[K] extends Router<any>
3329
? RouterClientWithRouter<TRouter[K]>

packages/contract/src/procedure.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export class ContractProcedure<
3535
) {}
3636
}
3737

38+
export type WELL_CONTRACT_PROCEDURE = ContractProcedure<Schema, Schema>
39+
3840
export class DecoratedContractProcedure<
3941
TInputSchema extends Schema,
4042
TOutputSchema extends Schema,

packages/contract/src/router-builder.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import type { HTTPPath } from './types'
33
import { DecoratedContractProcedure, isContractProcedure } from './procedure'
44

55
export class ContractRouterBuilder {
6-
constructor(public zz$crb: { prefix?: HTTPPath, tags?: string[] }) {}
6+
constructor(public zz$crb: { prefix?: HTTPPath, tags?: string[] }) {
7+
if (zz$crb.prefix && zz$crb.prefix.includes('{')) {
8+
throw new Error('Prefix cannot contain "{" for dynamic routing')
9+
}
10+
}
711

812
prefix(prefix: HTTPPath): ContractRouterBuilder {
913
return new ContractRouterBuilder({

packages/openapi/src/fetch/base-handler.ts

Lines changed: 88 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
/// <reference lib="dom" />
22

33
import type { HTTPPath } from '@orpc/contract'
4+
import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE, Router } from '@orpc/server'
45
import type { FetchHandler } from '@orpc/server/fetch'
56
import type { Router as HonoRouter } from 'hono/router'
7+
import type { EachContractLeafResultItem, EachLeafOptions } from '../utils'
68
import { ORPC_HEADER, standardizeHTTPPath } from '@orpc/contract'
7-
import { createProcedureCaller, isProcedure, ORPCError, type Procedure, type Router, type WELL_DEFINED_PROCEDURE } from '@orpc/server'
9+
import { createProcedureCaller, isLazy, isProcedure, LAZY_LOADER_SYMBOL, LAZY_ROUTER_PREFIX_SYMBOL, ORPCError } from '@orpc/server'
810
import { isPlainObject, mapValues, trim, value } from '@orpc/shared'
911
import { OpenAPIDeserializer, OpenAPISerializer, zodCoerce } from '@orpc/transformer'
12+
import { eachContractProcedureLeaf } from '../utils'
1013

11-
export type ResolveRouter = (router: Router<any>, method: string, pathname: string) => {
14+
export type ResolveRouter = (router: Router<any>, method: string, pathname: string) => Promise<{
1215
path: string[]
13-
procedure: Procedure<any, any, any, any, any>
16+
procedure: ANY_PROCEDURE | ANY_LAZY_PROCEDURE
1417
params: Record<string, string>
15-
} | undefined
18+
} | undefined>
1619

17-
type Routing = HonoRouter<[string[], Procedure<any, any, any, any, any>]>
20+
type Routing = HonoRouter<string[]>
1821

1922
export function createOpenAPIHandler(createHonoRouter: () => Routing): FetchHandler {
2023
const resolveRouter = createResolveRouter(createHonoRouter)
@@ -37,14 +40,21 @@ export function createOpenAPIHandler(createHonoRouter: () => Routing): FetchHand
3740
: undefined
3841
const method = customMethod || options.request.method
3942

40-
const match = resolveRouter(options.router, method, pathname)
43+
const match = await resolveRouter(options.router, method, pathname)
4144

4245
if (!match) {
4346
throw new ORPCError({ code: 'NOT_FOUND', message: 'Not found' })
4447
}
45-
const procedure = match.procedure
48+
const procedure = isLazy(match.procedure) ? (await match.procedure[LAZY_LOADER_SYMBOL]()).default : match.procedure
4649
const path = match.path
4750

51+
if (!isProcedure(procedure)) {
52+
throw new ORPCError({
53+
code: 'NOT_FOUND',
54+
message: 'Not found',
55+
})
56+
}
57+
4858
const params = procedure.zz$p.contract.zz$cp.InputSchema
4959
? zodCoerce(
5060
procedure.zz$p.contract.zz$cp.InputSchema,
@@ -107,37 +117,65 @@ export function createOpenAPIHandler(createHonoRouter: () => Routing): FetchHand
107117
}
108118

109119
const routingCache = new Map<Router<any>, Routing>()
120+
const pendingCache = new Map<Router<any>, { ref: EachContractLeafResultItem[] }> ()
110121

111122
export function createResolveRouter(createHonoRouter: () => Routing): ResolveRouter {
112-
return (router: Router<any>, method: string, pathname: string) => {
113-
let routing = routingCache.get(router)
114-
115-
if (!routing) {
116-
routing = createHonoRouter()
117-
118-
const addRouteRecursively = (routing: Routing, router: Router<any>, basePath: string[]) => {
119-
for (const key in router) {
120-
const currentPath = [...basePath, key]
121-
const item = router[key] as WELL_DEFINED_PROCEDURE | Router<any>
122-
123-
if (isProcedure(item)) {
124-
const method = item.zz$p.contract.zz$cp.method ?? 'POST'
125-
const path = item.zz$p.contract.zz$cp.path
126-
? openAPIPathToRouterPath(item.zz$p.contract.zz$cp.path)
127-
: `/${currentPath.map(encodeURIComponent).join('/')}`
128-
129-
routing.add(method, path, [currentPath, item])
130-
}
131-
else {
132-
addRouteRecursively(routing, item, currentPath)
133-
}
134-
}
123+
const addRoutes = (routing: Routing, pending: { ref: EachContractLeafResultItem[] }, options: EachLeafOptions) => {
124+
const lazies = eachContractProcedureLeaf(options, ({ path, contract }) => {
125+
const method = contract.zz$cp.method ?? 'POST'
126+
const httpPath = contract.zz$cp.path
127+
? openAPIPathToRouterPath(contract.zz$cp.path)
128+
: `/${path.map(encodeURIComponent).join('/')}`
129+
130+
routing.add(method, httpPath, path)
131+
})
132+
133+
pending.ref.push(...lazies)
134+
}
135+
136+
return async (router: Router<any>, method: string, pathname: string) => {
137+
const pending = (() => {
138+
let pending = pendingCache.get(router)
139+
if (!pending) {
140+
pending = { ref: [] }
141+
pendingCache.set(router, pending)
142+
}
143+
144+
return pending
145+
})()
146+
147+
const routing = (() => {
148+
let routing = routingCache.get(router)
149+
150+
if (!routing) {
151+
routing = createHonoRouter()
152+
routingCache.set(router, routing)
153+
addRoutes(routing, pending, { router, path: [] })
154+
}
155+
156+
return routing
157+
})()
158+
159+
const newPending = []
160+
161+
for (const item of pending.ref) {
162+
if (
163+
(LAZY_ROUTER_PREFIX_SYMBOL in item.lazy)
164+
&& item.lazy[LAZY_ROUTER_PREFIX_SYMBOL]
165+
&& !pathname.startsWith(item.lazy[LAZY_ROUTER_PREFIX_SYMBOL] as HTTPPath)
166+
&& !pathname.startsWith(`/${item.path.map(encodeURIComponent).join('/')}`)
167+
) {
168+
newPending.push(item)
169+
continue
135170
}
136171

137-
addRouteRecursively(routing, router, [])
138-
routingCache.set(router, routing)
172+
const router = (await item.lazy[LAZY_LOADER_SYMBOL]()).default
173+
174+
addRoutes(routing, pending, { path: item.path, router })
139175
}
140176

177+
pending.ref = newPending
178+
141179
const [matches, params_] = routing.match(method, pathname)
142180

143181
const [match] = matches.sort((a, b) => {
@@ -148,20 +186,31 @@ export function createResolveRouter(createHonoRouter: () => Routing): ResolveRou
148186
return undefined
149187
}
150188

151-
const path = match[0][0]
152-
const procedure = match[0][1]
189+
const path = match[0]
153190
const params = params_
154191
? mapValues(
155192
(match as any)[1]!,
156193
v => params_[v as number]!,
157194
)
158195
: match[1] as Record<string, string>
159196

160-
return {
161-
path,
162-
procedure,
163-
params: { ...params }, // params from hono not a normal object, so we need spread here
197+
let current: Router<any> | ANY_PROCEDURE | ANY_LAZY_PROCEDURE | undefined = router
198+
for (const segment of path) {
199+
if ((typeof current !== 'object' && typeof current !== 'function') || !current) {
200+
current = undefined
201+
break
202+
}
203+
204+
current = (current as any)[segment]
164205
}
206+
207+
return isProcedure(current) || isLazy(current)
208+
? {
209+
path,
210+
procedure: current,
211+
params: { ...params }, // params from hono not a normal object, so we need spread here
212+
}
213+
: undefined
165214
}
166215
}
167216

@@ -180,7 +229,7 @@ function mergeParamsAndInput(coercedParams: Record<string, unknown>, input: unkn
180229
}
181230
}
182231

183-
async function deserializeInput(request: Request, procedure: Procedure<any, any, any, any, any>): Promise<unknown> {
232+
async function deserializeInput(request: Request, procedure: ANY_PROCEDURE): Promise<unknown> {
184233
const deserializer = new OpenAPIDeserializer({
185234
schema: procedure.zz$p.contract.zz$cp.InputSchema,
186235
})

0 commit comments

Comments
 (0)