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
63 changes: 56 additions & 7 deletions packages/server/src/action-seed.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,28 +288,75 @@ function seedProxy(file, fnName, orig) {
export function extractExportNames(src) {
const fnNames = new Set();
const valNames = new Set();

// Find local function declarations & function variable assignments
const localFns = new Set();
let m;
const reLocalFn = /\b(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
while ((m = reLocalFn.exec(src))) localFns.add(m[1]);

const reLocalFnVar = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|[\w$]+\s*=>)/g;
while ((m = reLocalFnVar.exec(src))) localFns.add(m[1]);

// Find direct function exports
const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
while ((m = reFn.exec(src))) fnNames.add(m[1]);

const reFnVar = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|[\w$]+\s*=>)/g;
while ((m = reFnVar.exec(src))) fnNames.add(m[1]);

// Find local non-function variables and classes
const localVals = new Set();
const reLocalClass = /\b(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g;
while ((m = reLocalClass.exec(src))) localVals.add(m[1]);

const reLocalVarStmt = /\b(?:const|let|var)\s+([^;\n]+)/g;
while ((m = reLocalVarStmt.exec(src))) {
const stmt = m[1];
const idRe = /\b([A-Za-z_$][\w$]*)\s*(?:=|,|;|$)/g;
let idM;
while ((idM = idRe.exec(stmt))) {
const name = idM[1];
if (name !== 'const' && name !== 'let' && name !== 'var' && name !== 'async' && name !== 'function') {
if (!localFns.has(name)) localVals.add(name);
}
}
}

// Direct class exports
const reClass = /\bexport\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g;
while ((m = reClass.exec(src))) valNames.add(m[1]);

// Direct variable exports (not function assignments)
const reVar = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g;
while ((m = reVar.exec(src))) {
if (!fnNames.has(m[1])) valNames.add(m[1]);
}

// Export list: export { a, b as bee }
const reList = /\bexport\s*\{([^}]*)\}/g;
while ((m = reList.exec(src))) {
for (const part of m[1].split(',')) {
const seg = part.trim();
if (!seg) continue;
const as = seg.split(/\s+as\s+/);
const local = as[0].trim();
const exported = (as[1] || as[0]).trim();
if (/^[A-Za-z_$][\w$]*$/.test(exported) && exported !== 'default') fnNames.add(exported);
else if (exported === 'default') fnNames.add('__default__');
if (!/^[A-Za-z_$][\w$]*$/.test(exported)) continue;

if (exported === 'default' || local === 'default') {
fnNames.add('__default__');
} else if (localVals.has(local) && !localFns.has(local)) {
valNames.add(exported);
} else {
fnNames.add(exported);
}
}
}
const reVar = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g;
while ((m = reVar.exec(src))) {
if (!fnNames.has(m[1])) valNames.add(m[1]);
}

// Clean up any overlap if an item ended up in both (fnNames wins if it's a function)
for (const fn of fnNames) valNames.delete(fn);

const hasDefault = /\bexport\s+default\b/.test(src) || fnNames.delete('__default__') || valNames.delete('__default__');
return { fnNames: [...fnNames], valNames: [...valNames], names: [...fnNames, ...valNames], hasDefault };
}
Expand All @@ -332,8 +379,10 @@ function buildFacade(origUrl, absPath, exports) {
out += `export * from ${origSpec};\n`;
for (const n of exports.fnNames) {
const k = JSON.stringify(n);
const v = `_fn_${n}`;
out += `let ${v};\n`;
out += `export function ${n}(...args) {\n`;
out += ` const fn = __w(${file}, ${k}, __orig[${k}]);\n`;
out += ` const fn = ${v} || (${v} = __w(${file}, ${k}, __orig[${k}]));\n`;
out += ` return typeof fn === 'function' ? fn.apply(this, args) : fn;\n`;
out += `}\n`;
out += `__w(${file}, ${k}, ${n});\n`;
Expand Down
12 changes: 11 additions & 1 deletion packages/server/test/seed/action-seed-unit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ test('extractExportNames finds function / const / class / list / default exports
export { a, b as bee };
export default function () {}
`;
const { names, hasDefault } = extractExportNames(src);
const { fnNames, valNames, names, hasDefault } = extractExportNames(src);
assert.ok(names.includes('getUser'));
assert.ok(names.includes('getPosts'));
assert.ok(names.includes('VERSION'));
Expand All @@ -56,6 +56,16 @@ test('extractExportNames finds function / const / class / list / default exports
assert.ok(names.includes('bee'), 'the EXPORTED name of `b as bee` is `bee`');
assert.ok(!names.includes('b'), 'the local name is not the exported binding');
assert.equal(hasDefault, true);

assert.deepEqual(fnNames.sort(), ['getUser', 'getPosts'].sort());
assert.deepEqual(valNames.sort(), ['VERSION', 'counter', 'Thing', 'a', 'bee'].sort());
});

test('buildSeedFacade memoizes action wrap lookups and generates hoisted function facades', () => {
const src = `'use server';\nexport async function submitData(d) { return d; }\n`;
const facade = buildSeedFacade('file:///app/s.server.js', '/app/s.server.js', src);
assert.match(facade, /let _fn_submitData;/);
assert.match(facade, /const fn = _fn_submitData \|\| \(_fn_submitData = __w\(/);
});

test('a star re-export is FACETED, not passed through (#1155)', () => {
Expand Down
1 change: 0 additions & 1 deletion test/bun/action-seed-circular.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';

import { installBunSeedPlugin } from '../../packages/server/src/action-seed-bun.js';
import { seedingEnabled, registerActionHooks } from '../../packages/server/src/action-seed.js';

test('circular re-export between use-server modules loads on Bun / Node (#1208)', async () => {
Expand Down
Loading