@jcoder-stack/registry 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@ import { getRequestHeader, setResponseHeader } from "@tanstack/react-start/serve
16
16
  import { z } from "zod";
17
17
  import { authMiddleware } from "./middleware";
18
18
  import { getAuthRuntime } from "./runtime";
19
+ import { unpackUpload } from "./upload-payload";
19
20
 
20
21
  /** SSR 一次取数喂两张嘴:config(AppConfigProvider)+ identity(SessionProvider)。 */
21
22
  export const getAppStateFn = createServerFn({ method: "GET" })
@@ -87,29 +88,49 @@ const abpRequestSchema = z.object({
87
88
  body: z.string().optional(),
88
89
  });
89
90
 
90
- /** 业务 API 的唯一请求边界:orval mutator server fn → 代理网关。 */
91
+ /** 代理调用 + Set-Cookie 落地 + 响应归一,两个请求边界共用。 */
92
+ async function forwardToAbp(
93
+ req: Parameters<typeof callAbpWithSession>[3],
94
+ context: { session: Parameters<typeof callAbpWithSession>[1]; cookieHeader: string | null },
95
+ ) {
96
+ const rt = getAuthRuntime();
97
+ let res: Awaited<ReturnType<typeof callAbpWithSession>>;
98
+ try {
99
+ res = await callAbpWithSession(rt, context.session, context.cookieHeader, req);
100
+ } catch (error) {
101
+ // 失败也要把过程中刷新出的会话 cookie 落到响应,否则轮换型 IdP 下用户被静默登出。
102
+ if (error instanceof AbpProxyError && error.setCookies.length > 0)
103
+ setResponseHeader("Set-Cookie", error.setCookies);
104
+ throw error;
105
+ }
106
+ if (res.setCookies.length > 0) setResponseHeader("Set-Cookie", res.setCookies);
107
+ return {
108
+ status: res.status,
109
+ contentType: res.headers.get("content-type"),
110
+ body: typeof res.body === "string" ? res.body : undefined,
111
+ bodyBase64: typeof res.body === "string" ? undefined : toBase64(res.body),
112
+ };
113
+ }
114
+
115
+ /** 业务 API 的文本请求边界:orval mutator → 此 server fn → 代理网关。 */
91
116
  export const abpRequestFn = createServerFn({ method: "POST" })
92
117
  .validator(abpRequestSchema)
93
118
  .middleware([authMiddleware])
94
- .handler(async ({ data, context }) => {
95
- const rt = getAuthRuntime();
96
- let res: Awaited<ReturnType<typeof callAbpWithSession>>;
97
- try {
98
- res = await callAbpWithSession(rt, context.session, context.cookieHeader, data);
99
- } catch (error) {
100
- // 失败也要把过程中刷新出的会话 cookie 落到响应,否则轮换型 IdP 下用户被静默登出。
101
- if (error instanceof AbpProxyError && error.setCookies.length > 0)
102
- setResponseHeader("Set-Cookie", error.setCookies);
103
- throw error;
104
- }
105
- if (res.setCookies.length > 0) setResponseHeader("Set-Cookie", res.setCookies);
106
- return {
107
- status: res.status,
108
- contentType: res.headers.get("content-type"),
109
- body: typeof res.body === "string" ? res.body : undefined,
110
- bodyBase64: typeof res.body === "string" ? undefined : toBase64(res.body),
111
- };
112
- });
119
+ .handler(({ data, context }) => forwardToAbp(data, context));
120
+
121
+ /** 二进制请求边界:multipart 上传与裸字节直传。
122
+ *
123
+ * 单独一个 server fn 而非让 `abpRequestFn` 双模:Start 只在**整个** payload 就是 FormData 时
124
+ * 才走原生 multipart(`data instanceof FormData`),混不进 JSON 对象里。分开还能让文本那条
125
+ * 路径的 zod schema 一个字不改——所有既有 CRUD 调用不受本次改动影响。
126
+ *
127
+ * 刻意不走 base64 塞进 JSON:seroval 对 typed array 的往返在 1MB 就会抛
128
+ * `SerovalDeserializationError`,而 base64 成字符串虽能过,10MB 文件要变成 13.3MB 字符串
129
+ * 再经两端 JSON 解析。原生 multipart 让文件字节全程不进 JSON。 */
130
+ export const abpUploadFn = createServerFn({ method: "POST" })
131
+ .validator((data: FormData) => data)
132
+ .middleware([authMiddleware])
133
+ .handler(async ({ data, context }) => forwardToAbp(await unpackUpload(data), context));
113
134
 
