tjs-lang 0.2.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.
Files changed (91) hide show
  1. package/CONTEXT.md +594 -0
  2. package/LICENSE +190 -0
  3. package/README.md +220 -0
  4. package/bin/benchmarks.ts +351 -0
  5. package/bin/dev.ts +205 -0
  6. package/bin/docs.js +170 -0
  7. package/bin/install-cursor.sh +71 -0
  8. package/bin/install-vscode.sh +71 -0
  9. package/bin/select-local-models.d.ts +1 -0
  10. package/bin/select-local-models.js +28 -0
  11. package/bin/select-local-models.ts +31 -0
  12. package/demo/autocomplete.test.ts +232 -0
  13. package/demo/docs.json +186 -0
  14. package/demo/examples.test.ts +598 -0
  15. package/demo/index.html +91 -0
  16. package/demo/src/autocomplete.ts +482 -0
  17. package/demo/src/capabilities.ts +859 -0
  18. package/demo/src/demo-nav.ts +2097 -0
  19. package/demo/src/examples.test.ts +161 -0
  20. package/demo/src/examples.ts +476 -0
  21. package/demo/src/imports.test.ts +196 -0
  22. package/demo/src/imports.ts +421 -0
  23. package/demo/src/index.ts +639 -0
  24. package/demo/src/module-store.ts +635 -0
  25. package/demo/src/module-sw.ts +132 -0
  26. package/demo/src/playground.ts +949 -0
  27. package/demo/src/service-host.ts +389 -0
  28. package/demo/src/settings.ts +440 -0
  29. package/demo/src/style.ts +280 -0
  30. package/demo/src/tjs-playground.ts +1605 -0
  31. package/demo/src/ts-examples.ts +478 -0
  32. package/demo/src/ts-playground.ts +1092 -0
  33. package/demo/static/favicon.svg +30 -0
  34. package/demo/static/photo-1.jpg +0 -0
  35. package/demo/static/photo-2.jpg +0 -0
  36. package/demo/static/texts/ai-history.txt +9 -0
  37. package/demo/static/texts/coffee-origins.txt +9 -0
  38. package/demo/static/texts/renewable-energy.txt +9 -0
  39. package/dist/index.js +256 -0
  40. package/dist/index.js.map +37 -0
  41. package/dist/tjs-batteries.js +4 -0
  42. package/dist/tjs-batteries.js.map +15 -0
  43. package/dist/tjs-full.js +256 -0
  44. package/dist/tjs-full.js.map +37 -0
  45. package/dist/tjs-transpiler.js +220 -0
  46. package/dist/tjs-transpiler.js.map +21 -0
  47. package/dist/tjs-vm.js +4 -0
  48. package/dist/tjs-vm.js.map +14 -0
  49. package/docs/CNAME +1 -0
  50. package/docs/favicon.svg +30 -0
  51. package/docs/index.html +91 -0
  52. package/docs/index.js +10468 -0
  53. package/docs/index.js.map +92 -0
  54. package/docs/photo-1.jpg +0 -0
  55. package/docs/photo-1.webp +0 -0
  56. package/docs/photo-2.jpg +0 -0
  57. package/docs/photo-2.webp +0 -0
  58. package/docs/texts/ai-history.txt +9 -0
  59. package/docs/texts/coffee-origins.txt +9 -0
  60. package/docs/texts/renewable-energy.txt +9 -0
  61. package/docs/tjs-lang.svg +31 -0
  62. package/docs/tosijs-agent.svg +31 -0
  63. package/editors/README.md +325 -0
  64. package/editors/ace/ajs-mode.js +328 -0
  65. package/editors/ace/ajs-mode.ts +269 -0
  66. package/editors/ajs-syntax.ts +212 -0
  67. package/editors/build-grammars.ts +510 -0
  68. package/editors/codemirror/ajs-language.js +287 -0
  69. package/editors/codemirror/ajs-language.ts +1447 -0
  70. package/editors/codemirror/autocomplete.test.ts +531 -0
  71. package/editors/codemirror/component.ts +404 -0
  72. package/editors/monaco/ajs-monarch.js +243 -0
  73. package/editors/monaco/ajs-monarch.ts +225 -0
  74. package/editors/tjs-syntax.ts +115 -0
  75. package/editors/vscode/language-configuration.json +37 -0
  76. package/editors/vscode/package.json +65 -0
  77. package/editors/vscode/syntaxes/ajs-injection.tmLanguage.json +107 -0
  78. package/editors/vscode/syntaxes/ajs.tmLanguage.json +252 -0
  79. package/editors/vscode/syntaxes/tjs.tmLanguage.json +333 -0
  80. package/package.json +83 -0
  81. package/src/cli/commands/check.ts +41 -0
  82. package/src/cli/commands/convert.ts +133 -0
  83. package/src/cli/commands/emit.ts +260 -0
  84. package/src/cli/commands/run.ts +68 -0
  85. package/src/cli/commands/test.ts +194 -0
  86. package/src/cli/commands/types.ts +20 -0
  87. package/src/cli/create-app.ts +236 -0
  88. package/src/cli/playground.ts +250 -0
  89. package/src/cli/tjs.ts +166 -0
  90. package/src/cli/tjsx.ts +160 -0
  91. package/tjs-lang.svg +31 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Unit tests for import resolution infrastructure
