@briancray/belte 0.2.0 → 0.2.2

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.2",
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'
@@ -111,6 +112,13 @@ export function belteResolverPlugin({
111
112
  const promptsDir = `${mcpDir}/prompts`
112
113
  const resourcesDir = `${mcpDir}/resources`
113
114
 
115
+ /*
116
+ The bare specifier the project imports belte under (canonical
117
+ `@briancray/belte` or a package alias). Resolved once from the project's
118
+ package.json and threaded into every generated module so the codegen's
119
+ imports resolve regardless of which install style the project uses.
120
+ */
121
+ const belteImportNameOnce = once(() => belteImportName(cwd))
114
122
  /*
115
123
  The whole-tree validation + per-leaf classification only needs to run
116
124
  once per build. Memoise the promise so the virtual manifests
@@ -120,7 +128,11 @@ export function belteResolverPlugin({
120
128
  */
121
129
  const scanPagesOnce = once(() =>
122
130
  scanPages(pagesDir).then(async (scan) => {
123
- await writeRoutesDts({ cwd, pageFiles: scan.pageFiles })
131
+ await writeRoutesDts({
132
+ cwd,
133
+ pageFiles: scan.pageFiles,
134
+ importName: await belteImportNameOnce(),
135
+ })
124
136
  return scan
125
137
  }),
126
138
  )
@@ -176,7 +188,8 @@ export function belteResolverPlugin({
176
188
  const relativePath = args.path.slice(rpcDir.length + 1)
177
189
  const source = await Bun.file(args.path).text()
178
190
  const url = rpcUrlForFile(relativePath)
179
- const prepared = prepareRpcModule(source)
191
+ const importName = await belteImportNameOnce()
192
+ const prepared = prepareRpcModule(source, importName)
180
193
  if (!prepared) {
181
194
  throw new Error(
182
195
  `[belte] src/server/rpc/${relativePath} has no \`export const <name> = <VERB>(...)\` — every $rpc module must declare exactly one remote function`,
@@ -196,7 +209,7 @@ export function belteResolverPlugin({
196
209
  so page imports resolve identically on both sides.
197
210
  */
198
211
  if (target === 'client') {
199
- const contents = `import { remoteProxy as __belteRemoteProxy__ } from 'belte/browser/remoteProxy';
212
+ const contents = `import { remoteProxy as __belteRemoteProxy__ } from '${importName}/browser/remoteProxy';
200
213
  export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prepared.verb)}, ${JSON.stringify(url)});
201
214
  `
202
215
  return { contents, loader: 'ts' }
@@ -211,7 +224,7 @@ export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prep
211
224
  tokenizer-driven so `GET` mentions inside strings and
212
225
  comments are left alone.
213
226
  */
214
- const banner = `import { defineVerb as __belteDefineVerb__ } from 'belte/server/rpc/defineVerb';
227
+ const banner = `import { defineVerb as __belteDefineVerb__ } from '${importName}/server/rpc/defineVerb';
215
228
  `
216
229
  return { contents: `${banner}${prepared.rewriteForServer(url)}`, loader: 'ts' }
217
230
  })
@@ -223,7 +236,8 @@ export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prep
223
236
  const relativePath = args.path.slice(socketsDir.length + 1)
224
237
  const source = await Bun.file(args.path).text()
225
238
  const name = socketNameForFile(relativePath)
226
- const prepared = prepareSocketModule(source)
239
+ const importName = await belteImportNameOnce()
240
+ const prepared = prepareSocketModule(source, importName)
227
241
  if (!prepared) {
228
242
  throw new Error(
229
243
  `[belte] src/server/sockets/${relativePath} has no \`export const <name> = socket(...)\` — every $sockets module must declare exactly one socket`,
@@ -241,12 +255,12 @@ export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prep
241
255
  clientPublish) are server-side state and don't
242
256
  affect the client's wire behaviour.
243
257
  */
244
- const contents = `import { socketProxy as __belteSocketProxy__ } from 'belte/browser/socketProxy';
258
+ const contents = `import { socketProxy as __belteSocketProxy__ } from '${importName}/browser/socketProxy';
245
259
  export const ${prepared.exportName} = __belteSocketProxy__(${JSON.stringify(name)});
246
260
  `
247
261
  return { contents, loader: 'ts' }
248
262
  }
249
- const banner = `import { defineSocket as __belteDefineSocket__ } from 'belte/server/sockets/defineSocket';
263
+ const banner = `import { defineSocket as __belteDefineSocket__ } from '${importName}/server/sockets/defineSocket';
250
264
  `
251
265
  return {
252
266
  contents: `${banner}${prepared.rewriteForServer(name)}`,
@@ -277,6 +291,7 @@ export const ${prepared.exportName} = __belteSocketProxy__(${JSON.stringify(name
277
291
  const relativePath = args.path.slice(promptsDir.length + 1)
278
292
  const source = await Bun.file(args.path).text()
279
293
  const name = promptNameForFile(relativePath)
294
+ const importName = await belteImportNameOnce()
280
295
  const parsed = parsePromptMarkdown(source)
281
296
  const jsonSchema = jsonSchemaForPromptArguments(parsed.arguments)
282
297
  const optionLines = [
@@ -288,8 +303,8 @@ export const ${prepared.exportName} = __belteSocketProxy__(${JSON.stringify(name
288
303
  ]
289
304
  .filter((line) => line !== undefined)
290
305
  .join('\n')
291
- const contents = `import { definePrompt as __belteDefinePrompt__ } from 'belte/server/prompts/definePrompt'
292
- import { renderPromptTemplate as __belteRenderPromptTemplate__ } from 'belte/server/prompts/renderPromptTemplate'
306
+ const contents = `import { definePrompt as __belteDefinePrompt__ } from '${importName}/server/prompts/definePrompt'
307
+ import { renderPromptTemplate as __belteRenderPromptTemplate__ } from '${importName}/server/prompts/renderPromptTemplate'
293
308
  const __template__ = ${JSON.stringify(parsed.body)}
294
309
  export const prompt = __belteDefinePrompt__(${JSON.stringify(name)}, {
295
310
  ${optionLines}
@@ -574,9 +589,9 @@ export const footer = ${JSON.stringify(footer)}
574
589
  from src/mcp/resources. createMcpServer is internal; there
575
590
  is no user-authored server module.
576
591
  */
592
+ const importName = await belteImportNameOnce()
577
593
  return {
578
- contents:
579
- "import { createMcpServer } from 'belte/mcp/createMcpServer'\nexport default createMcpServer()\n",
594
+ contents: `import { createMcpServer } from '${importName}/mcp/createMcpServer'\nexport default createMcpServer()\n`,
580
595
  loader: 'js',
581
596
  }
582
597
  }
@@ -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
@@ -30,14 +30,19 @@ page file in the project. Page picks this up as a discriminated union keyed
30
30
  on `route`, so `if (page.route === '/media/[id]') page.params.id` is typed
31
31
  automatically without consumers writing route types by hand.
32
32
  The file is written to `src/.belte/routes.d.ts` so the consumer's existing
33
- src tsconfig include picks it up with no extra configuration.
33
+ src tsconfig include picks it up with no extra configuration. The augmented
34
+ module is keyed on the name the project imports belte under (`importName`),
35
+ so the augmentation matches the consumer's `page` import whether belte is
36
+ installed directly (`@briancray/belte`) or behind an alias.
34
37
  */
35
38
  export async function writeRoutesDts({
36
39
  cwd,
37
40
  pageFiles,
41
+ importName,
38
42
  }: {
39
43
  cwd: string
40
44
  pageFiles: string[]
45
+ importName: string
41
46
  }): Promise<void> {
42
47
  const entries = pageFiles
43
48
  .map((file) => ({
@@ -50,7 +55,7 @@ export async function writeRoutesDts({
50
55
  )
51
56
  .join('\n')
52
57
  const contents = `// Generated by belte. Do not edit by hand.
53
- declare module 'belte/browser/page' {
58
+ declare module '${importName}/browser/page' {
54
59
  interface Routes {
55
60
  ${entries}
56
61
  }
@@ -11,7 +11,7 @@
11
11
  "bundle": "belte bundle"
12
12
  },
13
13
  "dependencies": {
14
- "@briancray/belte": "^0.1.0",
14
+ "@briancray/belte": "^0.2.0",
15
15
  "svelte": "^5.0.0"
16
16
  }
17
17
  }
@@ -7,7 +7,7 @@ module — no import is needed from your own code.
7
7
  handle middleware wrapping the default request pipeline
8
8
  handleError custom 500 fallback
9
9
  */
10
- import type { AppModule } from 'belte/server/AppModule'
10
+ import type { AppModule } from '@briancray/belte/server/AppModule'
11
11
 
12
12
  export const init: AppModule['init'] = ({ server }) => {
13
13
  console.log(`server listening on http://localhost:${server.port}`)
@@ -3,7 +3,7 @@ Root page — served at GET /. Every folder under src/browser/pages/ that
3
3
  contains a page.svelte mounts at that folder's URL.
4
4
  -->
5
5
  <script lang="ts">
6
- import { cache } from 'belte/browser/cache'
6
+ import { cache } from '@briancray/belte/browser/cache'
7
7
  import { getHello } from '$server/rpc/getHello.ts'
8
8
 
9
9
  /*
@@ -27,7 +27,7 @@ Every rpc value also exposes `.raw(args?)` (returns the underlying
27
27
  for callers that need headers/status or want to iterate SSE/JSONL frames.
28
28
  */
29
29
 
30
- import { GET } from 'belte/server/GET'
31
- import { json } from 'belte/server/json'
30
+ import { GET } from '@briancray/belte/server/GET'
31
+ import { json } from '@briancray/belte/server/json'
32
32
 
33
33
  export const getHello = GET(() => json({ message: 'Hello from belte' }))
@@ -3,7 +3,7 @@ Optional Svelte compiler configuration. Same shape as upstream Svelte.
3
3
  Delete this file to use defaults.
4
4
  */
5
5
 
6
- /** @type {import('belte').SvelteConfig} */
6
+ /** @type {import('@briancray/belte').SvelteConfig} */
7
7
  export default {
8
8
  compilerOptions: {
9
9
  // Opt in to top-level await inside Svelte components.
@@ -1,5 +1,5 @@
1
1
  {
2
- "extends": "belte/tsconfig",
2
+ "extends": "@briancray/belte/tsconfig",
3
3
  "include": ["src/**/*.ts", "src/**/*.svelte"],
4
4
  "compilerOptions": {
5
5
  "paths": {