114
135
  // server fn 边界只序列化 JSON,二进制体转 base64 过桥,abpFetch 端解码还原字节。
115
136
  function toBase64(buffer: ArrayBuffer): string {
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+
3
+ /** 元数据收在单个保留字段里。平铺成 `__path`/`__method`/`__headers` 会把撞名面扩成若干个
4
+ * 保留字——调用方的表单只要有一个同名字段就会被静默覆盖。 */
5
+ const META_FIELD = "__abp";
6
+ /** 裸字节的落点:multipart 只能承载字段,字节得先包成 Blob 才能过去。 */
7
+ const BYTES_FIELD = "__abpBody";
8
+
9
+ const metaSchema = z.object({
10
+ path: z.string(),
11
+ method: z.string().optional(),
12
+ headers: z.record(z.string(), z.string()).optional(),
13
+ kind: z.enum(["form", "bytes"]),
14
+ });
15
+
16
+ /** 可打包的字节形状。全部可重发——`ReadableStream` 刻意不在其中,它只能消费一次,
17
+ * 会让代理的 401 重放与幂等重试静默退化成「重放一个空正文」。 */
18
+ export type UploadBytes = Blob | ArrayBuffer | ArrayBufferView;
19
+ /** `packUpload` 收得下的正文形状。 */
20
+ export type UploadPayloadBody = FormData | UploadBytes;
21
+ /** 解包后交给代理的正文形状。 */
22
+ export type UploadBody = FormData | Uint8Array;
23
+
24
+ export interface UnpackedUpload {
25
+ path: string;
26
+ method?: string;
27
+ headers?: Record<string, string>;
28
+ body: UploadBody;
29
+ }
30
+
31
+ /**
32
+ * 把 path/method/headers 与正文打包成 server fn 能原生传输的 FormData。
33
+ *
34
+ * 与 `unpackUpload` 共享字段名约定,两者必须成对修改;单独存在一个模块里正是为了让这份约定
35
+ * 只有一处真相,且能脱离 TanStack Start 的运行时被测试直接 import。
36
+ */
37
+ export function packUpload(
38
+ path: string,
39
+ method: string | undefined,
40
+ headers: Record<string, string> | undefined,
41
+ body: UploadPayloadBody,
42
+ ): FormData {
43
+ const packed = new FormData();
44
+ const kind = body instanceof FormData ? "form" : "bytes";
45
+ packed.set(META_FIELD, JSON.stringify({ path, method, headers, kind }));
46
+ if (body instanceof FormData) {
47
+ body.forEach((value, name) => {
48
+ packed.append(name, value);
49
+ });
50
+ } else {
51
+ // TS 5.7 起 ArrayBufferView 带上了 ArrayBufferLike 泛型参数(含 SharedArrayBuffer 背衬),
52
+ // 而 BlobPart 只认 ArrayBuffer 背衬那支。把泛型参数写进公开类型能消掉这次转换,代价是
53
+ // 拒掉调用方最常写的裸 `Uint8Array` 标注。与 proxy.ts 里那处同源。
54
+ packed.set(BYTES_FIELD, new Blob([body as BlobPart]));
55
+ }
56
+ return packed;
57
+ }
58
+
59
+ /** `packUpload` 的逆操作;元字段一律剔除,剩下的才是要发给上游的正文。 */
60
+ export async function unpackUpload(packed: FormData): Promise<UnpackedUpload> {
61
+ const raw = packed.get(META_FIELD);
62
+ if (typeof raw !== "string") throw new Error("abp upload: missing meta field");
63
+ const meta = metaSchema.parse(JSON.parse(raw));
64
+ packed.delete(META_FIELD);
65
+ if (meta.kind === "form") {
66
+ return { path: meta.path, method: meta.method, headers: meta.headers, body: packed };
67
+ }
68
+ const blob = packed.get(BYTES_FIELD);
69
+ if (!(blob instanceof Blob)) throw new Error("abp upload: missing byte body");
70
+ return {
71
+ path: meta.path,
72
+ method: meta.method,
73
+ headers: meta.headers,
74
+ body: new Uint8Array(await blob.arrayBuffer()),
75
+ };
76
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jcoder-stack/registry",
3
3
  "description": "Copy-in sources for the auth shell and shadcn blocks: login/callback/logout handlers, the callAbp proxy, route guards, and TanStack wiring",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "auth",
@@ -14,7 +14,7 @@
14
14
  "postpack": "node ../scripts/restore-publish-config.mjs"
15
15
  },
16
16
  "dependencies": {
17
- "@jcoder-stack/abp-react": "^0.2.1",
17
+ "@jcoder-stack/abp-react": "^0.3.0",
18
18
  "@tanstack/react-router": "^1.0.0",
19
19
  "@tanstack/react-start": "^1.0.0",
20
20
  "zod": "^4.0.0"
@@ -99,7 +99,7 @@
99
99
  },
100
100
  {
101
101
  "path": "ui/blocks/app-shell/api/abp-fetch.ts",
102
- "content": "import { configureAbpMutator } from \"@/api/mutator\";\nimport { abpRequestFn } from \"@/auth/server-fns\";\n\nconst NULL_BODY_STATUSES = new Set([204, 205, 304]);\n\n/** fetch 形状的封装:生成的 API 客户端 abpRequestFn(服务端代理边界)。 */\nexport const abpFetch: typeof fetch = async (input, init) => {\n const path =\n typeof input === \"string\" ? input : input instanceof Request ? input.url : input.toString();\n const headers =\n init?.headers === undefined ? undefined : Object.fromEntries(new Headers(init.headers));\n const body = typeof init?.body === \"string\" ? init.body : undefined;\n const res = await abpRequestFn({ data: { path, method: init?.method, headers, body } });\n const responseBody = NULL_BODY_STATUSES.has(res.status)\n ? null\n : res.bodyBase64 === undefined\n ? (res.body ?? null)\n : Uint8Array.from(atob(res.bodyBase64), (char) => char.charCodeAt(0));\n return new Response(responseBody, {\n status: res.status,\n headers: res.contentType ? { \"Content-Type\": res.contentType } : {},\n });\n};\n\nconfigureAbpMutator({ fetchFn: abpFetch });\n",
102
+ "content": "import { configureAbpMutator } from \"@/api/mutator\";\nimport { abpRequestFn, abpUploadFn } from \"@/auth/server-fns\";\nimport { packUpload, type UploadPayloadBody } from \"@/auth/upload-payload\";\n\nconst NULL_BODY_STATUSES = new Set([204, 205, 304]);\n\ninterface AbpResult {\n status: number;\n contentType: string | null;\n body?: string;\n bodyBase64?: string;\n}\n\n/** 文本类正文继续走 JSON 边界;其余(FormData、字节)改走原生 multipart upload fn。\n * `URLSearchParams` 归到文本:它就是 urlencoded 的字符串形态,序列化后无损。 */\nfunction textBodyOf(body: BodyInit | null | undefined): string | undefined {\n if (typeof body === \"string\") return body;\n if (body instanceof URLSearchParams) return body.toString();\n return undefined;\n}\n\n/** 收窄成可打包的二进制正文;`null` 表示这个形状送不过去(目前只有流)。 */\nfunction binaryBodyOf(body: BodyInit): UploadPayloadBody | null {\n if (body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer) return body;\n return ArrayBuffer.isView(body) ? body : null;\n}\n\nfunction toResponse(res: AbpResult): Response {\n const responseBody = NULL_BODY_STATUSES.has(res.status)\n ? null\n : res.bodyBase64 === undefined\n ? (res.body ?? null)\n : Uint8Array.from(atob(res.bodyBase64), (char) => char.charCodeAt(0));\n return new Response(responseBody, {\n status: res.status,\n headers: res.contentType ? { \"Content-Type\": res.contentType } : {},\n });\n}\n\n/** fetch 形状的封装:生成的 API 客户端 → abpRequestFn / abpUploadFn(服务端代理边界)。 */\nexport const abpFetch: typeof fetch = async (input, init) => {\n const path =\n typeof input === \"string\" ? input : input instanceof Request ? input.url : input.toString();\n const headers =\n init?.headers === undefined ? undefined : Object.fromEntries(new Headers(init.headers));\n const body = init?.body;\n const text = textBodyOf(body);\n if (body === undefined || body === null || text !== undefined) {\n return toResponse(await abpRequestFn({ data: { path, method: init?.method, headers, body: text } }));\n }\n const binary = binaryBodyOf(body);\n if (binary === null) {\n throw new TypeError(\n \"abpFetch: a ReadableStream body cannot be replayed after a 401 refresh; buffer it into a Blob or bytes first\",\n );\n }\n return toResponse(await abpUploadFn({ data: packUpload(path, init?.method, headers, binary) }));\n};\n\nconfigureAbpMutator({ fetchFn: abpFetch });\n",
103
103
  "type": "registry:file",
104
104
  "target": "src/api/abp-fetch.ts"
105
105
  },