Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/code-mode-skills-worker-safe-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/ai-code-mode-skills': minor
---

Make the `@tanstack/ai-code-mode-skills` root export Worker/browser-safe.

The root entry previously re-exported `createFileSkillStorage` (via `export * from './storage'`), which eagerly pulled in `node:fs` / `node:path`. This broke Cloudflare Workers and browser bundlers even for consumers that only used non-storage helpers like `createSkillManagementTools` or `createSkillsSystemPrompt`.

The Node-only file storage now lives **only** behind the `@tanstack/ai-code-mode-skills/storage` subpath. The root entry still re-exports the browser-safe `createMemorySkillStorage`.

**Breaking:** import `createFileSkillStorage` from `@tanstack/ai-code-mode-skills/storage` instead of the package root.
13 changes: 10 additions & 3 deletions docs/code-mode/code-mode-with-skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ Skills are persisted through the `SkillStorage` interface. Two implementations a

### File storage (production)

`createFileSkillStorage` is Node-only β€” it imports `node:fs` / `node:path` β€” so
it lives behind the `/storage` subpath rather than the package root. This keeps
the root export safe to bundle for Cloudflare Workers and browser builds; only
reach for the subpath in a Node runtime.

```typescript
import { createFileSkillStorage } from '@tanstack/ai-code-mode-skills/storage'

Expand All @@ -227,15 +232,17 @@ Creates a directory structure:
code.ts
```

### Memory storage (testing)
### Memory storage (testing & edge runtimes)

```typescript
import { createMemorySkillStorage } from '@tanstack/ai-code-mode-skills/storage'
import { createMemorySkillStorage } from '@tanstack/ai-code-mode-skills'

const storage = createMemorySkillStorage()
```

Keeps everything in memory. Useful for tests and demos.
Keeps everything in memory β€” no `node:fs` dependency, so it is re-exported from
the package root and is safe to use in Workers and browsers. Useful for tests,
demos, and edge deployments. (It is also available from the `/storage` subpath.)

### Storage interface

