fix: 修正 ORPC handler 语义、加固 Electron 安全、优化构建与运行时配置

- todo.router: create 错误码 NOT_FOUND → INTERNAL_SERVER_ERROR,remove 增加存在性检查
- __root: devtools 仅在 DEV 环境渲染
- Electron: 添加 will-navigate 导航拦截、显式安全 webPreferences、deny-all 权限请求
- sidecar: 空 catch 块补充意图注释,新增 lastResolvedUrl getter
- todo.contract: 硬编码 omit 改用 generatedFieldKeys
- router: QueryClient 添加 staleTime/retry 默认值
- turbo: build 任务精细化 inputs 提升缓存命中率
- fields: id() 改为模块私有
This commit is contained in:
2026-03-05 14:06:43 +08:00
parent cd7448c3b3
commit 9d8a38a4c4
8 changed files with 69 additions and 32 deletions

View File

@@ -1,5 +1,5 @@
import { join } from 'node:path' import { join } from 'node:path'
import { app, BrowserWindow, dialog, shell } from 'electron' import { app, BrowserWindow, dialog, session, shell } from 'electron'
import { createSidecarRuntime } from './sidecar' import { createSidecarRuntime } from './sidecar'
const DEV_SERVER_URL = 'http://localhost:3000' const DEV_SERVER_URL = 'http://localhost:3000'
@@ -61,6 +61,8 @@ const createWindow = async () => {
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),
sandbox: true, sandbox: true,
contextIsolation: true,
nodeIntegration: false,
}, },
}) })
mainWindow = windowRef mainWindow = windowRef
@@ -78,6 +80,21 @@ const createWindow = async () => {
return { action: 'deny' } return { action: 'deny' }
}) })
windowRef.webContents.on('will-navigate', (event, url) => {
const allowed = [DEV_SERVER_URL, sidecar.lastResolvedUrl].filter((v): v is string => v != null)
const isAllowed = allowed.some((origin) => url.startsWith(origin))
if (!isAllowed) {
event.preventDefault()
if (canOpenExternally(url)) {
void shell.openExternal(url)
} else if (!app.isPackaged) {
console.warn(`Blocked navigation to: ${url}`)
}
}
})
windowRef.on('closed', () => { windowRef.on('closed', () => {
if (mainWindow === windowRef) { if (mainWindow === windowRef) {
mainWindow = null mainWindow = null
@@ -151,7 +168,13 @@ const handleWindowCreationError = (error: unknown, context: string) => {
app app
.whenReady() .whenReady()
.then(() => ensureWindow()) .then(() => {
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(false)
})
return ensureWindow()
})
.catch((error) => { .catch((error) => {
handleWindowCreationError(error, 'Failed to create window') handleWindowCreationError(error, 'Failed to create window')
}) })

View File

@@ -27,6 +27,7 @@ type SidecarRuntimeOptions = {
type SidecarRuntime = { type SidecarRuntime = {
resolveUrl: () => Promise<string> resolveUrl: () => Promise<string>
stop: () => void stop: () => void
lastResolvedUrl: string | null
} }
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)) const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
@@ -72,7 +73,9 @@ const isServerReady = async (url: string): Promise<boolean> => {
return true return true
} }
} catch {} } catch {
// Expected: probe request fails while server is still starting up
}
} }
return false return false
@@ -239,11 +242,15 @@ export const createSidecarRuntime = (options: SidecarRuntimeOptions): SidecarRun
throw new Error('Dev server not responding. Run `bun dev` in apps/server first.') throw new Error('Dev server not responding. Run `bun dev` in apps/server first.')
} }
state.url = options.devServerUrl
return options.devServerUrl return options.devServerUrl
} }
return { return {
resolveUrl, resolveUrl,
stop, stop,
get lastResolvedUrl() {
return state.url
},
} }
} }

View File

