@briancray/belte 0.2.0 → 0.2.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@briancray/belte",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
6
6
  "license": "MIT",
@@ -2,6 +2,7 @@
2
2
  import { existsSync, statSync } from 'node:fs'
3
3
  import type { BunPlugin } from 'bun'
4
4
  import { Glob } from 'bun'
5
+ import { belteImportName } from './lib/shared/belteImportName.ts'
5
6
  import { fileStem } from './lib/shared/fileStem.ts'
6
7
  import { jsonSchemaForPromptArguments } from './lib/shared/jsonSchemaForPromptArguments.ts'
7
8
  import { log } from './lib/shared/log.ts'
@@ -128,6 +129,13 @@ export function belteResolverPlugin({
128
129
  const scanSocketsOnce = once(() => scanSockets(socketsDir))
129
130
  const scanPromptsOnce = once(() => scanPrompts(promptsDir))
130
131
  const loadShellOnce = once(() => loadShell(cwd))
132
+ /*
133
+ The bare specifier the project imports belte under (canonical
134
+ `@briancray/belte` or a package alias). Resolved once from the project's
135
+ package.json and threaded into every generated module so the codegen's
136
+ imports resolve regardless of which install style the project uses.
137
+ */
138
+ const belteImportNameOnce = once(() => belteImportName(cwd))
131
139
 
132
140
  const rpcFilter = new RegExp(`^${escapeRegex(rpcDir)}/.*\\.ts$`)
133
141
  const socketsFilter = new RegExp(`^${escapeRegex(socketsDir)}/.*\\.ts$`)
@@ -176,7 +184,8 @@ export function belteResolverPlugin({
176
184
  const relativePath = args.path.slice(rpcDir.length + 1)
177
185
  const source = await Bun.file(args.path).text()
178
186
  const url = rpcUrlForFile(relativePath)
179
- const prepared = prepareRpcModule(source)
187
+ const importName = await belteImportNameOnce()
188
+ const prepared = prepareRpcModule(source, importName)
180
189
  if (!prepared) {
181
190
  throw new Error(
182
191
  `[belte] src/server/rpc/${relativePath} has no \`export const <name> = <VERB>(...)\` — every $rpc module must declare exactly one remote function`,
@@ -196,7 +205,7 @@ export function belteResolverPlugin({
196
205
  so page imports resolve identically on both sides.
197
206
  */
198
207
  if (target === 'client') {
199
- const contents = `import { remoteProxy as __belteRemoteProxy__ } from 'belte/browser/remoteProxy';
208
+ const contents = `import { remoteProxy as __belteRemoteProxy__ } from '${importName}/browser/remoteProxy';
200
209
  export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prepared.verb)}, ${JSON.stringify(url)});
201
210
  `
202
211
  return { contents, loader: 'ts' }
@@ -211,7 +220,7 @@ export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prep
211
220
  tokenizer-driven so `GET` mentions inside strings and
212
221
  comments are left alone.
213
222
  */
214
- const banner = `import { defineVerb as __belteDefineVerb__ } from 'belte/server/rpc/defineVerb';
223
+ const banner = `import { defineVerb as __belteDefineVerb__ } from '${importName}/server/rpc/defineVerb';
215
224
  `
216
225
  return { contents: `${banner}${prepared.rewriteForServer(url)}`, loader: 'ts' }
217
226
  })
@@ -223,7 +232,8 @@ export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prep
223
232
  const relativePath = args.path.slice(socketsDir.length + 1)
224
233
  const source = await Bun.file(args.path).text()
225
234
  const name = socketNameForFile(relativePath)
226
- const prepared = prepareSocketModule(source)
235
+ const importName = await belteImportNameOnce()
236
+ const prepared = prepareSocketModule(source, importName)
227
237
  if (!prepared) {
228
238
  throw new Error(
229
239
  `[belte] src/server/sockets/${relativePath} has no \`export const <name> = socket(...)\` — every $sockets module must declare exactly one socket`,
@@ -241,12 +251,12 @@ export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prep
241
251
  clientPublish) are server-side state and don't
242
252
  affect the client's wire behaviour.
243
253
  */
244
- const contents = `import { socketProxy as __belteSocketProxy__ } from 'belte/browser/socketProxy';
254
+ const contents = `import { socketProxy as __belteSocketProxy__ } from '${importName}/browser/socketProxy';
245
255
  export const ${prepared.exportName} = __belteSocketProxy__(${JSON.stringify(name)});
246
256
  `
247
257
  return { contents, loader: 'ts' }
248
258
  }
249
- const banner = `import { defineSocket as __belteDefineSocket__ } from 'belte/server/sockets/defineSocket';
259
+ const banner = `import { defineSocket as __belteDefineSocket__ } from '${importName}/server/sockets/defineSocket';
250
260
  `
251
261
  return {
252
262
  contents: `${banner}${prepared.rewriteForServer(name)}`,
@@ -277,6 +287,7 @@ export const ${prepared.exportName} = __belteSocketProxy__(${JSON.stringify(name
277
287
  const relativePath = args.path.slice(promptsDir.length + 1)
278
288
  const source = await Bun.file(args.path).text()
279
289
  const name = promptNameForFile(relativePath)
290
+ const importName = await belteImportNameOnce()
280
291
  const parsed = parsePromptMarkdown(source)
281
292
  const jsonSchema = jsonSchemaForPromptArguments(parsed.arguments)
282
293
  const optionLines = [
@@ -288,8 +299,8 @@ export const ${prepared.exportName} = __belteSocketProxy__(${JSON.stringify(name
288
299
  ]
289
300
  .filter((line) => line !== undefined)
290
301
  .join('\n')
291
- const contents = `import { definePrompt as __belteDefinePrompt__ } from 'belte/server/prompts/definePrompt'
292
- import { renderPromptTemplate as __belteRenderPromptTemplate__ } from 'belte/server/prompts/renderPromptTemplate'
302
+ const contents = `import { definePrompt as __belteDefinePrompt__ } from '${importName}/server/prompts/definePrompt'
303
+ import { renderPromptTemplate as __belteRenderPromptTemplate__ } from '${importName}/server/prompts/renderPromptTemplate'
293
304
  const __template__ = ${JSON.stringify(parsed.body)}
294
305
  export const prompt = __belteDefinePrompt__(${JSON.stringify(name)}, {
295
306
  ${optionLines}
@@ -574,9 +585,9 @@ export const footer = ${JSON.stringify(footer)}
574
585
  from src/mcp/resources. createMcpServer is internal; there
575
586
  is no user-authored server module.
576
587
  */
588
+ const importName = await belteImportNameOnce()
577
589
  return {
578
- contents:
579
- "import { createMcpServer } from 'belte/mcp/createMcpServer'\nexport default createMcpServer()\n",
590
+ contents: `import { createMcpServer } from '${importName}/mcp/createMcpServer'\nexport default createMcpServer()\n`,
580
591
  loader: 'js',
581
592
  }
582
593
  }
@@ -0,0 +1,58 @@
1
+ import { afterAll, expect, test } from 'bun:test'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { belteImportName } from './belteImportName.ts'
5
+
6
+ const roots: string[] = []
7
+ afterAll(() => roots.forEach((root) => rmSync(root, { recursive: true, force: true })))
8
+
9
+ // Writes a package.json into a fresh temp dir and returns the dir.
10
+ async function projectWith(packageJson: unknown): Promise<string> {
11
+ const root = mkdtempSync(`${tmpdir()}/belte-import-name-`)
12
+ roots.push(root)
13
+ await Bun.write(`${root}/package.json`, JSON.stringify(packageJson))
14
+ return root
15
+ }
16
+
17
+ test('uses the canonical name for a direct dependency', async () => {
18
+ const cwd = await projectWith({ dependencies: { '@briancray/belte': '^0.2.0' } })
19
+ expect(await belteImportName(cwd)).toBe('@briancray/belte')
20
+ })
21
+
22
+ test('uses the `belte` alias key for an npm alias', async () => {
23
+ const cwd = await projectWith({ dependencies: { belte: 'npm:@briancray/belte@^0.2.0' } })
24
+ expect(await belteImportName(cwd)).toBe('belte')
25
+ })
26
+
27
+ test('uses the `belte` alias key for a workspace alias', async () => {
28
+ const cwd = await projectWith({ dependencies: { belte: 'workspace:@briancray/belte@*' } })
29
+ expect(await belteImportName(cwd)).toBe('belte')
30
+ })
31
+
32
+ test('uses a non-`belte` alias key when that is how belte is declared', async () => {
33
+ const cwd = await projectWith({ dependencies: { framework: 'npm:@briancray/belte' } })
34
+ expect(await belteImportName(cwd)).toBe('framework')
35
+ })
36
+
37
+ test('prefers the `belte` alias over a direct canonical dependency', async () => {
38
+ const cwd = await projectWith({
39
+ dependencies: { '@briancray/belte': '^0.2.0', belte: 'npm:@briancray/belte@^0.2.0' },
40
+ })
41
+ expect(await belteImportName(cwd)).toBe('belte')
42
+ })
43
+
44
+ test('finds the alias in devDependencies', async () => {
45
+ const cwd = await projectWith({ devDependencies: { belte: 'npm:@briancray/belte@^0.2.0' } })
46
+ expect(await belteImportName(cwd)).toBe('belte')
47
+ })
48
+
49
+ test('falls back to the canonical name when belte is absent', async () => {
50
+ const cwd = await projectWith({ dependencies: { svelte: '^5.0.0' } })
51
+ expect(await belteImportName(cwd)).toBe('@briancray/belte')
52
+ })
53
+
54
+ test('falls back to the canonical name when package.json is missing', async () => {
55
+ const root = mkdtempSync(`${tmpdir()}/belte-import-name-`)
56
+ roots.push(root)
57
+ expect(await belteImportName(root)).toBe('@briancray/belte')
58
+ })
@@ -0,0 +1,45 @@
1
+ import { beltePackageName } from './beltePackageName.ts'
2
+
3
+ /*
4
+ Resolves the bare specifier prefix a consuming project imports belte under —
5
+ the name belte is installed as in its package.json. A project may depend on
6
+ belte directly (`@briancray/belte`) or behind a package alias
7
+ (`"belte": "npm:@briancray/belte@..."`, or `workspace:@briancray/belte@*`
8
+ inside this repo). An alias-only install resolves only under the alias key and
9
+ a direct install only under the canonical name, so the generated rpc / socket
10
+ / prompt modules must import under whichever name the project actually
11
+ declared.
12
+
13
+ Prefers a `belte` alias (the ergonomic surface the docs use) when present, then
14
+ a direct canonical dependency, then any other alias targeting belte. Falls back
15
+ to the canonical name when belte isn't found in package.json — the build can't
16
+ resolve belte at all in that case, and the canonical name yields the clearest
17
+ resolution error.
18
+ */
19
+ export async function belteImportName(cwd: string): Promise<string> {
20
+ const packageJsonPath = `${cwd}/package.json`
21
+ if (!(await Bun.file(packageJsonPath).exists())) {
22
+ return beltePackageName
23
+ }
24
+ const packageJson = (await Bun.file(packageJsonPath).json()) as {
25
+ dependencies?: Record<string, string>
26
+ devDependencies?: Record<string, string>
27
+ }
28
+ const dependencies = { ...packageJson.devDependencies, ...packageJson.dependencies }
29
+ /*
30
+ Alias entries whose target is belte — `npm:` for a published install,
31
+ `workspace:` for the in-repo examples. The key is the name the project
32
+ imports under; the version suffix (`@^0.2.0`, `@*`) is optional.
33
+ */
34
+ const aliasPattern = new RegExp(`^(npm|workspace):${beltePackageName}(@.*)?$`)
35
+ const aliasNames = Object.entries(dependencies)
36
+ .filter(([, specifier]) => aliasPattern.test(specifier))
37
+ .map(([name]) => name)
38
+ if (aliasNames.includes('belte')) {
39
+ return 'belte'
40
+ }
41
+ if (beltePackageName in dependencies) {
42
+ return beltePackageName
43
+ }
44
+ return aliasNames[0] ?? beltePackageName
45
+ }
@@ -0,0 +1,7 @@
1
+ /*
2
+ The package's published npm name. The codegen and the import-name resolver
3
+ match a consuming project's dependency (direct or aliased) against this to
4
+ decide which specifier to emit, so keeping it in one place means a future
5
+ rename touches a single constant.
6
+ */
7
+ export const beltePackageName = '@briancray/belte'
@@ -1,10 +1,10 @@
1
1
  import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
+ import { beltePackageName } from './beltePackageName.ts'
2
3
  import { findExportCallSite } from './findExportCallSite.ts'
3
4
  import { stripImport } from './stripImport.ts'
4
5
 
5
6
  const VERB_NAMES = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const
6
7
  const VERB_SET = new Set<string>(VERB_NAMES)
7
- const VERB_IMPORT_PATHS = VERB_NAMES.map((verb) => `belte/server/${verb}`)
8
8
 
9
9
  const SINGLE_EXPORT_ERROR =
10
10
  '[belte] $rpc module contains more than one `<VERB>(...)` export — each file must declare exactly one remote function'
@@ -27,14 +27,24 @@ A regex pass would be tidier but it can't tell a `GET` mention inside a
27
27
  docstring or template literal from the real call, and it can't follow
28
28
  nested generics like `GET<Map<K, V>>(`.
29
29
  */
30
- export function prepareRpcModule(source: string): PreparedRpcModule | undefined {
30
+ export function prepareRpcModule(
31
+ source: string,
32
+ importName: string,
33
+ ): PreparedRpcModule | undefined {
31
34
  /*
32
35
  The "no barrels" surface places each verb at its own path
33
36
  (`belte/server/GET`, `belte/server/POST`, …) — strip every one so
34
37
  the user's verb import doesn't linger and side-effect-load the
35
- stub module into the server bundle.
38
+ stub module into the server bundle. The user may import under the
39
+ project's chosen name or the canonical package name, so strip both.
36
40
  */
37
- const stripped = VERB_IMPORT_PATHS.reduce((current, path) => stripImport(current, path), source)
41
+ const importNames =
42
+ importName === beltePackageName ? [beltePackageName] : [importName, beltePackageName]
43
+ const stripped = importNames.reduce(
44
+ (current, name) =>
45
+ VERB_NAMES.reduce((acc, verb) => stripImport(acc, `${name}/server/${verb}`), current),
46
+ source,
47
+ )
38
48
  const site = findExportCallSite(stripped, (ident) => VERB_SET.has(ident), SINGLE_EXPORT_ERROR)
39
49
  if (!site) {
40
50
  return undefined
@@ -1,3 +1,4 @@
1
+ import { beltePackageName } from './beltePackageName.ts'
1
2
  import { findExportCallSite } from './findExportCallSite.ts'
2
3
  import { stripImport } from './stripImport.ts'
3
4
 
@@ -17,8 +18,21 @@ original source). The single scan replaces the prior separate
17
18
  extract + rewrite passes, so the resolver plugin only walks each source
18
19
  character-by-character once.
19
20
  */
20
- export function prepareSocketModule(source: string): PreparedSocketModule | undefined {
21
- const stripped = stripImport(source, 'belte/server/socket')
21
+ export function prepareSocketModule(
22
+ source: string,
23
+ importName: string,
24
+ ): PreparedSocketModule | undefined {
25
+ /*
26
+ Strip the user's `socket` import under the project's chosen name and the
27
+ canonical package name so the dead import can't side-effect-load the
28
+ socket helper into the server bundle.
29
+ */
30
+ const importNames =
31
+ importName === beltePackageName ? [beltePackageName] : [importName, beltePackageName]
32
+ const stripped = importNames.reduce(
33
+ (current, name) => stripImport(current, `${name}/server/socket`),
34
+ source,
35
+ )
22
36
  const site = findExportCallSite(stripped, (ident) => ident === 'socket', SINGLE_EXPORT_ERROR)
23
37
  if (!site) {
24
38
  return undefined
@@ -11,7 +11,7 @@
11
11
  "bundle": "belte bundle"
12
12
  },
13
13
  "dependencies": {
14
- "@briancray/belte": "^0.1.0",
14
+ "belte": "npm:@briancray/belte@^0.2.0",
15
15
  "svelte": "^5.0.0"
16
16
  }
17
17
  }