3
+ *
4
+ * Tests the synchronous functions that don't require browser/network.
5
+ */
6
+
7
+ import { describe, it, expect } from 'bun:test'
8
+ import {
9
+ extractImports,
10
+ getCDNUrl,
11
+ generateImportMap,
12
+ generateImportMapScript,
13
+ wrapAsModule,
14
+ clearModuleCache,
15
+ getCacheStats,
16
+ } from './imports'
17
+
18
+ describe('extractImports', () => {
19
+ it('should extract named imports', () => {
20
+ const source = `import { foo, bar } from 'some-package'`
21
+ expect(extractImports(source)).toEqual(['some-package'])
22
+ })
23
+
24
+ it('should extract default imports', () => {
25
+ const source = `import React from 'react'`
26
+ expect(extractImports(source)).toEqual(['react'])
27
+ })
28
+
29
+ it('should extract namespace imports', () => {
30
+ const source = `import * as lodash from 'lodash'`
31
+ expect(extractImports(source)).toEqual(['lodash'])
32
+ })
33
+
34
+ it('should extract side-effect imports', () => {
35
+ const source = `import 'polyfill'`
36
+ expect(extractImports(source)).toEqual(['polyfill'])
37
+ })
38
+
39
+ it('should extract re-exports', () => {
40
+ const source = `export { foo } from 'some-package'`
41
+ expect(extractImports(source)).toEqual(['some-package'])
42
+ })
43
+
44
+ it('should handle multiple imports', () => {
45
+ const source = `
46
+ import { add } from 'lodash'
47
+ import { format } from 'date-fns'
48
+ import React from 'react'
49
+ `
50
+ expect(extractImports(source)).toEqual(['lodash', 'date-fns', 'react'])
51
+ })
52
+
53
+ it('should deduplicate imports', () => {
54
+ const source = `
55
+ import { add } from 'lodash'
56
+ import { subtract } from 'lodash'
57
+ `
58
+ expect(extractImports(source)).toEqual(['lodash'])
59
+ })
60
+
61
+ it('should ignore relative imports', () => {
62
+ const source = `
63
+ import { foo } from './local'
64
+ import { bar } from '../parent'
65
+ import { baz } from '/absolute'
66
+ import { qux } from 'npm-package'
67
+ `
68
+ expect(extractImports(source)).toEqual(['npm-package'])
69
+ })
70
+
71
+ it('should handle subpath imports', () => {
72
+ const source = `import { debounce } from 'lodash/debounce'`
73
+ expect(extractImports(source)).toEqual(['lodash/debounce'])
74
+ })
75
+
76
+ it('should handle scoped packages', () => {
77
+ const source = `import { something } from '@scope/package'`
78
+ expect(extractImports(source)).toEqual(['@scope/package'])
79
+ })
80
+
81
+ it('should handle scoped packages with subpaths', () => {
82
+ const source = `import { util } from '@scope/package/utils'`
83
+ expect(extractImports(source)).toEqual(['@scope/package/utils'])
84
+ })
85
+
86
+ it('should handle double quotes', () => {
87
+ const source = `import { foo } from "some-package"`
88
+ expect(extractImports(source)).toEqual(['some-package'])
89
+ })
90
+
91
+ it('should return empty array for no imports', () => {
92
+ const source = `const x = 1; function foo() { return x }`
93
+ expect(extractImports(source)).toEqual([])
94
+ })
95
+ })
96
+
97
+ describe('getCDNUrl', () => {
98
+ it('should generate URL for simple package', () => {
99
+ expect(getCDNUrl('some-unknown-package')).toBe(
100
+ 'https://unpkg.com/some-unknown-package'
101
+ )
102
+ })
103
+
104
+ it('should use pinned version and path for known packages', () => {
105
+ // tosijs has pinned version and path
106
+ expect(getCDNUrl('tosijs')).toBe(
107
+ 'https://unpkg.com/tosijs@1.0.10/dist/module.js'
108
+ )
109
+ // date-fns has pinned version but no path
110
+ expect(getCDNUrl('date-fns')).toBe('https://unpkg.com/date-fns@3.6.0')
111
+ })
112
+
113
+ it('should handle subpath imports with pinned version', () => {
114
+ expect(getCDNUrl('lodash-es/debounce')).toBe(
115
+ 'https://unpkg.com/lodash-es@4.17.21/debounce'
116
+ )
117
+ })
118
+
119
+ it('should handle scoped packages', () => {
120
+ expect(getCDNUrl('@scope/package')).toBe('https://unpkg.com/@scope/package')
121
+ })
122
+
123
+ it('should handle scoped packages with subpaths', () => {
124
+ expect(getCDNUrl('@scope/package/utils')).toBe(
125
+ 'https://unpkg.com/@scope/package/utils'
126
+ )
127
+ })
128
+
129
+ it('should handle packages with pinned version but no path', () => {
130
+ // lodash-es is pinned but has no explicit path
131
+ expect(getCDNUrl('lodash-es')).toBe('https://unpkg.com/lodash-es@4.17.21')
132
+ })
133
+ })
134
+
135
+ describe('generateImportMap', () => {
136
+ it('should generate import map for specifiers', () => {
137
+ const result = generateImportMap(['tosijs', 'date-fns'])
138
+ expect(result).toEqual({
139
+ imports: {
140
+ tosijs: 'https://unpkg.com/tosijs@1.0.10/dist/module.js',
141
+ 'date-fns': 'https://unpkg.com/date-fns@3.6.0',
142
+ },
143
+ })
144
+ })
145
+
146
+ it('should handle empty array', () => {
147
+ expect(generateImportMap([])).toEqual({ imports: {} })
148
+ })
149
+
150
+ it('should handle subpath imports', () => {
151
+ const result = generateImportMap(['lodash-es/debounce'])
152
+ expect(result.imports['lodash-es/debounce']).toBe(
153
+ 'https://unpkg.com/lodash-es@4.17.21/debounce'
154
+ )
155
+ })
156
+ })
157
+
158
+ describe('generateImportMapScript', () => {
159
+ it('should generate script tag with import map', () => {
160
+ const importMap = {
161
+ imports: { tosijs: 'https://unpkg.com/tosijs@1.0.10/dist/module.js' },
162
+ }
163
+ const script = generateImportMapScript(importMap)
164
+
165
+ expect(script).toContain('<script type="importmap">')
166
+ expect(script).toContain('</script>')
167
+ expect(script).toContain('"tosijs"')
168
+ expect(script).toContain('https://unpkg.com/tosijs@1.0.10/dist/module.js')
169
+ })
170
+ })
171
+
172
+ describe('wrapAsModule', () => {
173
+ it('should wrap code in module script tag', () => {
174
+ const code = 'console.log("hello")'
175
+ const wrapped = wrapAsModule(code)
176
+
177
+ expect(wrapped).toContain('<script type="module">')
178
+ expect(wrapped).toContain('</script>')
179
+ expect(wrapped).toContain(code)
180
+ })
181
+ })
182
+
183
+ describe('module cache', () => {
184
+ it('should start empty', () => {
185
+ clearModuleCache()
186
+ const stats = getCacheStats()
187
+ expect(stats.size).toBe(0)
188
+ expect(stats.entries).toEqual([])
189
+ })
190
+
191
+ it('should clear cache', () => {
192
+ // Just verify it doesn't throw
193
+ clearModuleCache()
194
+ expect(getCacheStats().size).toBe(0)
195
+ })
196
+ })
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Playground Import Resolver
3
+ *
4
+ * Resolves and fetches external modules for the TJS playground.
5
+ * Uses unpkg.com CDN for npm packages.
6
+ *
7
+ * Note: unpkg serves files directly from npm, unlike esm.sh which
8
+ * converts CJS to ESM. For packages without proper ESM exports,
9
+ * you need to specify the exact path to the ESM bundle.
10
+ *
11
+ * Features:
12
+ * - Detects import statements in code
13
+ * - Fetches modules from unpkg with pinned versions
14
+ * - Caches fetched modules in memory
15
+ * - Returns import map for browser
16
+ */
17
+
18
+ // CDN base URL - unpkg serves files directly from npm
19
+ const CDN_BASE = 'https://unpkg.com'
20
+
21
+ // In-memory cache for fetched module URLs
22
+ const moduleCache = new Map<string, string>()
23
+
24
+ // Common packages that should use specific versions and ESM paths
25
+ // unpkg serves files directly, so we need explicit paths to ESM bundles
26
+ // Packages with proper "exports" or "module" fields in package.json
27
+ // may work without explicit paths, but it's safer to specify them
28
+ const PINNED_PACKAGES: Record<string, { version: string; path?: string }> = {
29
+ // tosijs ecosystem
30
+ tosijs: { version: '1.0.10', path: '/dist/module.js' },
31
+ 'tosijs-ui': { version: '1.0.10', path: '/dist/index.js' },
32
+
33
+ // Utilities - lodash-es is native ESM
34
+ 'lodash-es': { version: '4.17.21' },
35
+ 'date-fns': { version: '3.6.0' }, // v3+ is native ESM
36
+
37
+ // Validation
38
+ zod: { version: '3.23.8', path: '/lib/index.mjs' },
39
+
40
+ // Markdown
41
+ marked: { version: '9.1.6', path: '/lib/marked.esm.js' },
42
+
43
+ // UI frameworks - these have proper ESM exports
44
+ preact: { version: '10.19.0', path: '/dist/preact.module.js' },
45
+ 'preact/hooks': { version: '10.19.0', path: '/hooks/dist/hooks.module.js' },
46
+ }
47
+
48
+ /**
49
+ * Extract import specifiers from source code
50
+ */
51
+ export function extractImports(source: string): string[] {
52
+ const imports: string[] = []
53
+
54
+ // Match: import ... from 'package'
55
+ // Match: import ... from "package"
56
+ // Match: import 'package'
57
+ // Match: export ... from 'package'
58
+ const importRegex =
59
+ /(?:import|export)\s+(?:[\w\s{},*]+\s+from\s+)?['"]([^'"]+)['"]/g
60
+
61
+ let match
62
+ while ((match = importRegex.exec(source)) !== null) {
63
+ const specifier = match[1]
64
+ // Only resolve bare specifiers (not relative or absolute paths)
65
+ if (!specifier.startsWith('.') && !specifier.startsWith('/')) {
66
+ imports.push(specifier)
67
+ }
68
+ }
69
+
70
+ return [...new Set(imports)] // Dedupe
71
+ }
72
+
73
+ /**
74
+ * Parse a package specifier into name and subpath
75
+ *
76
+ * Examples:
77
+ * 'lodash' -> { name: 'lodash', subpath: '' }
78
+ * 'lodash/debounce' -> { name: 'lodash', subpath: '/debounce' }
79
+ * '@scope/pkg' -> { name: '@scope/pkg', subpath: '' }
80
+ * '@scope/pkg/util' -> { name: '@scope/pkg', subpath: '/util' }
81
+ */
82
+ function parseSpecifier(specifier: string): { name: string; subpath: string } {
83
+ if (specifier.startsWith('@')) {
84
+ // Scoped package: @scope/name or @scope/name/path
85
+ const parts = specifier.split('/')
86
+ const name = `${parts[0]}/${parts[1]}`
87
+ const subpath = parts.slice(2).join('/')
88
+ return { name, subpath: subpath ? `/${subpath}` : '' }
89
+ } else {
90
+ // Regular package: name or name/path
91
+ const slashIndex = specifier.indexOf('/')
92
+ if (slashIndex === -1) {
93
+ return { name: specifier, subpath: '' }
94
+ }
95
+ return {
96
+ name: specifier.slice(0, slashIndex),
97
+ subpath: specifier.slice(slashIndex),
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Get CDN URL for a package specifier
104
+ *
105
+ * unpkg serves files directly from npm packages. For ESM packages,
106
+ * we often need to specify the exact path to the ESM bundle since
107
+ * unpkg doesn't do automatic ESM conversion like esm.sh.
108
+ */
109
+ export function getCDNUrl(specifier: string): string {
110
+ const { name, subpath } = parseSpecifier(specifier)
111
+
112
+ // Check for pinned package config
113
+ const pinned = PINNED_PACKAGES[name]
114
+
115
+ if (pinned) {
116
+ // If subpath provided in specifier, use that; otherwise use pinned path
117
+ const path = subpath || pinned.path || ''
118
+ return `${CDN_BASE}/${name}@${pinned.version}${path}`
119
+ }
120
+
121
+ // For unknown packages, try unpkg's default resolution
122
+ // This may not work for all packages if they don't have proper ESM exports
123
+ return `${CDN_BASE}/${name}${subpath}`
124
+ }
125
+
126
+ /**
127
+ * Generate an import map for the browser
128
+ *
129
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap
130
+ */
131
+ export function generateImportMap(specifiers: string[]): {
132
+ imports: Record<string, string>
133
+ } {
134
+ const imports: Record<string, string> = {}
135
+
136
+ for (const specifier of specifiers) {
137
+ imports[specifier] = getCDNUrl(specifier)
138
+ }
139
+
140
+ return { imports }
141
+ }
142
+
143
+ // Cache for package.json data
144
+ const packageJsonCache = new Map<string, any>()
145
+
146
+ /**
147
+ * Fetch and cache package.json for a package
148
+ */
149
+ async function getPackageJson(name: string, version?: string): Promise<any> {
150
+ const cacheKey = version ? `${name}@${version}` : name
151
+
152
+ if (packageJsonCache.has(cacheKey)) {
153
+ return packageJsonCache.get(cacheKey)
154
+ }
155
+
156
+ const url = `${CDN_BASE}/${cacheKey}/package.json`
157
+ const response = await fetch(url)
158
+
159
+ if (!response.ok) {
160
+ throw new Error(`Package not found: ${cacheKey}`)
161
+ }
162
+
163
+ const pkg = await response.json()
164
+ packageJsonCache.set(cacheKey, pkg)
165
+ return pkg
166
+ }
167
+
168
+ /**
169
+ * Resolve the ESM entry point from package.json
170
+ * Checks exports, module, and main fields in order of preference
171
+ */
172
+ function resolveEntryPoint(pkg: any): string | null {
173
+ // Check exports field first (modern packages)
174
+ if (pkg.exports) {
175
+ // Handle string exports
176
+ if (typeof pkg.exports === 'string') {
177
+ return pkg.exports
178
+ }
179
+
180
+ // Handle exports object - look for ESM entry
181
+ const exp = pkg.exports['.'] ?? pkg.exports
182
+ if (typeof exp === 'string') {
183
+ return exp
184
+ }
185
+ if (exp?.import) {
186
+ return typeof exp.import === 'string' ? exp.import : exp.import?.default
187
+ }
188
+ if (exp?.module) {
189
+ return exp.module
190
+ }
191
+ if (exp?.default) {
192
+ return typeof exp.default === 'string' ? exp.default : null
193
+ }
194
+ }
195
+
196
+ // Check module field (ES modules)
197
+ if (pkg.module) {
198
+ return pkg.module
199
+ }
200
+
201
+ // Check main field (may be CJS, but worth trying)
202
+ if (pkg.main) {
203
+ return pkg.main
204
+ }
205
+
206
+ return null
207
+ }
208
+
209
+ /**
210
+ * Fetch and cache a module, returning its resolved URL
211
+ * Uses package.json to find the correct ESM entry point
212
+ */
213
+ export async function resolveModule(specifier: string): Promise<string> {
214
+ // Check cache first
215
+ if (moduleCache.has(specifier)) {
216
+ return moduleCache.get(specifier)!
217
+ }
218
+
219
+ const { name, subpath } = parseSpecifier(specifier)
220
+ const pinned = PINNED_PACKAGES[name]
221
+ const version = pinned?.version
222
+
223
+ // If there's a subpath in the specifier, use it directly
224
+ if (subpath) {
225
+ const url = `${CDN_BASE}/${name}${version ? `@${version}` : ''}${subpath}`
226
+ moduleCache.set(specifier, url)
227
+ return url
228
+ }
229
+
230
+ // If we have a pinned path, use it directly (skip package.json lookup)
231
+ if (pinned?.path) {
232
+ const url = `${CDN_BASE}/${name}@${version}${pinned.path}`
233
+ moduleCache.set(specifier, url)
234
+ return url
235
+ }
236
+
237
+ // Fetch package.json and resolve the entry point
238
+ const pkg = await getPackageJson(name, version)
239
+ const entryPoint = resolveEntryPoint(pkg)
240
+
241
+ if (!entryPoint) {
242
+ throw new Error(`No ESM entry point found in package.json for ${name}`)
243
+ }
244
+
245
+ // Normalize path (ensure it starts with /)
246
+ const path = entryPoint.startsWith('./')
247
+ ? entryPoint.slice(1)
248
+ : entryPoint.startsWith('/')
249
+ ? entryPoint
250
+ : `/${entryPoint}`
251
+
252
+ const url = `${CDN_BASE}/${name}${version ? `@${version}` : ''}${path}`
253
+ moduleCache.set(specifier, url)
254
+ return url
255
+ }
256
+
257
+ /**
258
+ * Resolve all imports in source code and return import map
259
+ * Checks local module store first, then falls back to CDN
260
+ */
261
+ export async function resolveImports(source: string): Promise<{
262
+ importMap: { imports: Record<string, string> }
263
+ errors: string[]
264
+ localModules: string[]
265
+ }> {
266
+ const specifiers = extractImports(source)
267
+ const errors: string[] = []
268
+ const imports: Record<string, string> = {}
269
+ const localModules: string[] = []
270
+
271
+ // Lazy import to avoid circular deps
272
+ const { resolveLocalImports } = await import('./module-store')
273
+
274
+ // First, resolve local modules
275
+ try {
276
+ const localImports = await resolveLocalImports(specifiers)
277
+ Object.assign(imports, localImports)
278
+ localModules.push(...Object.keys(localImports))
279
+ } catch (error: any) {
280
+ errors.push(error.message)
281
+ }
282
+
283
+ // Then resolve remaining from CDN
284
+ const remaining = specifiers.filter((s) => !localModules.includes(s))
285
+
286
+ await Promise.all(
287
+ remaining.map(async (specifier) => {
288
+ try {
289
+ imports[specifier] = await resolveModule(specifier)
290
+ } catch (error: any) {
291
+ errors.push(error.message)
292
+ }
293
+ })
294
+ )
295
+
296
+ return { importMap: { imports }, errors, localModules }
297
+ }
298
+
299
+ /**
300
+ * Generate HTML script tag with import map
301
+ */
302
+ export function generateImportMapScript(importMap: {
303
+ imports: Record<string, string>
304
+ }): string {
305
+ return `<script type="importmap">
306
+ ${JSON.stringify(importMap, null, 2)}
307
+ </script>`
308
+ }
309
+
310
+ /**
311
+ * Transform source code to use module script
312
+ */
313
+ export function wrapAsModule(code: string): string {
314
+ return `<script type="module">
315
+ ${code}
316
+ </script>`
317
+ }
318
+
319
+ /**
320
+ * Clear the in-memory module cache
321
+ */
322
+ export function clearModuleCache(): void {
323
+ moduleCache.clear()
324
+ }
325
+
326
+ /**
327
+ * Get in-memory cache statistics
328
+ */
329
+ export function getCacheStats(): { size: number; entries: string[] } {
330
+ return {
331
+ size: moduleCache.size,
332
+ entries: [...moduleCache.keys()],
333
+ }
334
+ }
335
+
336
+ // Service Worker integration for persistent caching
337
+
338
+ let swRegistration: ServiceWorkerRegistration | null = null
339
+
340
+ /**
341
+ * Register the module cache service worker
342
+ */
343
+ export async function registerServiceWorker(): Promise<boolean> {
344
+ if (!('serviceWorker' in navigator)) {
345
+ console.warn('Service workers not supported')
346
+ return false
347
+ }
348
+
349
+ try {
350
+ swRegistration = await navigator.serviceWorker.register('/module-sw.js', {
351
+ scope: '/',
352
+ })
353
+ console.log('Module cache SW registered')
354
+ return true
355
+ } catch (error) {
356
+ console.error('SW registration failed:', error)
357
+ return false
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Send message to service worker and wait for response
363
+ */
364
+ function sendSWMessage<T>(type: string, payload?: any): Promise<T> {
365
+ return new Promise((resolve, reject) => {
366
+ if (!navigator.serviceWorker.controller) {
367
+ reject(new Error('No active service worker'))
368
+ return
369
+ }
370
+
371
+ const channel = new MessageChannel()
372
+ channel.port1.onmessage = (event) => resolve(event.data)
373
+
374
+ navigator.serviceWorker.controller.postMessage({ type, payload }, [
375
+ channel.port2,
376
+ ])
377
+
378
+ // Timeout after 5 seconds
379
+ setTimeout(() => reject(new Error('SW message timeout')), 5000)
380
+ })
381
+ }
382
+
383
+ /**
384
+ * Clear the service worker cache
385
+ */
386
+ export async function clearSWCache(): Promise<boolean> {
387
+ try {
388
+ const result = await sendSWMessage<{ success: boolean }>('CLEAR_CACHE')
389
+ return result.success
390
+ } catch {
391
+ return false
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Get service worker cache statistics
397
+ */
398
+ export async function getSWCacheStats(): Promise<{
399
+ size: number
400
+ entries: string[]
401
+ } | null> {
402
+ try {
403
+ return await sendSWMessage('GET_CACHE_STATS')
404
+ } catch {
405
+ return null
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Prefetch modules into service worker cache
411
+ */
412
+ export async function prefetchModules(
413
+ specifiers: string[]
414
+ ): Promise<{ success: number; failed: number }> {
415
+ const urls = specifiers.map(getCDNUrl)
416
+ try {
417
+ return await sendSWMessage('PREFETCH', { urls })
418
+ } catch {
419
+ return { success: 0, failed: specifiers.length }
420
+ }
421
+ }