@hyperspan/plugin-vue 1.0.1 → 1.0.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": "@hyperspan/plugin-vue",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Hyperspan Plugin for Vue.js Client Islands",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,4 @@
1
+ /** Bun-safe entry: same runtime API as `@vue/compiler-sfc`, typings from the package root. */
2
+ declare module '@vue/compiler-sfc/dist/compiler-sfc.esm-browser.js' {
3
+ export * from '@vue/compiler-sfc';
4
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import './types.d';
1
2
  import { JS_IMPORT_MAP, JS_ISLAND_PUBLIC_PATH } from '@hyperspan/framework/client/js';
2
3
  import { assetHash } from '@hyperspan/framework/utils';
3
4
  import { IS_PROD } from '@hyperspan/framework/server';
@@ -5,11 +6,39 @@ import { join, resolve } from 'node:path';
5
6
  import type { Hyperspan as HS } from '@hyperspan/framework';
6
7
  import { html } from '@hyperspan/html';
7
8
  import debug from 'debug';
8
- import { parse, compileScript, compileTemplate, rewriteDefault } from '@vue/compiler-sfc';
9
- import './types.d';
9
+ // Use the ESM browser build: the Node CJS build inlines consolidate and static `require()`
10
+ // calls for optional engines (velocityjs, pug, etc.). Bun resolves those at bundle time and
11
+ // errors unless every optional package is installed. The browser bundle has the same SFC API
12
+ // without those preprocessors — fine for compile-from-source string workflows.
13
+ import { parse, compileScript, compileTemplate, rewriteDefault } from '@vue/compiler-sfc/dist/compiler-sfc.esm-browser.js';
10
14
 
11
15
  const log = debug('hyperspan:plugin-vue');
12
16
 
17
+ /** Dev: stable `[name].js` via Bun default. Prod: hashed filenames for caching. */
18
+ const ISLAND_JS_NAMING = IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined;
19
+
20
+ function islandBundleBaseName(outputPath: string): string {
21
+ return String(outputPath.split('/').reverse()[0]!.replace(/\.js$/i, ''));
22
+ }
23
+
24
+ /** Prefer the real entry artifact — `outputs[0]` is often a shared chunk, not the .vue entry. */
25
+ function pickEntryPointJsOutput(
26
+ outputs: ReadonlyArray<{ path: string; kind?: string }>,
27
+ entrySourcePath: string
28
+ ): { path: string } {
29
+ const js = outputs.filter((o) => o.path.endsWith('.js'));
30
+ const entry = js.find((o) => o.kind === 'entry-point');
31
+ if (entry) return entry;
32
+ const sourceBase = entrySourcePath.split('/').pop()!.replace(/\.(vue|ts)$/i, '');
33
+ const byName = js.find((o) => {
34
+ const b = islandBundleBaseName(o.path);
35
+ return b === sourceBase || b.startsWith(`${sourceBase}-`);
36
+ });
37
+ if (byName) return byName;
38
+ if (js[0]) return js[0];
39
+ throw new Error('[Hyperspan] Vue island client build produced no JS output');
40
+ }
41
+
13
42
  /**
14
43
  * Build the island wrapper HTML: a div for SSR content + a module script tag for client hydration.
15
44
  * Exported so it can be imported by generated island module code and used directly in tests.
@@ -40,7 +69,9 @@ export async function renderVueSSR(Component: any, props: any = {}): Promise<str
40
69
  return renderToString(app);
41
70
  }
42
71
 
43
- const VUE_ISLAND_CACHE = new Map<string, string>();
72
+ type VueIslandCacheEntry = { contents: string; esmName: string };
73
+
74
+ const VUE_ISLAND_CACHE = new Map<string, VueIslandCacheEntry>();
44
75
 
45
76
  /**
46
77
  * Compile a Vue SFC to JavaScript using @vue/compiler-sfc.
@@ -104,16 +135,6 @@ async function compileVueSFC(
104
135
  */
105
136
  async function copyVueToPublicFolder(config: HS.Config) {
106
137
  const outdir = join('./', config.publicDir, JS_ISLAND_PUBLIC_PATH);
107
- const devOutputFile = join(outdir, 'vue-client.js');
108
-
109
- // In dev mode, skip the build if the file already exists to avoid
110
- // conflicts with bun --watch (building vue triggers watch restarts)
111
- if (!IS_PROD && (await Bun.file(devOutputFile).exists())) {
112
- const builtFilePath = `${JS_ISLAND_PUBLIC_PATH}/vue-client.js`;
113
- JS_IMPORT_MAP.set('vue', builtFilePath);
114
- JS_IMPORT_MAP.set('vue/dist/vue.esm-bundler.js', builtFilePath);
115
- return;
116
- }
117
138
 
118
139
  const currentNodeEnv = process.env.NODE_ENV || 'production';
119
140
  const sourceFile = resolve(__dirname, './vue-client.ts');
@@ -123,7 +144,7 @@ async function copyVueToPublicFolder(config: HS.Config) {
123
144
  const result = await Bun.build({
124
145
  entrypoints: [sourceFile],
125
146
  outdir,
126
- naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
147
+ naming: ISLAND_JS_NAMING,
127
148
  minify: true,
128
149
  format: 'esm',
129
150
  target: 'browser',
@@ -135,7 +156,8 @@ async function copyVueToPublicFolder(config: HS.Config) {
135
156
  });
136
157
  process.env.NODE_ENV = currentNodeEnv;
137
158
 
138
- const builtFileName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
159
+ const vueClientEntry = pickEntryPointJsOutput(result.outputs, sourceFile);
160
+ const builtFileName = islandBundleBaseName(vueClientEntry.path);
139
161
  const builtFilePath = `${JS_ISLAND_PUBLIC_PATH}/${builtFileName}.js`;
140
162
 
141
163
  JS_IMPORT_MAP.set('vue', builtFilePath);
@@ -163,11 +185,18 @@ export function vuePlugin(): HS.Plugin {
163
185
  log('vue file loaded', args.path);
164
186
  const jsId = assetHash(args.path);
165
187
 
188
+ if (!JS_IMPORT_MAP.has('vue')) {
189
+ await copyVueToPublicFolder(config);
190
+ }
191
+
166
192
  // Cache: Avoid re-processing the same file
167
193
  if (VUE_ISLAND_CACHE.has(jsId)) {
194
+ const hit = VUE_ISLAND_CACHE.get(jsId)!;
195
+ // Re-register: JS_IMPORT_MAP may have been reset (e.g. module reload) while this cache survives.
196
+ JS_IMPORT_MAP.set(hit.esmName, `${JS_ISLAND_PUBLIC_PATH}/${hit.esmName}.js`);
168
197
  log('vue file cached', args.path);
169
198
  return {
170
- contents: VUE_ISLAND_CACHE.get(jsId) || '',
199
+ contents: hit.contents,
171
200
  loader: 'js',
172
201
  };
173
202
  }
@@ -180,55 +209,41 @@ export function vuePlugin(): HS.Plugin {
180
209
  const ssrCode = await compileVueSFC(source, args.path, jsId, true);
181
210
 
182
211
  const outdir = join('./', config.publicDir, JS_ISLAND_PUBLIC_PATH);
183
- const baseName = args.path.split('/').pop()!.replace('.vue', '');
184
-
185
- let esmName: string;
186
- if (!IS_PROD) {
187
- // In dev mode, write compiled JS directly — avoids nested Bun.build() inside
188
- // Bun.plugin() onLoad which causes EISDIR conflicts under bun --watch.
189
- // The browser import map resolves 'vue' to vue-client.js.
190
- const clientCode = await compileVueSFC(source, args.path, jsId, false);
191
- await Bun.write(join(outdir, `${baseName}.js`), clientCode);
192
- esmName = baseName;
193
- } else {
194
- // Production: full bundle with tree-shaking and minification
195
- const vueClientPlugin = {
196
- name: 'vue-client-compiler',
197
- setup(clientBuild: any) {
198
- clientBuild.onLoad(
199
- { filter: /\.vue$/ },
200
- async ({ path }: { path: string }) => {
201
- const src = await Bun.file(path).text();
202
- const clientId = assetHash(path);
203
- const compiledCode = await compileVueSFC(src, path, clientId, false);
204
- return { contents: compiledCode, loader: 'js' };
205
- }
206
- );
207
- },
208
- };
209
212
 
210
- const clientResult = await Bun.build({
211
- entrypoints: [args.path],
212
- outdir,
213
- naming: '[dir]/[name]-[hash].[ext]',
214
- external: Array.from(JS_IMPORT_MAP.keys()),
215
- minify: true,
216
- format: 'esm',
217
- target: 'browser',
218
- plugins: [vueClientPlugin],
219
- env: 'APP_PUBLIC_*',
220
- define: {
221
- __VUE_OPTIONS_API__: 'true',
222
- __VUE_PROD_DEVTOOLS__: 'false',
223
- __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
224
- },
225
- });
226
-
227
- esmName = String(clientResult.outputs[0].path.split('/').reverse()[0]).replace(
228
- '.js',
229
- ''
230
- );
231
- }
213
+ const vueClientPlugin = {
214
+ name: 'vue-client-compiler',
215
+ setup(clientBuild: any) {
216
+ clientBuild.onLoad(
217
+ { filter: /\.vue$/ },
218
+ async ({ path }: { path: string }) => {
219
+ const src = await Bun.file(path).text();
220
+ const clientId = assetHash(path);
221
+ const compiledCode = await compileVueSFC(src, path, clientId, false);
222
+ return { contents: compiledCode, loader: 'js' };
223
+ }
224
+ );
225
+ },
226
+ };
227
+
228
+ const clientResult = await Bun.build({
229
+ entrypoints: [args.path],
230
+ outdir,
231
+ naming: ISLAND_JS_NAMING,
232
+ external: Array.from(JS_IMPORT_MAP.keys()),
233
+ minify: true,
234
+ format: 'esm',
235
+ target: 'browser',
236
+ plugins: [vueClientPlugin],
237
+ env: 'APP_PUBLIC_*',
238
+ define: {
239
+ __VUE_OPTIONS_API__: 'true',
240
+ __VUE_PROD_DEVTOOLS__: 'false',
241
+ __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
242
+ },
243
+ });
244
+
245
+ const entryOut = pickEntryPointJsOutput(clientResult.outputs, args.path);
246
+ const esmName = islandBundleBaseName(entryOut.path);
232
247
 
233
248
  JS_IMPORT_MAP.set(esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
234
249
  log('added to import map', esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
@@ -280,7 +295,7 @@ ${componentName}.__HS_ISLAND = {
280
295
  };
281
296
  `;
282
297
 
283
- VUE_ISLAND_CACHE.set(jsId, moduleCode);
298
+ VUE_ISLAND_CACHE.set(jsId, { contents: moduleCode, esmName });
284
299
 
285
300
  return {
286
301
  contents: moduleCode,
package/tsconfig.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "compileOnSave": true,
3
+ "include": ["src/**/*.ts", "src/**/*.d.ts"],
3
4
  "compilerOptions": {
4
5
  "rootDir": "src",
5
6
  "outDir": "dist",