@@ -5,7 +5,14 @@ import type { RouterContext } from './routes/__root'
import { routeTree } from './routeTree.gen' import { routeTree } from './routeTree.gen'
export const getRouter = () => { export const getRouter = () => {
const queryClient = new QueryClient() const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 1,
},
},
})
const router = createRouter({ const router = createRouter({
routeTree, routeTree,

View File

@@ -46,21 +46,23 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
</head> </head>
<body> <body>
{children} {children}
<TanStackDevtools {import.meta.env.DEV && (
config={{ <TanStackDevtools
position: 'bottom-right', config={{
}} position: 'bottom-right',
plugins={[ }}
{ plugins={[
name: 'TanStack Router', {
render: <TanStackRouterDevtoolsPanel />, name: 'TanStack Router',
}, render: <TanStackRouterDevtoolsPanel />,
{ },
name: 'TanStack Query', {
render: <ReactQueryDevtoolsPanel />, name: 'TanStack Query',
}, render: <ReactQueryDevtoolsPanel />,
]} },
/> ]}
/>
)}
<Scripts /> <Scripts />
</body> </body>
</html> </html>

View File

@@ -1,21 +1,14 @@
import { oc } from '@orpc/contract' import { oc } from '@orpc/contract'
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-orm/zod' import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-orm/zod'
import { z } from 'zod' import { z } from 'zod'
import { generatedFieldKeys } from '@/server/db/fields'
import { todoTable } from '@/server/db/schema' import { todoTable } from '@/server/db/schema'
const selectSchema = createSelectSchema(todoTable) const selectSchema = createSelectSchema(todoTable)
const insertSchema = createInsertSchema(todoTable).omit({ const insertSchema = createInsertSchema(todoTable).omit(generatedFieldKeys)
id: true,
createdAt: true,
updatedAt: true,
})
const updateSchema = createUpdateSchema(todoTable).omit({ const updateSchema = createUpdateSchema(todoTable).omit(generatedFieldKeys)
id: true,
createdAt: true,
updatedAt: true,
})
export const list = oc.input(z.void()).output(z.array(selectSchema)) export const list = oc.input(z.void()).output(z.array(selectSchema))

View File

@@ -15,7 +15,7 @@ export const create = os.todo.create.use(db).handler(async ({ context, input })
const [newTodo] = await context.db.insert(todoTable).values(input).returning() const [newTodo] = await context.db.insert(todoTable).values(input).returning()
if (!newTodo) { if (!newTodo) {
throw new ORPCError('NOT_FOUND') throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create todo' })
} }
return newTodo return newTodo
@@ -32,5 +32,9 @@ export const update = os.todo.update.use(db).handler(async ({ context, input })
}) })
export const remove = os.todo.remove.use(db).handler(async ({ context, input }) => { export const remove = os.todo.remove.use(db).handler(async ({ context, input }) => {
await context.db.delete(todoTable).where(eq(todoTable.id, input.id)) const [deleted] = await context.db.delete(todoTable).where(eq(todoTable.id, input.id)).returning({ id: todoTable.id })
if (!deleted) {
throw new ORPCError('NOT_FOUND')
}
}) })

View File

@@ -4,7 +4,7 @@ import { v7 as uuidv7 } from 'uuid'
// id // id
export const id = (name: string) => uuid(name) const id = (name: string) => uuid(name)
export const pk = (name: string, strategy?: 'native' | 'extension') => { export const pk = (name: string, strategy?: 'native' | 'extension') => {
switch (strategy) { switch (strategy) {
// PG 18+ // PG 18+

View File

@@ -4,6 +4,7 @@
"tasks": { "tasks": {
"build": { "build": {
"env": ["NODE_ENV", "VITE_*"], "env": ["NODE_ENV", "VITE_*"],
"inputs": ["src/**", "public/**", "package.json", "tsconfig.json", "vite.config.ts"],
"outputs": [".output/**"] "outputs": [".output/**"]
}, },
"compile": { "compile": {