Expand Down
3 changes: 2 additions & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@
{
"label": "Code Mode with Skills",
"to": "code-mode/code-mode-with-skills",
"addedAt": "2026-04-15"
"addedAt": "2026-04-15",
"updatedAt": "2026-06-10"
},
{
"label": "Code Mode Isolate Drivers",
Expand Down
37 changes: 23 additions & 14 deletions packages/ai-code-mode-skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ pnpm add @tanstack/ai-code-mode-skills
```typescript
import {
codeModeWithSkills,
createFileSkillStorage,
createAlwaysTrustedStrategy,
} from '@tanstack/ai-code-mode-skills'
// Node-only file storage lives behind the `/storage` subpath so the root
// export stays safe for Worker/browser bundlers.
import { createFileSkillStorage } from '@tanstack/ai-code-mode-skills/storage'
import { createNodeIsolateDriver } from '@tanstack/ai-isolate-node'

// Create skill storage
Expand All @@ -37,20 +39,21 @@ const codeModeConfig = {
}

// Build a dynamic registry and system prompt with skills
const { registry, systemPrompt, selectedSkills } = await codeModeWithSkills({
config: codeModeConfig,
adapter: anthropic('claude-3-haiku'), // Cheap model for skill selection
skills: {
storage: skillStorage,
maxSkillsInContext: 5,
},
messages,
})
const { toolsRegistry, systemPrompt, selectedSkills } =
await codeModeWithSkills({
config: codeModeConfig,
adapter: anthropic('claude-3-haiku'), // Cheap model for skill selection
skills: {
storage: skillStorage,
maxSkillsInContext: 5,
},
messages,
})

// Use in chat
const stream = chat({
adapter: anthropic('claude-sonnet-4-20250514'), // Main model
toolRegistry: registry,
toolRegistry: toolsRegistry,
messages,
systemPrompts: [basePrompt, systemPrompt],
})
Expand Down Expand Up @@ -151,11 +154,17 @@ Creates Code Mode tools and system prompt with skills integration.

### Storage

Storage is available from both the root export and the explicit storage subpath:
The worker/browser-safe in-memory storage (`createMemorySkillStorage`) is
re-exported from the root entry. The Node-only file storage
(`createFileSkillStorage`) imports `node:fs` / `node:path`, so it is only
available from the `/storage` subpath β€” keeping the root export safe to import
from Cloudflare Workers and browser bundlers:

```typescript
import { createFileSkillStorage } from '@tanstack/ai-code-mode-skills'
// or
// Worker/browser-safe β€” root export
import { createMemorySkillStorage } from '@tanstack/ai-code-mode-skills'

// Node-only β€” `/storage` subpath
import { createFileSkillStorage } from '@tanstack/ai-code-mode-skills/storage'
```

Expand Down
3 changes: 3 additions & 0 deletions packages/ai-code-mode-skills/src/code-mode-with-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export type { CodeModeWithSkillsOptions, CodeModeWithSkillsResult }
*
* @example
* ```typescript
* // Node-only file storage lives behind the `/storage` subpath:
* import { createFileSkillStorage } from '@tanstack/ai-code-mode-skills/storage'
*
* const { toolsRegistry, systemPrompt, selectedSkills } = await codeModeWithSkills({
* config: {
* driver: createNodeIsolateDriver(),
Expand Down
8 changes: 7 additions & 1 deletion packages/ai-code-mode-skills/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ export { createSkillsSystemPrompt } from './create-skills-system-prompt'
export { generateSkillTypes } from './generate-skill-types'

// Storage implementations
export * from './storage'
//
// Only the worker/browser-safe in-memory storage is re-exported from the root
// entry. The Node-only file storage (`createFileSkillStorage`) imports
// `node:fs` / `node:path`, so it lives behind the `@tanstack/ai-code-mode-skills/storage`
// subpath to keep this root export safe for Cloudflare Workers and browser bundlers.
export { createMemorySkillStorage } from './storage/memory-storage'
export type { MemorySkillStorageOptions } from './storage/memory-storage'

// All types
export type {
Expand Down
115 changes: 115 additions & 0 deletions packages/ai-code-mode-skills/tests/root-export-worker-safe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import * as rootEntry from '../src/index'

const SRC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../src')

/** Resolve a `.`-relative import specifier to its on-disk source file. */
async function resolveRelative(
entry: string,
spec: string,
): Promise<string | undefined> {
const base = resolve(dirname(entry), spec)
// Try the literal path first (covers `./foo.js` NodeNext/ESM-style specifiers
// whose source is `foo.ts`), then the usual TS source extensions and barrels.
const candidates = [
base,
base.replace(/\.[cm]?js$/, '.ts'),
base.replace(/\.[cm]?js$/, '.tsx'),
`${base}.ts`,
`${base}.tsx`,
resolve(base, 'index.ts'),
resolve(base, 'index.tsx'),
]
for (const candidate of candidates) {
try {
await readFile(candidate, 'utf8')
return candidate
} catch (err) {
// Only "file not found" means "try the next candidate"; anything else
// (permissions, decode failure) is a real problem and must surface.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
}
return undefined
}

/**
* Walk the transitive module graph of an entry file (following only relative
* imports β€” package imports are treated as leaves) and collect every
* `node:`-prefixed builtin it statically references. Both static `from '…'`
* forms and dynamic `import('…')` calls are followed.
*
* This guards the regression behind issue #486: the root entry must stay safe
* to bundle for Cloudflare Workers / browsers, so it must not statically reach
* the Node-only file storage that imports `node:fs` / `node:path`.
*/
async function collectNodeBuiltins(
entry: string,
seen = new Set<string>(),
builtins = new Set<string>(),
): Promise<Set<string>> {
if (seen.has(entry)) return builtins
seen.add(entry)

const source = await readFile(entry, 'utf8')
// Static `import/export … from '…'` plus dynamic `import('…')`.
const specRe =
/(?:(?:import|export)[\s\S]*?from\s*['"]([^'"]+)['"])|(?:import\s*\(\s*['"]([^'"]+)['"]\s*\))/g

for (const match of source.matchAll(specRe)) {
const spec = match[1] ?? match[2]
if (!spec) continue

if (spec.startsWith('node:')) {
builtins.add(spec)
continue
}

if (spec.startsWith('.')) {
const resolved = await resolveRelative(entry, spec)
if (!resolved) {
// A real relative import the walker can't follow would silently drop a
// subtree (and any node: import in it), turning this guard into a false
// pass. Fail loudly instead.
throw new Error(
`Could not resolve relative import "${spec}" from "${entry}". ` +
`The module-graph walk would silently skip this subtree and the ` +
`#486 guard would be unsound.`,
)
}
await collectNodeBuiltins(resolved, seen, builtins)
}
}

return builtins
}

describe('root export worker/browser safety (#486)', () => {
it('does not statically import any node: builtin from the root entry', async () => {
const builtins = await collectNodeBuiltins(resolve(SRC_DIR, 'index.ts'))
expect([...builtins]).toEqual([])
})

it('keeps node: builtins reachable through the /storage subpath', async () => {
const builtins = await collectNodeBuiltins(
resolve(SRC_DIR, 'storage/index.ts'),
)
expect([...builtins].sort()).toEqual([
'node:fs',
'node:fs/promises',
'node:path',
])
})

it('re-exports the browser-safe memory storage but not the Node-only file storage from root', () => {
// The public contract issue #486 is about: the root entry must expose the
// in-memory storage and must NOT expose the Node-only file storage.
expect(typeof rootEntry.createMemorySkillStorage).toBe('function')
expect(
(rootEntry as Record<string, unknown>).createFileSkillStorage,
).toBeUndefined()
})
})
Loading