Skip to content

Commit 96e1fd4

Browse files
committed
ensure no node exports in internal cient create helpers
1 parent 5ca465b commit 96e1fd4

7 files changed

Lines changed: 104 additions & 12 deletions

File tree

packages/shiftapi/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
"./internal": {
2525
"types": "./dist/internal.d.mts",
2626
"default": "./dist/internal.mjs"
27+
},
28+
"./internal/browser": {
29+
"types": "./dist/browser.d.mts",
30+
"default": "./dist/browser.mjs"
2731
}
2832
},
2933
"bin": {

packages/shiftapi/src/__tests__/generate.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from "vitest";
22
import { resolve } from "node:path";
3+
import { readFileSync, existsSync } from "node:fs";
34
import { extractSpec } from "../extract";
45
import { generateTypes } from "../generate";
56
import { virtualModuleTemplate } from "../templates";
@@ -112,6 +113,49 @@ describe("generateTypes", () => {
112113
});
113114
});
114115

116+
describe("browser entry point", () => {
117+
const distDir = resolve(__dirname, "../../dist");
118+
119+
/**
120+
* Recursively collect all local chunk imports starting from an entry file.
121+
*/
122+
function collectChunks(entryFile: string): string[] {
123+
const seen = new Set<string>();
124+
const queue = [entryFile];
125+
while (queue.length > 0) {
126+
const file = queue.pop()!;
127+
if (seen.has(file)) continue;
128+
seen.add(file);
129+
if (!existsSync(file)) continue;
130+
const content = readFileSync(file, "utf-8");
131+
const importRe = /from\s+"\.\/([^"]+)"/g;
132+
let m;
133+
while ((m = importRe.exec(content)) !== null) {
134+
queue.push(resolve(distDir, m[1]));
135+
}
136+
}
137+
return [...seen];
138+
}
139+
140+
it("does not depend on Node.js built-ins", () => {
141+
const entry = resolve(distDir, "browser.mjs");
142+
if (!existsSync(entry)) {
143+
// Skip if dist hasn't been built (CI may run tests before build).
144+
return;
145+
}
146+
const files = collectChunks(entry);
147+
for (const file of files) {
148+
const content = readFileSync(file, "utf-8");
149+
const nodeImports = content.match(/from\s+["']node:[^"']+["']/g);
150+
if (nodeImports) {
151+
throw new Error(
152+
`${file} imports Node.js built-ins (would break in browser):\n ${nodeImports.join("\n ")}`,
153+
);
154+
}
155+
}
156+
});
157+
});
158+
115159
describe("virtualModuleTemplate", () => {
116160
it("produces a runtime JS module with client export", () => {
117161
const source = virtualModuleTemplate("/api");
@@ -121,7 +165,7 @@ describe("virtualModuleTemplate", () => {
121165
expect(source).toContain("import.meta.env.VITE_SHIFTAPI_BASE_URL");
122166
expect(source).toContain("/api");
123167
expect(source).toContain("export { createClient }");
124-
expect(source).toContain('import { createSSE, createWebSocket } from "shiftapi/internal"');
168+
expect(source).toContain('import { createSSE, createWebSocket } from "shiftapi/internal/browser"');
125169
expect(source).toContain("export const sse = createSSE(");
126170
expect(source).toContain("export const websocket = createWebSocket(");
127171
// Should NOT contain TypeScript syntax

packages/shiftapi/src/browser.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { createSSE } from "./sse";
2+
export type { SSEStream, SubscribeOptions, SSEFn } from "./sse";
3+
export { createWebSocket, WSError } from "./websocket";
4+
export type { WSConnection, WebSocketOptions, WebSocketFn } from "./websocket";

packages/shiftapi/src/templates.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ function buildSchemaExports(generatedTypes: string): string {
192192

193193
export function dtsTemplate(generatedTypes: string, asyncapiSpec: object | null = null): string {
194194
const wsSection = buildWSChannelsType(asyncapiSpec);
195-
const wsImport = wsSection ? '\n import type { WSConnection } from "shiftapi/internal";\n import { WSError } from "shiftapi/internal";' : "";
195+
const wsImport = wsSection ? '\n import type { WSConnection } from "shiftapi/internal/browser";\n import { WSError } from "shiftapi/internal/browser";' : "";
196196
const schemaExports = buildSchemaExports(generatedTypes);
197197

198198
return `\
@@ -201,7 +201,7 @@ declare module "@shiftapi/client" {
201201
${indent(generatedTypes)}
202202
203203
import type createClient from "openapi-fetch";
204-
import type { SSEStream } from "shiftapi/internal";${wsImport}
204+
import type { SSEStream } from "shiftapi/internal/browser";${wsImport}
205205
206206
type SSEPaths = {
207207
[P in keyof paths]: paths[P] extends {
@@ -235,7 +235,7 @@ export function clientJsTemplate(baseUrl: string, options?: { hasWebSocket?: boo
235235
return `\
236236
// Auto-generated by shiftapi. Do not edit.
237237
import createClient from "openapi-fetch";
238-
import { createSSE, createWebSocket${wsImport} } from "shiftapi/internal";
238+
import { createSSE, createWebSocket${wsImport} } from "shiftapi/internal/browser";
239239
240240
/** Pre-configured, fully-typed API client. */
241241
export const client = createClient({
@@ -263,7 +263,7 @@ export function nextClientJsTemplate(
263263
return `\
264264
// Auto-generated by @shiftapi/next. Do not edit.
265265
import createClient from "./openapi-fetch.js";
266-
import { createSSE, createWebSocket${wsImport} } from "shiftapi/internal";
266+
import { createSSE, createWebSocket${wsImport} } from "shiftapi/internal/browser";
267267
268268
const baseUrl =
269269
process.env.NEXT_PUBLIC_SHIFTAPI_BASE_URL || ${JSON.stringify(baseUrl)};
@@ -285,7 +285,7 @@ export { createClient${wsExport} };
285285
return `\
286286
// Auto-generated by @shiftapi/next. Do not edit.
287287
import createClient from "./openapi-fetch.js";
288-
import { createSSE, createWebSocket${wsImport} } from "shiftapi/internal";
288+
import { createSSE, createWebSocket${wsImport} } from "shiftapi/internal/browser";
289289
290290
const baseUrl =
291291
process.env.NEXT_PUBLIC_SHIFTAPI_BASE_URL ||
@@ -321,7 +321,7 @@ export function virtualModuleTemplate(
321321
return `\
322322
// Auto-generated by @shiftapi/vite-plugin
323323
import createClient from "openapi-fetch";
324-
import { createSSE, createWebSocket${wsImport} } from "shiftapi/internal";
324+
import { createSSE, createWebSocket${wsImport} } from "shiftapi/internal/browser";
325325
326326
const baseUrl = ${baseUrlExpr};
327327

packages/shiftapi/tsdown.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export default defineConfig({
44
entry: [
55
"src/index.ts",
66
"src/internal.ts",
7+
"src/browser.ts",
78
"src/prepare.ts",
89
],
910
});

packages/vite-plugin/src/index.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,8 @@ export default function shiftapiPlugin(opts?: ShiftAPIPluginOptions): Plugin {
145145
if (id === "openapi-fetch" && importer === RESOLVED_MODULE_ID) {
146146
return createRequire(import.meta.url).resolve("openapi-fetch");
147147
}
148-
if (id === "shiftapi/internal" && importer === RESOLVED_MODULE_ID) {
149-
return createRequire(import.meta.url).resolve("shiftapi/internal");
150-
}
151-
if (id === "shiftapi/internal" && importer === RESOLVED_MODULE_ID) {
152-
return createRequire(import.meta.url).resolve("shiftapi/internal");
148+
if (id === "shiftapi/internal/browser" && importer === RESOLVED_MODULE_ID) {
149+
return createRequire(import.meta.url).resolve("shiftapi/internal/browser");
153150
}
154151
},
155152

ws_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,48 @@ func TestHandleWS_AsyncAPISpec(t *testing.T) {
181181
}
182182
}
183183

184+
func TestHandleWS_OpenAPISchemaProperties(t *testing.T) {
185+
api := shiftapi.New()
186+
187+
shiftapi.HandleWS(api, "GET /ws",
188+
shiftapi.Websocket(
189+
noSetup,
190+
shiftapi.WSSends(shiftapi.WSMessageType[wsServerMsg]("server")),
191+
shiftapi.WSOn("echo", func(sender *shiftapi.WSSender, _ struct{}, msg wsClientMsg) error {
192+
return sender.Send(wsServerMsg(msg))
193+
}),
194+
),
195+
)
196+
197+
w := httptest.NewRecorder()
198+
r := httptest.NewRequest("GET", "/openapi.json", nil)
199+
api.ServeHTTP(w, r)
200+
201+
var spec map[string]any
202+
if err := json.NewDecoder(w.Body).Decode(&spec); err != nil {
203+
t.Fatalf("decode spec: %v", err)
204+
}
205+
206+
oaComponents := spec["components"].(map[string]any)
207+
oaSchemas := oaComponents["schemas"].(map[string]any)
208+
209+
// Verify WS schemas have actual properties, not just empty entries.
210+
// This ensures openapi-typescript generates real types (not any).
211+
for _, name := range []string{"wsServerMsg", "wsClientMsg"} {
212+
schema, ok := oaSchemas[name].(map[string]any)
213+
if !ok {
214+
t.Fatalf("missing %s schema in OpenAPI components", name)
215+
}
216+
props, ok := schema["properties"].(map[string]any)
217+
if !ok || len(props) == 0 {
218+
t.Errorf("schema %s has no properties; would resolve to any in generated client", name)
219+
}
220+
if _, ok := props["text"]; !ok {
221+
t.Errorf("schema %s missing 'text' property", name)
222+
}
223+
}
224+
}
225+
184226
func TestHandleWS_AsyncAPISpec_XErrors(t *testing.T) {
185227
api := shiftapi.New()
186228

0 commit comments

Comments
 (0)