Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: framework agnostic server-action #300

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
13 changes: 13 additions & 0 deletions packages/server-action/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# server-action

## todo

- extract from https://github.com/hi-ogawa/vite-environment-examples/blob/main/examples/vue-ssr-extra/src/features/server-action/server.ts
- action side effects
- redirect
- revalidate
- progressive enhancements
- react / vue agnostics?
- server action handler
- context
- custom serialization
37 changes: 37 additions & 0 deletions packages/server-action/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@hiogawa/server-action",
"version": "0.0.0-pre.0",
"homepage": "https://github.com/hi-ogawa/vite-plugins/tree/main/packages/server-action",
"repository": {
"type": "git",
"url": "https://github.com/hi-ogawa/vite-plugins",
"directory": "packages/server-action"
},
"license": "MIT",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"dev": "tsup --watch",
"build": "tsup --clean",
"release": "pnpm publish --no-git-checks --access public"
},
"dependencies": {
"fast-glob": "^3.3.2",
"magic-string": "^0.30.8"
},
"peerDependencies": {
"vite": "*"
}
}
2 changes: 2 additions & 0 deletions packages/server-action/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// call server
//
Empty file.
209 changes: 209 additions & 0 deletions packages/server-action/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import FastGlob from "fast-glob";
import MagicString from "magic-string";
import fs from "node:fs";
import { parseAstAsync, type Plugin, type PluginOption, type ResolvedConfig } from "vite";

const USE_SERVER_RE = /^("use server"|'use server')/;

export function vitePluginServerAction({
clientRuntime,
serverRuntime,
entries,
}: {
clientRuntime: string;
serverRuntime: string;
entries: string[],
}): PluginOption {
let config: ResolvedConfig;

const basePlugin: Plugin = {
name: vitePluginServerAction.name + ":base",
configResolved(config_) {
config = config_;
},
}

const transformPlugin: Plugin = {
name: vitePluginServerAction.name + ":transform",
async transform(code, id, options) {
if (USE_SERVER_RE.test(code)) {
const { writable, exportNames } = await parseExports(code)
if (!options?.ssr) {
const outCode = [
`import { createServerReference as $$create } from "${clientRuntime}";`,
...[...exportNames].map(
(name) => `export const ${name} = $$create("${id}", "${name}");`,
),
].join("\n");
return { code: outCode, map: null };
} else {
const footer = [
code,
`import { registerServerReference as $$register } from "${serverRuntime}";`,
...[...exportNames].map(
(name) => `${name} = $$register(${name}, "${id}", "${name}");`,
),
].join("\n");
writable.append(footer);
return { code: writable.toString(), map: writable.generateMap() };
}
}
return;
},
};

const virtualServerReference = createVirtualPlugin(
"server-action/references",
async function () {
const files = await FastGlob(entries, {
cwd: config.root,
absolute: true,
})
const ids: string[] = [];
for (const file of files) {
const code = await fs.promises.readFile(file, "utf-8");
if (USE_SERVER_RE.test(code)) {
ids.push(file);
}
}
return [
"export default {",
...ids.map((id) => `"${id}": () => import("${id}"),\n`),
"}",
].join("\n");
},
);

return [
basePlugin,
transformPlugin,
virtualServerReference,
vitePluginSilenceDirectiveBuildWarning(),
];
}

function createVirtualPlugin(name: string, load: Plugin["load"]) {
name = "virtual:" + name;
return {
name,
resolveId(source, _importer, _options) {
return source === name ? "\0" + name : undefined;
},
load(id, options) {
if (id === "\0" + name) {
return (load as any).apply(this, [id, options]);
}
},
} satisfies Plugin;
}

function vitePluginSilenceDirectiveBuildWarning(): Plugin {
return {
name: vitePluginSilenceDirectiveBuildWarning.name,
apply: "build",
enforce: "post",
config: (config, _env) => ({
build: {
rollupOptions: {
onwarn(warning, defaultHandler) {
if (
warning.code === "SOURCEMAP_ERROR" &&
warning.message.includes("(1:0)")
) {
return;
}
if (
warning.code === "MODULE_LEVEL_DIRECTIVE" &&
(warning.message.includes(`"use client"`) ||
warning.message.includes(`"use server"`))
) {
return;
}
if (config.build?.rollupOptions?.onwarn) {
config.build.rollupOptions.onwarn(warning, defaultHandler);
} else {
defaultHandler(warning);
}
},
},
},
}),
};
}

async function parseExports(code: string) {
const ast = await parseAstAsync(code);
const writable = new MagicString(code); // replace "const" with "let"
const exportNames = new Set<string>();

for (const node of ast.body) {
// named exports
if (node.type === "ExportNamedDeclaration") {
if (node.declaration) {
if (
node.declaration.type === "FunctionDeclaration" ||
node.declaration.type === "ClassDeclaration"
) {
/**
* export function foo() {}
*/
exportNames.add(node.declaration.id.name);
} else if (node.declaration.type === "VariableDeclaration") {
/**
* export const foo = 1, bar = 2
*/
// replace "const" to "let"
if (node.declaration.kind === "const") {
const { start } = node.declaration as any;
writable.remove(start, start + 5);
writable.appendLeft(start, "let");
}
for (const decl of node.declaration.declarations) {
if (decl.id.type === "Identifier") {
exportNames.add(decl.id.name);
} else {
console.error(parseExports.name, "unsupported code", decl);
}
}
}
} else {
/**
* export { foo, bar } from './foo'
* export { foo, bar as car }
*/
for (const spec of node.specifiers) {
exportNames.add(spec.exported.name);
}
}
}

// default export
if (node.type === "ExportDefaultDeclaration") {
if (
(node.declaration.type === "FunctionDeclaration" ||
node.declaration.type === "ClassExpression") &&
node.declaration.id
) {
/**
* export default function foo() {}
* export default class A {}
*/
exportNames.add("default");
} else {
/**
* export default () => {}
*/
exportNames.add("default");
}
}

/**
* export * from './foo'
*/
if (node.type === "ExportAllDeclaration") {
console.error(parseExports.name, "unsupported code", node);
}
}

return { exportNames, writable };
}
25 changes: 25 additions & 0 deletions packages/server-action/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { tinyassert } from "@hiogawa/utils"

export function serverActionHandler({ request }: { request: Request; }) {
request;
}

export async function decodeActionRequest(request: Request) {
request;
}

export function importServerAction(id: string, name: string) {
import(/* @vite-ignore */ id);
import("virtual:server-action/references" as string);
}

export async function importServerReference(id: string) {
if (import.meta.env.DEV) {
return import(/* @vite-ignore */ id);
} else {
const mod = await import("virtual:server-action/references" as string);
const dyn = mod.default[id];
tinyassert(dyn);
return dyn();
}
}
7 changes: 7 additions & 0 deletions packages/server-action/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "tsup.config.ts"],
"compilerOptions": {
"types": ["vite/client"]
}
}
7 changes: 7 additions & 0 deletions packages/server-action/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from "tsup";

export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
});
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading