@polardbx/pxbaas 0.0.0-stage → 0.1.0-beta.1
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/bin/pxbaas.js +13 -0
- package/dist/app/preflight.d.ts +38 -0
- package/dist/app/preflight.d.ts.map +1 -0
- package/dist/app/preflight.js +79 -0
- package/dist/app/preflight.js.map +1 -0
- package/dist/app/spec-lint.d.ts +45 -0
- package/dist/app/spec-lint.d.ts.map +1 -0
- package/dist/app/spec-lint.js +407 -0
- package/dist/app/spec-lint.js.map +1 -0
- package/dist/app/templates.d.ts +143 -0
- package/dist/app/templates.d.ts.map +1 -0
- package/dist/app/templates.js +151 -0
- package/dist/app/templates.js.map +1 -0
- package/dist/cli/root.d.ts +3 -0
- package/dist/cli/root.d.ts.map +1 -0
- package/dist/cli/root.js +69 -0
- package/dist/cli/root.js.map +1 -0
- package/dist/commands/app/index.d.ts +8 -0
- package/dist/commands/app/index.d.ts.map +1 -0
- package/dist/commands/app/index.js +1267 -0
- package/dist/commands/app/index.js.map +1 -0
- package/dist/commands/auth/index.d.ts +3 -0
- package/dist/commands/auth/index.d.ts.map +1 -0
- package/dist/commands/auth/index.js +198 -0
- package/dist/commands/auth/index.js.map +1 -0
- package/dist/commands/completion/index.d.ts +3 -0
- package/dist/commands/completion/index.d.ts.map +1 -0
- package/dist/commands/completion/index.js +10 -0
- package/dist/commands/completion/index.js.map +1 -0
- package/dist/commands/data/index.d.ts +3 -0
- package/dist/commands/data/index.d.ts.map +1 -0
- package/dist/commands/data/index.js +245 -0
- package/dist/commands/data/index.js.map +1 -0
- package/dist/commands/db/index.d.ts +14 -0
- package/dist/commands/db/index.d.ts.map +1 -0
- package/dist/commands/db/index.js +355 -0
- package/dist/commands/db/index.js.map +1 -0
- package/dist/commands/doctor/index.d.ts +3 -0
- package/dist/commands/doctor/index.d.ts.map +1 -0
- package/dist/commands/doctor/index.js +305 -0
- package/dist/commands/doctor/index.js.map +1 -0
- package/dist/commands/functions/delete.d.ts +3 -0
- package/dist/commands/functions/delete.d.ts.map +1 -0
- package/dist/commands/functions/delete.js +55 -0
- package/dist/commands/functions/delete.js.map +1 -0
- package/dist/commands/functions/deploy.d.ts +3 -0
- package/dist/commands/functions/deploy.d.ts.map +1 -0
- package/dist/commands/functions/deploy.js +118 -0
- package/dist/commands/functions/deploy.js.map +1 -0
- package/dist/commands/functions/index.d.ts +3 -0
- package/dist/commands/functions/index.d.ts.map +1 -0
- package/dist/commands/functions/index.js +15 -0
- package/dist/commands/functions/index.js.map +1 -0
- package/dist/commands/functions/list.d.ts +6 -0
- package/dist/commands/functions/list.d.ts.map +1 -0
- package/dist/commands/functions/list.js +63 -0
- package/dist/commands/functions/list.js.map +1 -0
- package/dist/commands/functions/new.d.ts +3 -0
- package/dist/commands/functions/new.d.ts.map +1 -0
- package/dist/commands/functions/new.js +57 -0
- package/dist/commands/functions/new.js.map +1 -0
- package/dist/commands/gen/index.d.ts +3 -0
- package/dist/commands/gen/index.d.ts.map +1 -0
- package/dist/commands/gen/index.js +320 -0
- package/dist/commands/gen/index.js.map +1 -0
- package/dist/commands/instance/index.d.ts +8 -0
- package/dist/commands/instance/index.d.ts.map +1 -0
- package/dist/commands/instance/index.js +361 -0
- package/dist/commands/instance/index.js.map +1 -0
- package/dist/commands/login/index.d.ts +3 -0
- package/dist/commands/login/index.d.ts.map +1 -0
- package/dist/commands/login/index.js +185 -0
- package/dist/commands/login/index.js.map +1 -0
- package/dist/commands/login/login.d.ts +3 -0
- package/dist/commands/login/login.d.ts.map +1 -0
- package/dist/commands/login/login.js +185 -0
- package/dist/commands/login/login.js.map +1 -0
- package/dist/commands/logout/index.d.ts +3 -0
- package/dist/commands/logout/index.d.ts.map +1 -0
- package/dist/commands/logout/index.js +41 -0
- package/dist/commands/logout/index.js.map +1 -0
- package/dist/commands/profile/import-env.d.ts +13 -0
- package/dist/commands/profile/import-env.d.ts.map +1 -0
- package/dist/commands/profile/import-env.js +81 -0
- package/dist/commands/profile/import-env.js.map +1 -0
- package/dist/commands/profile/index.d.ts +3 -0
- package/dist/commands/profile/index.d.ts.map +1 -0
- package/dist/commands/profile/index.js +109 -0
- package/dist/commands/profile/index.js.map +1 -0
- package/dist/commands/rest/index.d.ts +3 -0
- package/dist/commands/rest/index.d.ts.map +1 -0
- package/dist/commands/rest/index.js +102 -0
- package/dist/commands/rest/index.js.map +1 -0
- package/dist/commands/skill/index.d.ts +11 -0
- package/dist/commands/skill/index.d.ts.map +1 -0
- package/dist/commands/skill/index.js +153 -0
- package/dist/commands/skill/index.js.map +1 -0
- package/dist/commands/status/index.d.ts +3 -0
- package/dist/commands/status/index.d.ts.map +1 -0
- package/dist/commands/status/index.js +59 -0
- package/dist/commands/status/index.js.map +1 -0
- package/dist/commands/storage/index.d.ts +3 -0
- package/dist/commands/storage/index.d.ts.map +1 -0
- package/dist/commands/storage/index.js +198 -0
- package/dist/commands/storage/index.js.map +1 -0
- package/dist/commands/whoami/index.d.ts +3 -0
- package/dist/commands/whoami/index.d.ts.map +1 -0
- package/dist/commands/whoami/index.js +82 -0
- package/dist/commands/whoami/index.js.map +1 -0
- package/dist/credentials/commit.d.ts +38 -0
- package/dist/credentials/commit.d.ts.map +1 -0
- package/dist/credentials/commit.js +57 -0
- package/dist/credentials/commit.js.map +1 -0
- package/dist/credentials/credential-store.d.ts +57 -0
- package/dist/credentials/credential-store.d.ts.map +1 -0
- package/dist/credentials/credential-store.js +180 -0
- package/dist/credentials/credential-store.js.map +1 -0
- package/dist/credentials/env-file.d.ts +44 -0
- package/dist/credentials/env-file.d.ts.map +1 -0
- package/dist/credentials/env-file.js +118 -0
- package/dist/credentials/env-file.js.map +1 -0
- package/dist/credentials/formatter.d.ts +39 -0
- package/dist/credentials/formatter.d.ts.map +1 -0
- package/dist/credentials/formatter.js +79 -0
- package/dist/credentials/formatter.js.map +1 -0
- package/dist/credentials/index.d.ts +11 -0
- package/dist/credentials/index.d.ts.map +1 -0
- package/dist/credentials/index.js +11 -0
- package/dist/credentials/index.js.map +1 -0
- package/dist/credentials/jwt-introspect.d.ts +32 -0
- package/dist/credentials/jwt-introspect.d.ts.map +1 -0
- package/dist/credentials/jwt-introspect.js +64 -0
- package/dist/credentials/jwt-introspect.js.map +1 -0
- package/dist/credentials/masker.d.ts +20 -0
- package/dist/credentials/masker.d.ts.map +1 -0
- package/dist/credentials/masker.js +37 -0
- package/dist/credentials/masker.js.map +1 -0
- package/dist/credentials/profile.d.ts +83 -0
- package/dist/credentials/profile.d.ts.map +1 -0
- package/dist/credentials/profile.js +184 -0
- package/dist/credentials/profile.js.map +1 -0
- package/dist/credentials/tenant-guard.d.ts +16 -0
- package/dist/credentials/tenant-guard.d.ts.map +1 -0
- package/dist/credentials/tenant-guard.js +53 -0
- package/dist/credentials/tenant-guard.js.map +1 -0
- package/dist/credentials/tty-guard.d.ts +19 -0
- package/dist/credentials/tty-guard.d.ts.map +1 -0
- package/dist/credentials/tty-guard.js +47 -0
- package/dist/credentials/tty-guard.js.map +1 -0
- package/dist/db/apply-schema.d.ts +57 -0
- package/dist/db/apply-schema.d.ts.map +1 -0
- package/dist/db/apply-schema.js +192 -0
- package/dist/db/apply-schema.js.map +1 -0
- package/dist/db/sql-file.d.ts +49 -0
- package/dist/db/sql-file.d.ts.map +1 -0
- package/dist/db/sql-file.js +167 -0
- package/dist/db/sql-file.js.map +1 -0
- package/dist/instance/provision.d.ts +89 -0
- package/dist/instance/provision.d.ts.map +1 -0
- package/dist/instance/provision.js +139 -0
- package/dist/instance/provision.js.map +1 -0
- package/dist/instance/zero-store.d.ts +47 -0
- package/dist/instance/zero-store.d.ts.map +1 -0
- package/dist/instance/zero-store.js +91 -0
- package/dist/instance/zero-store.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +46 -0
- package/dist/main.js.map +1 -0
- package/dist/skill/assets.d.ts +38 -0
- package/dist/skill/assets.d.ts.map +1 -0
- package/dist/skill/assets.js +111 -0
- package/dist/skill/assets.js.map +1 -0
- package/dist/skill/hint.d.ts +39 -0
- package/dist/skill/hint.d.ts.map +1 -0
- package/dist/skill/hint.js +109 -0
- package/dist/skill/hint.js.map +1 -0
- package/dist/skill/hosts.d.ts +21 -0
- package/dist/skill/hosts.d.ts.map +1 -0
- package/dist/skill/hosts.js +38 -0
- package/dist/skill/hosts.js.map +1 -0
- package/dist/skill/installer.d.ts +88 -0
- package/dist/skill/installer.d.ts.map +1 -0
- package/dist/skill/installer.js +203 -0
- package/dist/skill/installer.js.map +1 -0
- package/dist/skill/ownership.d.ts +35 -0
- package/dist/skill/ownership.d.ts.map +1 -0
- package/dist/skill/ownership.js +52 -0
- package/dist/skill/ownership.js.map +1 -0
- package/package.json +63 -3
- package/skills/pxbaas-app-spec/SKILL.md +163 -0
- package/skills/pxbaas-app-spec/references/deploy-options.md +157 -0
- package/skills/pxbaas-app-spec/references/guardrails-pitfalls.md +89 -0
- package/skills/pxbaas-app-spec/references/runtime-contract.md +151 -0
- package/skills/pxbaas-app-spec/references/ssr-session.md +121 -0
- package/skills/pxbaas-app-spec/references/templates-examples.md +308 -0
- package/skills/pxbaas-cli/SKILL.md +168 -0
- package/skills/pxbaas-cli/references/api-reference.md +68 -0
- package/skills/pxbaas-cli/references/app-deploy.md +143 -0
- package/skills/pxbaas-cli/references/auth-and-credentials.md +88 -0
- package/skills/pxbaas-cli/references/data-postgrest.md +85 -0
- package/skills/pxbaas-cli/references/db-and-meta.md +85 -0
- package/skills/pxbaas-cli/references/edge-functions.md +71 -0
- package/skills/pxbaas-cli/references/multi-tenant-apps.md +107 -0
- package/skills/pxbaas-cli/references/provision.md +86 -0
- package/skills/pxbaas-cli/references/safety-and-two-phase.md +49 -0
- package/skills/pxbaas-cli/references/storage.md +61 -0
- package/skills/pxbaas-cli/references/troubleshooting.md +73 -0
- package/templates/webapp/api/index.ts +55 -0
- package/templates/webapp/csr-bff/README.md +81 -0
- package/templates/webapp/csr-bff/backend/index.ts +168 -0
- package/templates/webapp/csr-bff/db/schema.sql +69 -0
- package/templates/webapp/csr-bff/frontend/dist/admin.html +101 -0
- package/templates/webapp/csr-bff/frontend/dist/article.html +44 -0
- package/templates/webapp/csr-bff/frontend/dist/blog.js +206 -0
- package/templates/webapp/csr-bff/frontend/dist/blogger.html +53 -0
- package/templates/webapp/csr-bff/frontend/dist/index.html +70 -0
- package/templates/webapp/csr-bff/frontend/dist/login.html +56 -0
- package/templates/webapp/csr-bff/frontend/dist/profile.html +59 -0
- package/templates/webapp/csr-bff/frontend/dist/signup.html +78 -0
- package/templates/webapp/csr-bff/frontend/dist/style.css +27 -0
- package/templates/webapp/manifest.json +296 -0
- package/templates/webapp/mpa/about.html +29 -0
- package/templates/webapp/mpa/app.js +42 -0
- package/templates/webapp/mpa/index.html +42 -0
- package/templates/webapp/mpa/index.ts +60 -0
- package/templates/webapp/mpa/style.css +42 -0
- package/templates/webapp/react-vite/frontend/index.html +12 -0
- package/templates/webapp/react-vite/frontend/package-lock.json +1826 -0
- package/templates/webapp/react-vite/frontend/package.json +23 -0
- package/templates/webapp/react-vite/frontend/src/App.tsx +34 -0
- package/templates/webapp/react-vite/frontend/src/main.tsx +13 -0
- package/templates/webapp/react-vite/frontend/src/style.css +46 -0
- package/templates/webapp/react-vite/frontend/tsconfig.json +20 -0
- package/templates/webapp/react-vite/frontend/vite.config.ts +10 -0
- package/templates/webapp/react-vite-hono/backend/index.ts +21 -0
- package/templates/webapp/react-vite-hono/frontend/index.html +12 -0
- package/templates/webapp/react-vite-hono/frontend/package-lock.json +1826 -0
- package/templates/webapp/react-vite-hono/frontend/package.json +23 -0
- package/templates/webapp/react-vite-hono/frontend/src/App.tsx +47 -0
- package/templates/webapp/react-vite-hono/frontend/src/main.tsx +13 -0
- package/templates/webapp/react-vite-hono/frontend/src/style.css +66 -0
- package/templates/webapp/react-vite-hono/frontend/tsconfig.json +20 -0
- package/templates/webapp/react-vite-hono/frontend/vite.config.ts +10 -0
- package/templates/webapp/spa/index.ts +119 -0
- package/templates/webapp/ssr-auth/backend/index.ts +133 -0
- package/templates/webapp/ssr-auth/frontend/dist/index.html +15 -0
- package/templates/webapp/ssr-blog/README.md +111 -0
- package/templates/webapp/ssr-blog/backend/index.ts +764 -0
- package/templates/webapp/ssr-blog/db/schema.sql +71 -0
- package/templates/webapp/ssr-blog/frontend/dist/index.html +39 -0
- package/templates/webapp/ssr-frameworks/README.md +64 -0
- package/templates/webapp/ssr-frameworks/backend/index.ts +241 -0
- package/templates/webapp/ssr-frameworks/frontend/dist/index.html +31 -0
- package/templates/webapp/ssr-hono/backend/index.ts +92 -0
- package/templates/webapp/ssr-hono/frontend/dist/index.html +12 -0
- package/templates/webapp/ssr-shop/README.md +80 -0
- package/templates/webapp/ssr-shop/backend/index.ts +491 -0
- package/templates/webapp/ssr-shop/db/schema.sql +120 -0
- package/templates/webapp/ssr-shop/frontend/dist/index.html +12 -0
- package/templates/webapp/vanilla-static/frontend/app.js +6 -0
- package/templates/webapp/vanilla-static/frontend/index.html +19 -0
- package/templates/webapp/vanilla-static/frontend/style.css +43 -0
- package/README.md +0 -3
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- mpa-blog-ssr — 数据层(幂等,可重复执行)
|
|
3
|
+
--
|
|
4
|
+
-- 用法:把 __SCHEMA__ 替换为目标库名后逐条执行
|
|
5
|
+
-- · 单租户实例:__SCHEMA__ = public
|
|
6
|
+
-- · 多租户实例:__SCHEMA__ = tenant_<tenant_id>
|
|
7
|
+
-- sed 's/__SCHEMA__/public/g' db/schema.sql
|
|
8
|
+
--
|
|
9
|
+
-- 执行通道:单租户可 pxbaas db query;多租户走 POST {endpoint}/pg/query
|
|
10
|
+
-- (全局 service_role key 作 Authorization+apikey),语句写全限定名。
|
|
11
|
+
--
|
|
12
|
+
-- ⚠️ RLS 元数据落点(写错静默失效=RLS 关闭):永远写中心库 supabase._mysqlrest_rls_*,
|
|
13
|
+
-- 且 table_schema 填 __SCHEMA__;租户库内同名表无人读取,勿写入。
|
|
14
|
+
-- =============================================================================
|
|
15
|
+
|
|
16
|
+
CREATE TABLE IF NOT EXISTS `__SCHEMA__`.`posts_ssr` (
|
|
17
|
+
`id` bigint NOT NULL AUTO_INCREMENT,
|
|
18
|
+
`title` varchar(200) NOT NULL,
|
|
19
|
+
`content` mediumtext,
|
|
20
|
+
`author_id` varchar(64) NOT NULL,
|
|
21
|
+
`author_name` varchar(64) DEFAULT NULL,
|
|
22
|
+
`status` varchar(16) DEFAULT 'draft',
|
|
23
|
+
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
24
|
+
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
25
|
+
PRIMARY KEY (`id`),
|
|
26
|
+
KEY `idx_posts_ssr_status` (`status`, `created_at`)
|
|
27
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
28
|
+
|
|
29
|
+
-- ---- RLS 注册 ----
|
|
30
|
+
INSERT IGNORE INTO supabase._mysqlrest_rls_tables (table_schema, table_name, rls_enabled, rls_forced)
|
|
31
|
+
VALUES ('__SCHEMA__','posts_ssr',1,0);
|
|
32
|
+
|
|
33
|
+
-- ---- RLS 策略:anon 只读 published;authenticated 读 published 或本人;写改删限本人 ----
|
|
34
|
+
INSERT IGNORE INTO supabase._mysqlrest_rls_policies
|
|
35
|
+
(table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
|
|
36
|
+
VALUES ('__SCHEMA__','posts_ssr','posts_ssr_select_anon','anon','SELECT','`status` = ''published''',NULL,1);
|
|
37
|
+
INSERT IGNORE INTO supabase._mysqlrest_rls_policies
|
|
38
|
+
(table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
|
|
39
|
+
VALUES ('__SCHEMA__','posts_ssr','posts_ssr_select_auth','authenticated','SELECT',
|
|
40
|
+
'`status` = ''published'' OR author_id = {{request.jwt.claim.sub}}',NULL,1);
|
|
41
|
+
INSERT IGNORE INTO supabase._mysqlrest_rls_policies
|
|
42
|
+
(table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
|
|
43
|
+
VALUES ('__SCHEMA__','posts_ssr','posts_ssr_insert_auth','authenticated','INSERT',NULL,
|
|
44
|
+
'author_id = {{request.jwt.claim.sub}}',1);
|
|
45
|
+
INSERT IGNORE INTO supabase._mysqlrest_rls_policies
|
|
46
|
+
(table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
|
|
47
|
+
VALUES ('__SCHEMA__','posts_ssr','posts_ssr_update_auth','authenticated','UPDATE',
|
|
48
|
+
'author_id = {{request.jwt.claim.sub}}','author_id = {{request.jwt.claim.sub}}',1);
|
|
49
|
+
INSERT IGNORE INTO supabase._mysqlrest_rls_policies
|
|
50
|
+
(table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
|
|
51
|
+
VALUES ('__SCHEMA__','posts_ssr','posts_ssr_delete_auth','authenticated','DELETE',
|
|
52
|
+
'author_id = {{request.jwt.claim.sub}}',NULL,1);
|
|
53
|
+
|
|
54
|
+
-- ---- 种子文章(3 篇 published,幂等)----
|
|
55
|
+
INSERT INTO `__SCHEMA__`.`posts_ssr` (title, content, author_id, author_name, status)
|
|
56
|
+
SELECT * FROM (
|
|
57
|
+
SELECT '服务端渲染的第一篇' a,
|
|
58
|
+
'worker 在服务端取数并直出整页 HTML,浏览器拿到的就是内容,无需等待客户端拉取。' b,
|
|
59
|
+
'demo-author' c, '架构笔记' d, 'published' e
|
|
60
|
+
UNION ALL SELECT '多租户下的数据边界',
|
|
61
|
+
'每个租户一套库与 RLS 元数据,凭据自带 tenant_id,天然隔离;跨租户读取会被网关或 RLS 拒绝。',
|
|
62
|
+
'demo-author', '平台', 'published'
|
|
63
|
+
UNION ALL SELECT '分享一个链接就能看',
|
|
64
|
+
'mount-id 入口即分享地址,无需额外网关配置;整站服务端渲染,首屏即内容。',
|
|
65
|
+
'demo-author', '随笔', 'published'
|
|
66
|
+
) t WHERE NOT EXISTS (SELECT 1 FROM `__SCHEMA__`.`posts_ssr` WHERE title='服务端渲染的第一篇');
|
|
67
|
+
|
|
68
|
+
-- ---- 验收 ----
|
|
69
|
+
-- 1) anon 读 posts_ssr → 只见 3 篇 published;
|
|
70
|
+
-- 2) 带平台会话(注入头)发文 author_id=自己 → 201;伪造他人 author_id → 403 PGRST302;
|
|
71
|
+
-- 3) 本人读 → 3 篇 published + 自己的 draft。
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>SSR 博客广场 — 正在进入</title>
|
|
7
|
+
<!-- 入口页:/apps/{mount}/ 由此静态页承载,立即跳转到服务端渲染(SSR)入口 ./api/page。
|
|
8
|
+
使用相对路径 ./api/page,兼容 /apps/{mount}/ 前缀(含分享挂载)。 -->
|
|
9
|
+
<meta http-equiv="refresh" content="0; url=./posts">
|
|
10
|
+
<style>
|
|
11
|
+
:root{--accent:#5b6cff;--accent2:#9333ea}
|
|
12
|
+
*{box-sizing:border-box}
|
|
13
|
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
|
14
|
+
background:linear-gradient(135deg,#f5f6fa,#eef0fb);
|
|
15
|
+
font:16px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;color:#1e2330}
|
|
16
|
+
.box{text-align:center;background:#fff;border:1px solid #e7e9f0;border-radius:20px;
|
|
17
|
+
padding:40px 44px;box-shadow:0 12px 40px rgba(16,24,40,.10);max-width:420px}
|
|
18
|
+
.logo{font-size:44px;line-height:1}
|
|
19
|
+
h1{font-size:22px;margin:14px 0 6px}
|
|
20
|
+
p{color:#6b7280;margin:0 0 18px;font-size:14.5px}
|
|
21
|
+
a{display:inline-block;text-decoration:none;color:#fff;font-weight:600;font-size:14.5px;
|
|
22
|
+
padding:10px 18px;border-radius:10px;background:linear-gradient(120deg,var(--accent),var(--accent2))}
|
|
23
|
+
.spin{width:22px;height:22px;margin:0 auto 6px;border:3px solid #e7e9f0;border-top-color:var(--accent);
|
|
24
|
+
border-radius:50%;animation:sp .8s linear infinite}
|
|
25
|
+
@keyframes sp{to{transform:rotate(360deg)}}
|
|
26
|
+
</style>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<div class="box">
|
|
30
|
+
<div class="logo">📚</div>
|
|
31
|
+
<div class="spin"></div>
|
|
32
|
+
<h1>SSR 博客广场</h1>
|
|
33
|
+
<p>正在进入服务端渲染页面…</p>
|
|
34
|
+
<a href="./posts">若未自动跳转,点此进入</a>
|
|
35
|
+
<p style="color:#9aa0b4;font-size:12.5px;margin-top:16px">整站服务端渲染;登录 / 注册 / 写作均在同一入口内完成(平台会话注入)。</p>
|
|
36
|
+
</div>
|
|
37
|
+
<script>location.replace("./posts");</script>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# {{APP_NAME}} — 四框架 SSR 效果对照参考模板(React / Vue / Preact / Solid)
|
|
2
|
+
|
|
3
|
+
**形态**:同一套 UI 规格由四家服务端渲染器各自 `renderToString` 直出整页 HTML,`/compare` 页并列对照范式特色。**无数据面、无凭据、无外部请求**——纯粹展示"沙箱预置 Tier-0 SSR 渲染器"的真机效果,因此**不需要填任何部署配置**。
|
|
4
|
+
**会话模型**:`none`。
|
|
5
|
+
|
|
6
|
+
## 目录
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
{{APP_NAME}}/
|
|
10
|
+
├── app.json # schemaVersion 2;fallback=worker;ssrProbePath=/react
|
|
11
|
+
├── backend/index.ts # 单文件自包含 worker(Hono 路由 + 四家渲染器)
|
|
12
|
+
└── frontend/dist/index.html # 静态入口壳
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 路由
|
|
16
|
+
|
|
17
|
+
| 路径 | 内容 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `/react` `/vue` `/preact` `/solid` | 各框架 idiomatic 组件树服务端渲染整页 |
|
|
20
|
+
| `/compare` | 四框架并列对照(含范式特色列) |
|
|
21
|
+
| `/api/health` | `{"ok":true,"renderers":[...],"engine":"hono@4.6.12"}` |
|
|
22
|
+
|
|
23
|
+
worker 的路径解析兼容两种派发形态:strip 后的 `/react` 与候选 slug 前缀 `/app_<uuid>_r_<rev>/react`。
|
|
24
|
+
|
|
25
|
+
## 平台锁版本(全部走沙箱离线缓存,`--cached-only` 可过)
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
npm:hono@4.6.12 npm:react@18.3.1 npm:react-dom@18.3.1/server
|
|
29
|
+
npm:vue@3.5.13 npm:@vue/server-renderer@3.5.13
|
|
30
|
+
npm:preact@10.24.3 npm:preact-render-to-string@6.5.11
|
|
31
|
+
npm:solid-js@1.9.3 npm:solid-js@1.9.3/web
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
清单唯一事实源:`deploy/base-images/deno-npm-cache/SSR_LIBS_LEDGER.md`。加/换库必须同步台账、`download.ts`、`verify-ssr-cache.ts` 与镜像的 `SSR_LIBS_LIST`。
|
|
35
|
+
|
|
36
|
+
## 部署
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pxbaas app deploy {{APP_NAME}} --dir . # appName 必须与 app.json 的 name 一致
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### ⚠️ 冷启动:本模板是四个模板里最重的
|
|
43
|
+
|
|
44
|
+
四个渲染器 + Hono 的模块解析/编译使**冷启动首次请求实测约 60s**(同一沙箱后续请求 < 0.2s)。manifest 声明了 `ssrProbePath: "/react"`,publish 前平台会实探一次,因此极易撞上 `409 APP_SSR_PROBE_FAILED`(先 `SSR probe unreachable: ... timeout`,后 `http_500`)。标准处置:
|
|
45
|
+
|
|
46
|
+
1. 先把同一 worker 以 legacy 函数部署到**同租户**,反复 GET `/api/health` 到 200(首次可能 ~60s)——沙箱按租户复用,模块缓存随即生效;
|
|
47
|
+
2. 再 `publish`;若仍失败,等 10~20s 重试 3~5 次。
|
|
48
|
+
|
|
49
|
+
判据:同租户 legacy 函数"慢但最终 200" = 冷启动;"立刻 500 带堆栈" = 代码问题。
|
|
50
|
+
|
|
51
|
+
## 验收清单
|
|
52
|
+
|
|
53
|
+
| # | 检查 | 期望 |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| 1 | `GET /apps/{mount}/api/health` | 200,`renderers` 列出四家版本、`engine` 为 hono@4.6.12 |
|
|
56
|
+
| 2 | `GET /apps/{mount}/react`(及 vue/preact/solid) | 200,整页 HTML 含 `data-ssr="v1"` 与卡片内容 |
|
|
57
|
+
| 3 | `GET /apps/{mount}/compare` | 200,四列对照齐全 |
|
|
58
|
+
| 4 | `GET /apps/{mount}/` | 200 静态壳 |
|
|
59
|
+
|
|
60
|
+
## 已知平台行为(非本模板缺陷)
|
|
61
|
+
|
|
62
|
+
- `capabilities.ssrLibs` 可能报空数组,但沙箱实际能解析上述全部包(能力低报,Aone #87704012)。**能否运行以实测为准**。
|
|
63
|
+
- 但 spec-lint 会**按 `ssrLibs` 白名单**校验 `npm:` 导入:实例报空时 `pxbaas app deploy` 会以 `no-remote-import` 阻断本模板。根治办法是镜像构建期把台账清单写入 `SSR_LIBS_LIST`(`deploy/all-in-one/build-aio.sh`),不要用 `--skip-spec-check` 长期绕过。
|
|
64
|
+
- 框架渲染陷阱(已在本模板规避,改代码时注意):React 的 `style` 必须是对象而非字符串;Vue 3 实例会自动解包 `ref`,`render()` 里不要再 `.value`;Solid 的 `ssr` 模板原语**不可嵌套**,子树用普通字符串拼接 + 手工 `esc()`。
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// ssr-ui-demo — 四框架服务端渲染效果对照 demo(React / Vue / Preact / Solid)
|
|
2
|
+
//
|
|
3
|
+
// 定位: 展示 sandbox 预置 Tier-0 SSR 渲染器的真机效果(同 UI 四框架 renderToString),
|
|
4
|
+
// 后续考虑收入 pxbaas CLI 示例。全部平台锁版本缓存, --cached-only 可过。
|
|
5
|
+
// 契约: 单文件自包含(D16) · marker data-ssr="v1"(DX-2) · 一级路由相对链接 ./x ·
|
|
6
|
+
// 交互用 data-* + 客户端委托(React SSR 无事件处理器坑)· 内联样式靠 CSP unsafe-inline。
|
|
7
|
+
// 路由: /react /vue /preact /solid /compare /api/health(+剥离形态)
|
|
8
|
+
|
|
9
|
+
import { Hono } from "npm:hono@4.6.12";
|
|
10
|
+
import React from "npm:react@18.3.1";
|
|
11
|
+
import { renderToString as reactRender } from "npm:react-dom@18.3.1/server";
|
|
12
|
+
import { createSSRApp, h as vh, ref as vref, computed as vcomputed } from "npm:vue@3.5.13";
|
|
13
|
+
import { renderToString as vueRender } from "npm:@vue/server-renderer@3.5.13";
|
|
14
|
+
import { h as ph, Fragment as PF } from "npm:preact@10.24.3";
|
|
15
|
+
import { render as preactRender } from "npm:preact-render-to-string@6.5.11";
|
|
16
|
+
import { renderToString as solidRender, ssr } from "npm:solid-js@1.9.3/web";
|
|
17
|
+
import { createSignal, createMemo } from "npm:solid-js@1.9.3";
|
|
18
|
+
|
|
19
|
+
// ---------- 共享 UI 规格(四框架渲染同一内容) ----------
|
|
20
|
+
const CARDS = [
|
|
21
|
+
{ icon: "⚡", title: "服务端渲染", desc: "首屏 HTML 由 worker 内 renderToString 直出", stat: "0 ms 客户端等待" },
|
|
22
|
+
{ icon: "🧩", title: "组件化", desc: "各框架 idiomatic 组件树(非字符串拼接)", stat: "4 家渲染器" },
|
|
23
|
+
{ icon: "🔒", title: "平台锁版本", desc: "npm:hono@4.6.12 与四家渲染器精确版本缓存", stat: "--cached-only" },
|
|
24
|
+
];
|
|
25
|
+
const FW_META: Record<string, { name: string; pkg: string; color: string; sig: string }> = {
|
|
26
|
+
react: { name: "React 18", pkg: "react-dom/server · renderToString", color: "#61dafb", sig: "Context 跨层注入 + 组件组合树" },
|
|
27
|
+
vue: { name: "Vue 3", pkg: "@vue/server-renderer · renderToString", color: "#42b883", sig: "ref/computed 服务端响应式 + v-for/v-if" },
|
|
28
|
+
preact: { name: "Preact 10", pkg: "preact-render-to-string · render", color: "#673ab8", sig: "3kB 内核 · 50 次连渲基准" },
|
|
29
|
+
solid: { name: "Solid 1.9", pkg: "solid-js/web · renderToString", color: "#2c4f7c", sig: "signal→memo 链式细粒度派生" },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ---------- 四框架组件实现(同 UI + 各家范式特色) ----------
|
|
33
|
+
const ReactTheme = React.createContext("indigo 主题");
|
|
34
|
+
function ReactDeep() {
|
|
35
|
+
const t = React.useContext(ReactTheme);
|
|
36
|
+
return React.createElement("span", { className: "stat" }, "第三层组件读 Context: " + t);
|
|
37
|
+
}
|
|
38
|
+
function ReactMid() {
|
|
39
|
+
return React.createElement("div", { className: "row" }, React.createElement(ReactDeep));
|
|
40
|
+
}
|
|
41
|
+
function reactBody(): string {
|
|
42
|
+
const e = React.createElement;
|
|
43
|
+
return reactRender(e(ReactTheme.Provider, { value: "indigo 主题(Provider 注入)" },
|
|
44
|
+
e("div", null,
|
|
45
|
+
e("h2", { className: "fwtitle" }, "React 18 服务端渲染"),
|
|
46
|
+
e("p", { className: "muted" }, "createElement 组件树 → renderToString · 特色:Context 跨三层注入 + 组件组合"),
|
|
47
|
+
e("div", { className: "cards" }, CARDS.map((c, i) => e("div", { className: "card", key: i },
|
|
48
|
+
e("div", { className: "icon" }, c.icon),
|
|
49
|
+
e("h3", null, c.title),
|
|
50
|
+
e("p", { className: "muted" }, c.desc),
|
|
51
|
+
e("span", { className: "stat" }, c.stat)))),
|
|
52
|
+
e(ReactMid),
|
|
53
|
+
e("div", { className: "row" },
|
|
54
|
+
e("button", { type: "button", "data-inc": "react" }, "点我计数"),
|
|
55
|
+
e("span", { className: "stat", id: "cnt-react" }, "0")))));
|
|
56
|
+
}
|
|
57
|
+
async function vueBody(): Promise<string> {
|
|
58
|
+
const e = vh;
|
|
59
|
+
const app = createSSRApp({
|
|
60
|
+
setup() {
|
|
61
|
+
const count = vref(CARDS.length);
|
|
62
|
+
const total = vcomputed(() => count.value * 100);
|
|
63
|
+
const hot = vcomputed(() => CARDS.filter((c) => c.icon === "⚡" || c.icon === "🧩"));
|
|
64
|
+
return { count, total, hot };
|
|
65
|
+
},
|
|
66
|
+
render() {
|
|
67
|
+
// Vue 实例 proxy 自动解包 ref/computed:this.total 已是 number、this.hot 已是数组
|
|
68
|
+
const ctx = this as unknown as { total: number; hot: typeof CARDS };
|
|
69
|
+
return e("div", null, [
|
|
70
|
+
e("h2", { class: "fwtitle" }, "Vue 3 服务端渲染"),
|
|
71
|
+
e("p", { class: "muted" }, "createSSRApp + h 渲染函数 → @vue/server-renderer · 特色:ref/computed 响应式在服务端求值 + v-for/v-if 等价渲染"),
|
|
72
|
+
e("div", { class: "cards" }, CARDS.map((c) => e("div", { class: "card", key: c.title }, [
|
|
73
|
+
e("div", { class: "icon" }, c.icon),
|
|
74
|
+
e("h3", null, c.title),
|
|
75
|
+
e("p", { class: "muted" }, c.desc),
|
|
76
|
+
e("span", { class: "stat" }, c.stat),
|
|
77
|
+
]))),
|
|
78
|
+
e("div", { class: "row" }, [
|
|
79
|
+
e("span", { class: "stat" }, "computed total = ref×100 = " + ctx.total),
|
|
80
|
+
e("span", { class: "stat" }, "v-if 过滤 hot 卡片 = " + ctx.hot.length + " 张"),
|
|
81
|
+
]),
|
|
82
|
+
e("div", { class: "row" }, [
|
|
83
|
+
e("button", { type: "button", "data-inc": "vue" }, "点我计数"),
|
|
84
|
+
e("span", { class: "stat", id: "cnt-vue" }, "0"),
|
|
85
|
+
]),
|
|
86
|
+
]);
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
return await vueRender(app);
|
|
90
|
+
}
|
|
91
|
+
function preactBody(): string {
|
|
92
|
+
const e = ph;
|
|
93
|
+
const tree = e("div", { className: "cards" }, CARDS.map((c) => e("div", { className: "card" },
|
|
94
|
+
e("div", { className: "icon" }, c.icon), e("h3", null, c.title))));
|
|
95
|
+
const N = 50;
|
|
96
|
+
const t0 = performance.now();
|
|
97
|
+
for (let i = 0; i < N; i++) preactRender(tree);
|
|
98
|
+
const avg = Math.round(((performance.now() - t0) / N) * 1000) / 1000;
|
|
99
|
+
return preactRender(e(PF, null,
|
|
100
|
+
e("div", null,
|
|
101
|
+
e("h2", { className: "fwtitle" }, "Preact 10 服务端渲染"),
|
|
102
|
+
e("p", { className: "muted" }, "3kB React 兼容替身 → preact-render-to-string · 特色:极速连渲基准(本页请求内实测)"),
|
|
103
|
+
e("div", { className: "cards" }, CARDS.map((c) => e("div", { className: "card" },
|
|
104
|
+
e("div", { className: "icon" }, c.icon),
|
|
105
|
+
e("h3", null, c.title),
|
|
106
|
+
e("p", { className: "muted" }, c.desc),
|
|
107
|
+
e("span", { className: "stat" }, c.stat)))),
|
|
108
|
+
e("div", { className: "row" },
|
|
109
|
+
e("span", { className: "stat" }, N + " 次连渲平均 " + avg + " ms/次"),
|
|
110
|
+
e("span", { className: "stat" }, "Fragment 根节点 ✓")),
|
|
111
|
+
e("div", { className: "row" },
|
|
112
|
+
e("button", { type: "button", "data-inc": "preact" }, "点我计数"),
|
|
113
|
+
e("span", { className: "stat", id: "cnt-preact" }, "0")))));
|
|
114
|
+
}
|
|
115
|
+
function esc(s: string): string {
|
|
116
|
+
return s.split("&").join("&").split("<").join("<").split(">").join(">").split('"').join(""");
|
|
117
|
+
}
|
|
118
|
+
function solidBody(): string {
|
|
119
|
+
// 无 JSX 的 Solid SSR:外层整页用编译器同款 ssr 原语;列表用普通字符串拼接 + esc
|
|
120
|
+
// (嵌套 ssr 标签返回编译器内部描述对象而非字符串,join 后会变 [object Object])
|
|
121
|
+
const [base] = createSignal(CARDS.length);
|
|
122
|
+
const doubled = createMemo(() => base() * 2);
|
|
123
|
+
const chained = createMemo(() => doubled() + 100);
|
|
124
|
+
const cards = CARDS.map((c) => `<div class="card"><div class="icon">${esc(c.icon)}</div><h3>${esc(c.title)}</h3><p class="muted">${esc(c.desc)}</p><span class="stat">${esc(c.stat)}</span></div>`).join("");
|
|
125
|
+
return solidRender(() => ssr`<div><h2 class="fwtitle">Solid 1.9 服务端渲染</h2><p class="muted">ssr 模板原语(编译器同款)→ web renderToString · 特色:无 VDOM,signal→memo 链式细粒度派生在服务端求值</p><div class="cards">${cards}</div><div class="row"><span class="stat">signal=${base()}</span><span class="stat">memo×2=${doubled()}</span><span class="stat">链式 memo+100=${chained()}</span></div><div class="row"><button type="button" data-inc="solid">点我计数</button><span class="stat" id="cnt-solid">0</span></div></div>`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------- /compare: 同请求四渲染对照 ----------
|
|
129
|
+
async function compareRows(): Promise<Array<{ fw: string; ms: number; bytes: number }>> {
|
|
130
|
+
const rows: Array<{ fw: string; ms: number; bytes: number }> = [];
|
|
131
|
+
const run = async (name: string, fn: () => string | Promise<string>) => {
|
|
132
|
+
const t0 = performance.now();
|
|
133
|
+
const out = await fn();
|
|
134
|
+
rows.push({ fw: name, ms: Math.round((performance.now() - t0) * 10) / 10, bytes: out.length });
|
|
135
|
+
};
|
|
136
|
+
await run("react", reactBody);
|
|
137
|
+
await run("vue", vueBody);
|
|
138
|
+
await run("preact", preactBody);
|
|
139
|
+
await run("solid", solidBody);
|
|
140
|
+
return rows;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------- 页面外壳 ----------
|
|
144
|
+
const CSS = `
|
|
145
|
+
:root { color-scheme: dark; --bg:#0b1020; --card:rgba(255,255,255,.05); --line:rgba(255,255,255,.12);
|
|
146
|
+
--txt:#e8eaf2; --muted:#9aa3b8; --acc:#6366f1; }
|
|
147
|
+
* { box-sizing:border-box; } html,body { margin:0; }
|
|
148
|
+
body { font-family:-apple-system,"PingFang SC",sans-serif; color:var(--txt); background:var(--bg);
|
|
149
|
+
min-height:100vh; max-width:960px; margin:0 auto; padding:0 18px 60px; }
|
|
150
|
+
header { display:flex; gap:14px; align-items:center; padding:14px 4px; border-bottom:1px solid var(--line);
|
|
151
|
+
position:sticky; top:0; background:rgba(11,16,32,.8); backdrop-filter:blur(10px); z-index:5; }
|
|
152
|
+
header .logo { font-weight:800; font-size:18px; }
|
|
153
|
+
nav { margin-left:auto; display:flex; gap:4px; flex-wrap:wrap; }
|
|
154
|
+
nav a { color:#aeb9d6; text-decoration:none; font-size:13px; padding:6px 12px; border-radius:999px; border:1px solid var(--line); }
|
|
155
|
+
nav a.on { color:#fff; border-color:transparent; background:var(--acc); }
|
|
156
|
+
.fwtitle { margin:22px 0 4px; } .muted { color:var(--muted); font-size:13px; }
|
|
157
|
+
.cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:14px; margin:18px 0; }
|
|
158
|
+
.card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:16px; }
|
|
159
|
+
.icon { font-size:34px; } .card h3 { margin:8px 0 4px; }
|
|
160
|
+
.stat { display:inline-block; margin-top:8px; font-size:12px; color:#c7d2fe;
|
|
161
|
+
background:rgba(99,102,241,.16); border:1px solid rgba(99,102,241,.4); border-radius:999px; padding:2px 10px; }
|
|
162
|
+
.row { display:flex; gap:12px; align-items:center; margin:10px 0; }
|
|
163
|
+
button { border:0; border-radius:10px; padding:9px 18px; font:inherit; cursor:pointer; color:#fff;
|
|
164
|
+
background:linear-gradient(90deg,#6366f1,#ec4899); }
|
|
165
|
+
table { width:100%; border-collapse:collapse; margin:16px 0; }
|
|
166
|
+
td,th { padding:10px; border-bottom:1px solid var(--line); text-align:left; font-size:14px; }
|
|
167
|
+
th { color:var(--muted); }
|
|
168
|
+
.toast { position:fixed; left:50%; bottom:32px; transform:translateX(-50%); background:var(--acc);
|
|
169
|
+
color:#fff; border-radius:999px; padding:9px 22px; font-size:13px; opacity:0; pointer-events:none;
|
|
170
|
+
transition:opacity .18s; } .toast.show { opacity:1; }
|
|
171
|
+
footer { margin-top:36px; padding-top:14px; border-top:1px solid var(--line); color:var(--muted); font-size:12px; }
|
|
172
|
+
`;
|
|
173
|
+
|
|
174
|
+
function pageDoc(opts: { fw: string; body: string; ms: number; extra?: string }): string {
|
|
175
|
+
const meta = FW_META[opts.fw] || { name: opts.fw, pkg: "", color: "#888" };
|
|
176
|
+
const nav = ["react", "vue", "preact", "solid", "compare"].map((k) =>
|
|
177
|
+
`<a href="./${k}"${k === opts.fw ? " class=\"on\"" : ""}>${k === "compare" ? "四家对照" : FW_META[k].name}</a>`).join("");
|
|
178
|
+
return "<!DOCTYPE html><html lang=\"zh\"><head><meta charset=\"utf-8\">" +
|
|
179
|
+
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" +
|
|
180
|
+
"<title>SSR UI Demo · " + meta.name + "</title><style>" + CSS + "</style></head><body>" +
|
|
181
|
+
"<header><span class=\"logo\">🎨 SSR UI Demo</span><nav>" + nav + "</nav></header>" +
|
|
182
|
+
"<div id=\"__ssr\" data-ssr=\"v1\">" + opts.body + "</div>" +
|
|
183
|
+
(opts.extra || "") +
|
|
184
|
+
"<footer>renderer: " + meta.pkg + " · server render " + opts.ms + " ms · sandbox Tier-0 缓存 · " +
|
|
185
|
+
new Date().toISOString() + "</footer>" + CLIENT_JS + "</body></html>";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const CLIENT_JS = "<script>" + `
|
|
189
|
+
var counts={};var tt=null;
|
|
190
|
+
function toast(m){var t=document.getElementById('dtoast');if(!t){t=document.createElement('div');t.id='dtoast';t.className='toast';document.body.appendChild(t);}
|
|
191
|
+
t.textContent=m;t.classList.add('show');if(tt)clearTimeout(tt);tt=setTimeout(function(){t.classList.remove('show');},1100);}
|
|
192
|
+
document.addEventListener('click',function(e){var b=e.target&&e.target.closest?e.target.closest('[data-inc]'):null;if(!b)return;
|
|
193
|
+
var k=b.getAttribute('data-inc');counts[k]=(counts[k]||0)+1;
|
|
194
|
+
var s=document.getElementById('cnt-'+k);if(s)s.textContent=counts[k];
|
|
195
|
+
toast(k+' 客户端交互 ✓ 第 '+counts[k]+' 次(SSR 首屏 + 渐进增强)');});
|
|
196
|
+
` + "</script>";
|
|
197
|
+
|
|
198
|
+
// ---------- 路由 ----------
|
|
199
|
+
const app = new Hono();
|
|
200
|
+
const KNOWN_SEG = ["react", "vue", "preact", "solid", "compare", "api", "health"];
|
|
201
|
+
function norm(p: string): string {
|
|
202
|
+
let q = p.replace(/^\/app_[^/]+/, "").replace(/^\/functions\/v1\/[a-zA-Z0-9_-]+/, "") || p;
|
|
203
|
+
// legacy /functions 入口 runtime 只剥 /functions/v1 保留 slug:/<slug>/route
|
|
204
|
+
const segs = q.split("/");
|
|
205
|
+
if (segs.length >= 3 && !KNOWN_SEG.includes(segs[1])) q = "/" + segs.slice(2).join("/");
|
|
206
|
+
return q || "/";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function fwPage(fw: string): Promise<Response> {
|
|
210
|
+
const t0 = performance.now();
|
|
211
|
+
const body = fw === "react" ? reactBody() : fw === "vue" ? await vueBody() : fw === "preact" ? preactBody() : solidBody();
|
|
212
|
+
const ms = Math.round((performance.now() - t0) * 10) / 10;
|
|
213
|
+
return new Response(pageDoc({ fw, body, ms }), { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
app.get("*", async (c) => {
|
|
217
|
+
const p = norm(new URL(c.req.url).pathname);
|
|
218
|
+
if (p === "/react" || p === "/vue" || p === "/preact" || p === "/solid") return fwPage(p.slice(1));
|
|
219
|
+
if (p === "/compare" || p === "/compare/") {
|
|
220
|
+
const t0 = performance.now();
|
|
221
|
+
const rows = await compareRows();
|
|
222
|
+
const total = Math.round((performance.now() - t0) * 10) / 10;
|
|
223
|
+
const body = "<h2 class=\"fwtitle\">同请求四渲染器对照</h2>" +
|
|
224
|
+
"<p class=\"muted\">同一 UI 规格 · 单次请求内顺序 renderToString · 总 " + total + " ms</p>" +
|
|
225
|
+
"<table><tr><th>框架</th><th>渲染器</th><th>范式特色</th><th>耗时</th><th>输出字节</th></tr>" +
|
|
226
|
+
rows.map((r) => "<tr><td><b style=\"color:" + FW_META[r.fw].color + "\">" + FW_META[r.fw].name + "</b></td><td class=\"muted\">" +
|
|
227
|
+
FW_META[r.fw].pkg + "</td><td class=\"muted\">" + FW_META[r.fw].sig + "</td><td>" + r.ms + " ms</td><td>" + r.bytes + " B</td></tr>").join("") + "</table>" +
|
|
228
|
+
"<p class=\"muted\">结论看点:四家均在毫秒级完成服务端渲染,缓存命中下 import 零冷拉取。</p>";
|
|
229
|
+
return c.html(pageDoc({ fw: "compare", body, ms: total }));
|
|
230
|
+
}
|
|
231
|
+
if (p === "/api/health" || p === "/health") {
|
|
232
|
+
return c.json({ ok: true, renderers: ["react@18.3.1", "vue@3.5.13", "preact@10.24.3", "solid@1.9.3"], engine: "hono@4.6.12" });
|
|
233
|
+
}
|
|
234
|
+
return c.html(pageDoc({
|
|
235
|
+
fw: "compare",
|
|
236
|
+
body: "<div class=\"cards\"><div class=\"card\"><h3>404</h3><p class=\"muted\">路径不存在 " + p + "</p></div></div>",
|
|
237
|
+
ms: 0,
|
|
238
|
+
}), 404);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
Deno.serve(app.fetch);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>SSR UI Demo · 四框架服务端渲染对照</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { color-scheme: dark; }
|
|
8
|
+
body { font-family:-apple-system,"PingFang SC",sans-serif; background:#0b1020; color:#e8eaf2;
|
|
9
|
+
max-width:860px; margin:0 auto; padding:40px 18px; }
|
|
10
|
+
h1 { font-size:26px; } .muted { color:#9aa3b8; font-size:14px; }
|
|
11
|
+
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:14px; margin:24px 0; }
|
|
12
|
+
a.card { display:block; text-decoration:none; color:#e8eaf2; background:rgba(255,255,255,.05);
|
|
13
|
+
border:1px solid rgba(255,255,255,.12); border-radius:14px; padding:18px; }
|
|
14
|
+
a.card:hover { border-color:#6366f1; }
|
|
15
|
+
a.card b { display:block; font-size:17px; margin-bottom:6px; }
|
|
16
|
+
a.card span { color:#9aa3b8; font-size:12px; }
|
|
17
|
+
a.cmp { color:#c7d2fe; }
|
|
18
|
+
</style>
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<h1>🎨 SSR UI Demo</h1>
|
|
22
|
+
<p class="muted">同一 UI 规格 · 四家 Tier-0 服务端渲染器真机对照(sandbox 预置缓存 · 平台锁版本)</p>
|
|
23
|
+
<div class="grid">
|
|
24
|
+
<a class="card" href="./react"><b style="color:#61dafb">React 18</b><span>react-dom/server<br>renderToString</span></a>
|
|
25
|
+
<a class="card" href="./vue"><b style="color:#42b883">Vue 3</b><span>@vue/server-renderer<br>createSSRApp + h</span></a>
|
|
26
|
+
<a class="card" href="./preact"><b style="color:#a88beb">Preact 10</b><span>preact-render-to-string<br>3kB React 替身</span></a>
|
|
27
|
+
<a class="card" href="./solid"><b style="color:#7fb2e5">Solid 1.9</b><span>solid-js/web<br>tagged-template</span></a>
|
|
28
|
+
</div>
|
|
29
|
+
<p><a class="cmp" href="./compare">→ 四家同请求对照表(耗时/字节)</a></p>
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// {{APP_NAME}} — SSR-shell worker (Hono, platform-pinned version).
|
|
2
|
+
//
|
|
3
|
+
// Spec: follow the `pxbaas-app-spec` skill — `pxbaas app deploy` lints this file
|
|
4
|
+
// against it, including the SSR rules (single-file self-contained, hydration
|
|
5
|
+
// marker, no browser globals at import/render time).
|
|
6
|
+
//
|
|
7
|
+
// Model: server renders the page SHELL (layout/theme/route shell/deploy-time
|
|
8
|
+
// content). Data is fetched by the browser AFTER hydration via relative
|
|
9
|
+
// ./api/* calls or the instance REST/Auth URLs. The worker MAY also fetch the
|
|
10
|
+
// instance data plane server-side (see /ssr-data) through the platform-injected
|
|
11
|
+
// SUPABASE_INTERNAL_URL: egressPolicy="deny" blocks PUBLIC egress, but the
|
|
12
|
+
// internal base is reachable, so server-side REST/Auth reads are allowed.
|
|
13
|
+
//
|
|
14
|
+
// Deploy with: pxbaas app deploy {{SLUG}}
|
|
15
|
+
// SSR page: {app-url}/page (static-miss fallthrough, fallback=worker)
|
|
16
|
+
// JSON API: {app-url}/api/data
|
|
17
|
+
|
|
18
|
+
import { Hono } from "npm:hono@{{HONO_VERSION}}";
|
|
19
|
+
|
|
20
|
+
const app = new Hono();
|
|
21
|
+
|
|
22
|
+
// Data-plane base contract (edge-runtime app-env.ts, MR #30497903): the platform
|
|
23
|
+
// injects SUPABASE_INTERNAL_URL — the ONLY base reachable from inside the sandbox
|
|
24
|
+
// (the public SUPABASE_URL is egress-filtered, cluster DNS is absent), and derives
|
|
25
|
+
// it via DX-12 platform-side. The worker just reads it; SUPABASE_URL is render-only
|
|
26
|
+
// and must never be fetched. Empty when the sandbox image predates the injection,
|
|
27
|
+
// so callers degrade gracefully (the shell still renders without server data).
|
|
28
|
+
function internalBase(): string {
|
|
29
|
+
return (Deno.env.get("SUPABASE_INTERNAL_URL") || "").replace(/\/+$/, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function esc(s: string): string {
|
|
33
|
+
return s
|
|
34
|
+
.split("&").join("&")
|
|
35
|
+
.split("<").join("<")
|
|
36
|
+
.split(">").join(">")
|
|
37
|
+
.split(String.fromCharCode(34)).join(""");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// L3 shape demo: server-side fetch of DB content via the derived internal base
|
|
41
|
+
// (anon key from env; RLS enforced server-side). Degrades gracefully so the
|
|
42
|
+
// shell still renders if the internal entry is ever unreachable.
|
|
43
|
+
app.get("/ssr-data", async (c) => {
|
|
44
|
+
let rows: Array<Record<string, unknown>> = [];
|
|
45
|
+
let note = "";
|
|
46
|
+
try {
|
|
47
|
+
const r = await fetch(internalBase() + "/rest/v1/?select=1", {
|
|
48
|
+
headers: { apikey: Deno.env.get("SUPABASE_ANON_KEY") || "" },
|
|
49
|
+
});
|
|
50
|
+
note = `internal base reachable: http-${r.status}`;
|
|
51
|
+
} catch (e) {
|
|
52
|
+
note = `internal base unreachable: ${String(e).slice(0, 60)}`;
|
|
53
|
+
}
|
|
54
|
+
return c.html(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>L3 demo</title></head><body>
|
|
55
|
+
<div id="__ssr" data-ssr="v1"><h1>L3 full-SSR demo</h1><p>${esc(note)}</p><pre>${esc(JSON.stringify(rows))}</pre></div>
|
|
56
|
+
</body></html>`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
app.get("/page", (c) => {
|
|
60
|
+
const theme = c.req.query("theme") === "dark" ? "dark" : "light";
|
|
61
|
+
const bg = theme === "dark" ? "#111" : "#fafafa";
|
|
62
|
+
const fg = theme === "dark" ? "#eee" : "#222";
|
|
63
|
+
const html = `<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
64
|
+
<title>{{APP_NAME}} SSR</title>
|
|
65
|
+
<style>body{font-family:sans-serif;max-width:720px;margin:40px auto;background:${bg};color:${fg}}</style>
|
|
66
|
+
</head><body>
|
|
67
|
+
<div id="__ssr" data-ssr="v1" data-theme="${theme}">
|
|
68
|
+
<h1>SSR Shell</h1>
|
|
69
|
+
<p>Rendered server-side at ${esc(new Date().toISOString())} · theme=${esc(theme)} (?theme=dark)</p>
|
|
70
|
+
<div id="data">hydrating…</div>
|
|
71
|
+
</div>
|
|
72
|
+
<script>
|
|
73
|
+
(async function () {
|
|
74
|
+
var r = await fetch("./api/data");
|
|
75
|
+
var j = await r.json();
|
|
76
|
+
document.getElementById("data").textContent = "hydrate ok: " + JSON.stringify(j);
|
|
77
|
+
})();
|
|
78
|
+
</script>
|
|
79
|
+
</body></html>`;
|
|
80
|
+
return c.html(html);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Path contract (APP_WORKER_PATH_REWRITE off, current default): the /api/* branch
|
|
84
|
+
// delivers the STRIPPED logical path (/data, /health) while static-miss
|
|
85
|
+
// fallthrough delivers the FULL logical path (/page). Register both shapes so
|
|
86
|
+
// the worker behaves identically when the rewrite kill-switch is flipped back on.
|
|
87
|
+
app.get("/data", (c) => c.json({ ok: true, engine: "hono", now: new Date().toISOString() }));
|
|
88
|
+
app.get("/api/data", (c) => c.json({ ok: true, engine: "hono", now: new Date().toISOString() }));
|
|
89
|
+
app.get("/health", (c) => c.json({ ok: true }));
|
|
90
|
+
app.get("/api/health", (c) => c.json({ ok: true }));
|
|
91
|
+
|
|
92
|
+
Deno.serve(app.fetch);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>{{APP_NAME}}</title>
|
|
6
|
+
<style>body{font-family:sans-serif;max-width:720px;margin:48px auto}</style>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<h1>{{APP_NAME}}</h1>
|
|
10
|
+
<p>Static landing page. The server-rendered shell lives at <a href="./page">./page</a> (SSR, fallback=worker).</p>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# {{APP_NAME}} — 框架 SSR 电商参考模板(React 18 + Hono)
|
|
2
|
+
|
|
3
|
+
**形态**:目录 / 详情 / 搜索 = **全量 SSR**(worker 服务端经内网基址取 MySQL 后 `renderToString` 直出整页);购物车 / 结算 / 订单 / 注册登录 / 管理台 = **SSR shell + 浏览器 hydrate**。
|
|
4
|
+
**会话模型**:`browser-side` —— 浏览器持 GoTrue token(localStorage)并**直连** `/rest/v1`、`/auth/v1`;worker 不碰用户凭据。下单门禁落在 **RLS 数据层**(`orders.buyer_email = {{jwt.email}}`),管理员白名单看全量。
|
|
5
|
+
|
|
6
|
+
> 为什么不用 `proxy-injected`:本模板的写操作由浏览器直接带 `Authorization` 打 REST,worker 只做公开数据的服务端渲染,因此不需要平台注入身份。若你要"SSR 首屏就带登录态",参考 `mpa-blog-ssr`(`session: proxy-injected`)。
|
|
7
|
+
|
|
8
|
+
## 目录
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
{{APP_NAME}}/
|
|
12
|
+
├── app.json # schemaVersion 2 webapp manifest(name 必须 == mount appName)
|
|
13
|
+
├── backend/index.ts # 单文件自包含 worker(Hono + React renderToString)
|
|
14
|
+
├── frontend/dist/index.html # 静态入口壳,meta refresh 到 ./shop
|
|
15
|
+
├── db/schema.sql # 幂等数据层:DDL + RLS + 10 件种子商品
|
|
16
|
+
└── README.md
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 部署前必须做的一件事:填 DEPLOY CONFIG
|
|
20
|
+
|
|
21
|
+
`backend/index.ts` 顶部有两个常量,**默认为空**;留空时 worker 会对所有请求返回 503 并说明原因(刻意设计:绝不回落到任何真实实例,避免静默读写别人的数据)。
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
const INTERNAL_BASE = ""; // 目标实例的内网基址,如 http://pxsp-<id>.pxbaas.pre.rds.aliyuncs.com:8000
|
|
25
|
+
const ANON_KEY = ""; // 单租户=实例 anon key;多租户=该租户 anon key
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- `INTERNAL_BASE` 是**内网**地址:把公网 host 里的 `http.` / `https.` 中缀去掉(等价于平台自 MR #30497903 起注入的 `SUPABASE_INTERNAL_URL`,也可直接读该 env)。公网 VIP 在沙箱内因 **egress 过滤**(非 hairpin)不可达。
|
|
29
|
+
- 多租户实例必须填**该租户**的 anon key(租户 key 自带 `tenant_id` claim,REST/Auth 据此路由,无需 `X-Tenant-Id`)。填错租户 = 读到别的租户数据。
|
|
30
|
+
- `service_role` key **严禁**出现在 worker 里(`verify_jwt=false` 的公开函数任何人可读源码)。
|
|
31
|
+
|
|
32
|
+
## 数据层
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
sed 's/__SCHEMA__/public/g' db/schema.sql # 单租户
|
|
36
|
+
sed 's/__SCHEMA__/tenant_acme/g' db/schema.sql # 多租户(租户库名)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
逐条执行(单租户可 `pxbaas db query`;多租户走 `POST {endpoint}/pg/query` + **全局** service key)。
|
|
40
|
+
⚠️ RLS 元数据永远写**中心库** `supabase._mysqlrest_rls_*`,且 `table_schema` 填上面的 `__SCHEMA__`;写进租户库里的同名表**没有任何服务读取**,写错会静默失效(等价于关闭 RLS)。
|
|
41
|
+
|
|
42
|
+
管理员账号走 GoTrue(不是 SQL):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
curl -X POST {endpoint}/auth/v1/signup -H "apikey: $ANON" -H 'Content-Type: application/json' \
|
|
46
|
+
-d '{"email":"admin@shop.demo","password":"admin-2026"}'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 部署(mount-id 方式)
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pxbaas app deploy {{APP_NAME}} --dir . # appName 必须与 app.json 的 name 一致
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
若走 Management API(curl),注意三件事:静态/后端文件要用**显式嵌套 filename**(`filename=frontend/dist/index.html`、`filename=backend/index.ts`),否则路径被压平导致 publish 报缺文件;`app.json` 的 `name` 必须等于 mount 的 `appName`,否则 `409 APP_MANIFEST_INVALID`;publish 用 `expectedGeneration` 做 CAS。
|
|
56
|
+
|
|
57
|
+
### 冷启动与 SSR 探针
|
|
58
|
+
|
|
59
|
+
manifest 声明了 `ssrProbePath: "/shop"`,publish 前平台会实探一次。本模板 import 了 `npm:react@18.3.1` + `npm:react-dom@18.3.1/server` + `npm:hono@4.6.12`,**冷启动首次请求可能数十秒**,探针可能报 `409 APP_SSR_PROBE_FAILED`(先 timeout 后 http_500)。这不是代码问题:
|
|
60
|
+
|
|
61
|
+
1. 先把同一 worker 以 legacy 函数部署到**同租户**(`functions deploy`),反复 GET 到 200(首次可能 ~60s)——沙箱按租户复用,模块解析结果会缓存;
|
|
62
|
+
2. 再 `publish`,失败则等 10~20s 重试 3~5 次。
|
|
63
|
+
|
|
64
|
+
## 验收清单
|
|
65
|
+
|
|
66
|
+
| # | 检查 | 期望 |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| 1 | `GET /apps/{mount}/` | 200 静态壳,meta refresh 到 `./shop` |
|
|
69
|
+
| 2 | `GET /apps/{mount}/shop` | 200,**10 件商品服务端渲染**,含 `data-ssr="v1"` |
|
|
70
|
+
| 3 | `GET /apps/{mount}/api/shop` | 200 同样出数据(双形态路由) |
|
|
71
|
+
| 4 | `GET /apps/{mount}/product/1` | 200,商品名 + 由 `price_cents` 换算的价格 |
|
|
72
|
+
| 5 | `GET /apps/{mount}/shop?q=耳机` | 只命中匹配商品(搜索在服务端生效) |
|
|
73
|
+
| 6 | anon 读 `products` / `orders` | 10 行 / `[]` |
|
|
74
|
+
| 7 | 买家写 `orders`(自己 / 他人 `buyer_email`) | 201 / **403 PGRST302** |
|
|
75
|
+
| 8 | 买家读 `orders` / 管理员读 `orders` | 只见自己 / 见全量 |
|
|
76
|
+
|
|
77
|
+
## 已知平台行为(非本模板缺陷)
|
|
78
|
+
|
|
79
|
+
- `capabilities.ssrLibs` 可能为空却仍能运行 React(该字段只反映构建期白名单,低报,见 Aone #87704012);能否使用以实测为准。
|
|
80
|
+
- spec-lint 会按 `ssrLibs` 白名单校验 `npm:` 导入:实例报空时 `pxbaas app deploy` 会以 `no-remote-import` 阻断本模板,需在实例侧补齐 `SSR_LIBS_LIST` 后再部署(不要长期依赖 `--skip-spec-check`)。
|