@lampdotdev/lamp 0.0.0-stage → 0.35.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.
- package/LICENSE +21 -0
- package/README.md +357 -2
- package/Resources/Devices/roku.json +410 -0
- package/Resources/Devices/xbox-one.json +367 -0
- package/Resources/GameKit/NOTICE.md +63 -0
- package/Resources/GameKit/apple/LampPage.swift +1646 -0
- package/Resources/GameKit/apple/LampPlayer.swift +46 -0
- package/Resources/GameKit/apple/LampWebApp.swift +63 -0
- package/Resources/GameKit/apple/LampWebGame.swift +87 -0
- package/Resources/GameKit/apple/Native.swift +12 -0
- package/Resources/GameKit/build.sh +55 -0
- package/Resources/GameKit/lamp_game.h +116 -0
- package/Resources/GameKit/lamp_host.h +19 -0
- package/Resources/GameKit/lamp_kit.cpp +61 -0
- package/Resources/GameKit/lamp_sound.cpp +455 -0
- package/Resources/GameKit/lamp_xbox.cpp +374 -0
- package/Resources/GameKit/libraries/HandmadeMath.h +3940 -0
- package/Resources/GameKit/libraries/cgltf.h +7240 -0
- package/Resources/GameKit/libraries/dr_mp3.h +5430 -0
- package/Resources/GameKit/libraries/dr_wav.h +9209 -0
- package/Resources/GameKit/libraries/sokol_debugtext.h +4754 -0
- package/Resources/GameKit/libraries/sokol_gfx.h +29558 -0
- package/Resources/GameKit/libraries/sokol_gl.h +4538 -0
- package/Resources/GameKit/libraries/sokol_log.h +335 -0
- package/Resources/GameKit/libraries/sokol_shape.h +1531 -0
- package/Resources/GameKit/libraries/stb_image.h +7988 -0
- package/Resources/GameKit/libraries/stb_vorbis.c +5584 -0
- package/Resources/GameKit/scaffold/Game/game.cpp +142 -0
- package/Resources/GameKit/scaffold/Game/scene.glsl +49 -0
- package/Resources/GameKit/scaffold-web/Web/game.js +31 -0
- package/Resources/GameKit/scaffold-web/Web/index.html +13 -0
- package/Resources/GameKit/scaffold-web/Web/making.js +33 -0
- package/Resources/GameKit/scaffold-web/Web/world.js +33 -0
- package/Resources/GameKit/web/lamp-game.js +3036 -0
- package/Resources/GameKit/web/lamp-phaser.js +128 -0
- package/Resources/WebHost/App.exe +0 -0
- package/Resources/WebHost/Microsoft.Web.WebView2.Core.dll +0 -0
- package/Resources/WebHost/WebView2-LICENSE.txt +27 -0
- package/Resources/WebHost/lamp_controllers.h +1506 -0
- package/Resources/WebHost/lamp_web_host.cpp +1173 -0
- package/Resources/WebLibraries/earlier.txt +318 -0
- package/Resources/WebLibraries/phaser/LICENSE.md +20 -0
- package/Resources/WebLibraries/phaser/NOTICE.md +2 -0
- package/Resources/WebLibraries/phaser/phaser.esm.min.js +1 -0
- package/Resources/WebLibraries/qrcode/LICENSE +21 -0
- package/Resources/WebLibraries/qrcode/NOTICE.md +4 -0
- package/Resources/WebLibraries/qrcode/qrcode.js +2297 -0
- package/Resources/WebLibraries/rapier/LICENSE +201 -0
- package/Resources/WebLibraries/rapier/NOTICE.md +3 -0
- package/Resources/WebLibraries/rapier/rapier.js +2 -0
- package/Resources/WebLibraries/three/LICENSE +21 -0
- package/Resources/WebLibraries/three/NOTICE.md +4 -0
- package/Resources/WebLibraries/three/addons/animation/AnimationClipCreator.js +168 -0
- package/Resources/WebLibraries/three/addons/animation/CCDIKSolver.js +607 -0
- package/Resources/WebLibraries/three/addons/controls/ArcballControls.js +3543 -0
- package/Resources/WebLibraries/three/addons/controls/DragControls.js +452 -0
- package/Resources/WebLibraries/three/addons/controls/FirstPersonControls.js +512 -0
- package/Resources/WebLibraries/three/addons/controls/FlyControls.js +384 -0
- package/Resources/WebLibraries/three/addons/controls/MapControls.js +116 -0
- package/Resources/WebLibraries/three/addons/controls/OrbitControls.js +1985 -0
- package/Resources/WebLibraries/three/addons/controls/PointerLockControls.js +264 -0
- package/Resources/WebLibraries/three/addons/controls/TrackballControls.js +1106 -0
- package/Resources/WebLibraries/three/addons/controls/TransformControls.js +2003 -0
- package/Resources/WebLibraries/three/addons/curves/CurveExtras.js +694 -0
- package/Resources/WebLibraries/three/addons/curves/NURBSCurve.js +169 -0
- package/Resources/WebLibraries/three/addons/curves/NURBSSurface.js +98 -0
- package/Resources/WebLibraries/three/addons/curves/NURBSUtils.js +532 -0
- package/Resources/WebLibraries/three/addons/curves/NURBSVolume.js +82 -0
- package/Resources/WebLibraries/three/addons/effects/AnaglyphEffect.js +274 -0
- package/Resources/WebLibraries/three/addons/effects/AsciiEffect.js +323 -0
- package/Resources/WebLibraries/three/addons/effects/OutlineEffect.js +489 -0
- package/Resources/WebLibraries/three/addons/effects/ParallaxBarrierEffect.js +155 -0
- package/Resources/WebLibraries/three/addons/effects/StereoEffect.js +91 -0
- package/Resources/WebLibraries/three/addons/environments/ColorEnvironment.js +59 -0
- package/Resources/WebLibraries/three/addons/environments/DebugEnvironment.js +102 -0
- package/Resources/WebLibraries/three/addons/environments/RoomEnvironment.js +185 -0
- package/Resources/WebLibraries/three/addons/geometries/BoxLineGeometry.js +92 -0
- package/Resources/WebLibraries/three/addons/geometries/ConvexGeometry.js +72 -0
- package/Resources/WebLibraries/three/addons/geometries/DecalGeometry.js +420 -0
- package/Resources/WebLibraries/three/addons/geometries/LoftGeometry.js +355 -0
- package/Resources/WebLibraries/three/addons/geometries/ParametricFunctions.js +100 -0
- package/Resources/WebLibraries/three/addons/geometries/ParametricGeometry.js +172 -0
- package/Resources/WebLibraries/three/addons/geometries/RoundedBoxGeometry.js +216 -0
- package/Resources/WebLibraries/three/addons/geometries/TeapotGeometry.js +689 -0
- package/Resources/WebLibraries/three/addons/geometries/TextGeometry.js +102 -0
- package/Resources/WebLibraries/three/addons/helpers/AnimationPathHelper.js +302 -0
- package/Resources/WebLibraries/three/addons/helpers/LightProbeGridHelper.js +166 -0
- package/Resources/WebLibraries/three/addons/helpers/LightProbeGridHelperWebGL.js +221 -0
- package/Resources/WebLibraries/three/addons/helpers/LightProbeHelper.js +166 -0
- package/Resources/WebLibraries/three/addons/helpers/LightProbeHelperGPU.js +102 -0
- package/Resources/WebLibraries/three/addons/helpers/OctreeHelper.js +109 -0
- package/Resources/WebLibraries/three/addons/helpers/PositionalAudioHelper.js +169 -0
- package/Resources/WebLibraries/three/addons/helpers/RapierHelper.js +59 -0
- package/Resources/WebLibraries/three/addons/helpers/RectAreaLightHelper.js +118 -0
- package/Resources/WebLibraries/three/addons/helpers/TextureHelper.js +265 -0
- package/Resources/WebLibraries/three/addons/helpers/TextureHelperGPU.js +214 -0
- package/Resources/WebLibraries/three/addons/helpers/VertexNormalsHelper.js +155 -0
- package/Resources/WebLibraries/three/addons/helpers/VertexTangentsHelper.js +133 -0
- package/Resources/WebLibraries/three/addons/helpers/ViewHelper.js +529 -0
- package/Resources/WebLibraries/three/addons/interactive/HTMLMesh.js +617 -0
- package/Resources/WebLibraries/three/addons/interactive/InteractiveGroup.js +224 -0
- package/Resources/WebLibraries/three/addons/interactive/SelectionBox.js +324 -0
- package/Resources/WebLibraries/three/addons/interactive/SelectionHelper.js +150 -0
- package/Resources/WebLibraries/three/addons/libs/fflate.module.js +2699 -0
- package/Resources/WebLibraries/three/addons/libs/lil-gui.module.min.js +8 -0
- package/Resources/WebLibraries/three/addons/libs/meshopt_decoder.module.js +196 -0
- package/Resources/WebLibraries/three/addons/libs/potpack.module.js +125 -0
- package/Resources/WebLibraries/three/addons/libs/stats.module.js +167 -0
- package/Resources/WebLibraries/three/addons/libs/tween.module.js +876 -0
- package/Resources/WebLibraries/three/addons/lighting/ClusteredLighting.js +153 -0
- package/Resources/WebLibraries/three/addons/lighting/DynamicLighting.js +82 -0
- package/Resources/WebLibraries/three/addons/lighting/LightMapper.js +295 -0
- package/Resources/WebLibraries/three/addons/lighting/LightProbeGrid.js +923 -0
- package/Resources/WebLibraries/three/addons/lighting/LightProbeGridUtils.js +107 -0
- package/Resources/WebLibraries/three/addons/lighting/LightProbeGridWebGL.js +846 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIConeTracer.js +243 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIIrradiance.js +43 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGINode.js +617 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGISceneCollector.js +793 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIVolume.js +1440 -0
- package/Resources/WebLibraries/three/addons/lights/LightProbeGenerator.js +337 -0
- package/Resources/WebLibraries/three/addons/lights/RectAreaLightTexturesLib.js +127 -0
- package/Resources/WebLibraries/three/addons/lights/RectAreaLightUniformsLib.js +40 -0
- package/Resources/WebLibraries/three/addons/lights/SunLight.js +100 -0
- package/Resources/WebLibraries/three/addons/lights/SunLightNode.js +56 -0
- package/Resources/WebLibraries/three/addons/lights/SunLightShadow.js +319 -0
- package/Resources/WebLibraries/three/addons/lights/SunShadowNode.js +312 -0
- package/Resources/WebLibraries/three/addons/lines/Line2.js +56 -0
- package/Resources/WebLibraries/three/addons/lines/LineGeometry.js +157 -0
- package/Resources/WebLibraries/three/addons/lines/LineMaterial.js +742 -0
- package/Resources/WebLibraries/three/addons/lines/LineSegments2.js +434 -0
- package/Resources/WebLibraries/three/addons/lines/LineSegmentsGeometry.js +298 -0
- package/Resources/WebLibraries/three/addons/lines/Wireframe.js +108 -0
- package/Resources/WebLibraries/three/addons/lines/WireframeGeometry2.js +49 -0
- package/Resources/WebLibraries/three/addons/lines/webgpu/Line2.js +46 -0
- package/Resources/WebLibraries/three/addons/lines/webgpu/LineSegments2.js +419 -0
- package/Resources/WebLibraries/three/addons/lines/webgpu/Wireframe.js +86 -0
- package/Resources/WebLibraries/three/addons/loaders/EXRLoader.js +3411 -0
- package/Resources/WebLibraries/three/addons/loaders/FBXLoader.js +4660 -0
- package/Resources/WebLibraries/three/addons/loaders/FontLoader.js +262 -0
- package/Resources/WebLibraries/three/addons/loaders/GLTFLoader.js +5006 -0
- package/Resources/WebLibraries/three/addons/loaders/HDRLoader.js +463 -0
- package/Resources/WebLibraries/three/addons/loaders/MTLLoader.js +601 -0
- package/Resources/WebLibraries/three/addons/loaders/OBJLoader.js +961 -0
- package/Resources/WebLibraries/three/addons/loaders/PLYLoader.js +992 -0
- package/Resources/WebLibraries/three/addons/loaders/RGBELoader.js +18 -0
- package/Resources/WebLibraries/three/addons/loaders/STLLoader.js +423 -0
- package/Resources/WebLibraries/three/addons/loaders/SVGLoader.js +3297 -0
- package/Resources/WebLibraries/three/addons/loaders/TGALoader.js +539 -0
- package/Resources/WebLibraries/three/addons/loaders/UltraHDRLoader.js +757 -0
- package/Resources/WebLibraries/three/addons/math/Capsule.js +159 -0
- package/Resources/WebLibraries/three/addons/math/ColorConverter.js +62 -0
- package/Resources/WebLibraries/three/addons/math/ColorSpaces.js +147 -0
- package/Resources/WebLibraries/three/addons/math/ConvexHull.js +1695 -0
- package/Resources/WebLibraries/three/addons/math/ImprovedNoise.js +88 -0
- package/Resources/WebLibraries/three/addons/math/Lut.js +317 -0
- package/Resources/WebLibraries/three/addons/math/MeshSurfaceSampler.js +315 -0
- package/Resources/WebLibraries/three/addons/math/OBB.js +535 -0
- package/Resources/WebLibraries/three/addons/math/Octree.js +825 -0
- package/Resources/WebLibraries/three/addons/math/SimplexNoise.js +470 -0
- package/Resources/WebLibraries/three/addons/misc/ConvexObjectBreaker.js +539 -0
- package/Resources/WebLibraries/three/addons/misc/GPUComputationRenderer.js +508 -0
- package/Resources/WebLibraries/three/addons/misc/Gyroscope.js +78 -0
- package/Resources/WebLibraries/three/addons/misc/MorphAnimMesh.js +119 -0
- package/Resources/WebLibraries/three/addons/misc/MorphBlendMesh.js +425 -0
- package/Resources/WebLibraries/three/addons/misc/ProgressiveLightMap.js +370 -0
- package/Resources/WebLibraries/three/addons/misc/ProgressiveLightMapGPU.js +322 -0
- package/Resources/WebLibraries/three/addons/misc/RollerCoaster.js +661 -0
- package/Resources/WebLibraries/three/addons/misc/Sculptor.js +1679 -0
- package/Resources/WebLibraries/three/addons/misc/SculptorMesh.js +1504 -0
- package/Resources/WebLibraries/three/addons/misc/SculptorTools.js +1180 -0
- package/Resources/WebLibraries/three/addons/misc/SculptorUtils.js +345 -0
- package/Resources/WebLibraries/three/addons/misc/TileCreasedNormalsPlugin.js +270 -0
- package/Resources/WebLibraries/three/addons/misc/TubePainter.js +611 -0
- package/Resources/WebLibraries/three/addons/misc/Volume.js +520 -0
- package/Resources/WebLibraries/three/addons/misc/VolumeSlice.js +275 -0
- package/Resources/WebLibraries/three/addons/modifiers/CurveModifier.js +374 -0
- package/Resources/WebLibraries/three/addons/modifiers/CurveModifierGPU.js +256 -0
- package/Resources/WebLibraries/three/addons/modifiers/EdgeSplitModifier.js +299 -0
- package/Resources/WebLibraries/three/addons/modifiers/TessellateModifier.js +340 -0
- package/Resources/WebLibraries/three/addons/objects/GroundedSkybox.js +69 -0
- package/Resources/WebLibraries/three/addons/objects/Lensflare.js +489 -0
- package/Resources/WebLibraries/three/addons/objects/LensflareMesh.js +376 -0
- package/Resources/WebLibraries/three/addons/objects/MarchingCubes.js +1261 -0
- package/Resources/WebLibraries/three/addons/objects/Reflector.js +390 -0
- package/Resources/WebLibraries/three/addons/objects/ReflectorForSSRPass.js +393 -0
- package/Resources/WebLibraries/three/addons/objects/Refractor.js +389 -0
- package/Resources/WebLibraries/three/addons/objects/ShadowMesh.js +130 -0
- package/Resources/WebLibraries/three/addons/objects/Sky.js +342 -0
- package/Resources/WebLibraries/three/addons/objects/SkyMesh.js +403 -0
- package/Resources/WebLibraries/three/addons/objects/Water.js +374 -0
- package/Resources/WebLibraries/three/addons/objects/Water2.js +403 -0
- package/Resources/WebLibraries/three/addons/objects/Water2Mesh.js +199 -0
- package/Resources/WebLibraries/three/addons/objects/WaterMesh.js +194 -0
- package/Resources/WebLibraries/three/addons/postprocessing/AfterimagePass.js +187 -0
- package/Resources/WebLibraries/three/addons/postprocessing/BloomPass.js +274 -0
- package/Resources/WebLibraries/three/addons/postprocessing/BokehPass.js +218 -0
- package/Resources/WebLibraries/three/addons/postprocessing/ClearPass.js +97 -0
- package/Resources/WebLibraries/three/addons/postprocessing/CubeTexturePass.js +146 -0
- package/Resources/WebLibraries/three/addons/postprocessing/DotScreenPass.js +114 -0
- package/Resources/WebLibraries/three/addons/postprocessing/EffectComposer.js +365 -0
- package/Resources/WebLibraries/three/addons/postprocessing/FXAAPass.js +40 -0
- package/Resources/WebLibraries/three/addons/postprocessing/FilmPass.js +113 -0
- package/Resources/WebLibraries/three/addons/postprocessing/GTAOPass.js +735 -0
- package/Resources/WebLibraries/three/addons/postprocessing/GlitchPass.js +177 -0
- package/Resources/WebLibraries/three/addons/postprocessing/HalftonePass.js +134 -0
- package/Resources/WebLibraries/three/addons/postprocessing/LUTPass.js +138 -0
- package/Resources/WebLibraries/three/addons/postprocessing/MaskPass.js +195 -0
- package/Resources/WebLibraries/three/addons/postprocessing/OutlinePass.js +776 -0
- package/Resources/WebLibraries/three/addons/postprocessing/OutputPass.js +148 -0
- package/Resources/WebLibraries/three/addons/postprocessing/Pass.js +191 -0
- package/Resources/WebLibraries/three/addons/postprocessing/RenderPass.js +193 -0
- package/Resources/WebLibraries/three/addons/postprocessing/RenderPixelatedPass.js +314 -0
- package/Resources/WebLibraries/three/addons/postprocessing/RenderTransitionPass.js +267 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SAOPass.js +407 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SMAAPass.js +230 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SSAARenderPass.js +313 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SSAOPass.js +527 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SSRPass.js +858 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SavePass.js +132 -0
- package/Resources/WebLibraries/three/addons/postprocessing/ShaderPass.js +135 -0
- package/Resources/WebLibraries/three/addons/postprocessing/TAARenderPass.js +243 -0
- package/Resources/WebLibraries/three/addons/postprocessing/TexturePass.js +131 -0
- package/Resources/WebLibraries/three/addons/postprocessing/UnrealBloomPass.js +541 -0
- package/Resources/WebLibraries/three/addons/shaders/ACESFilmicToneMappingShader.js +96 -0
- package/Resources/WebLibraries/three/addons/shaders/AfterimageShader.js +63 -0
- package/Resources/WebLibraries/three/addons/shaders/BasicShader.js +36 -0
- package/Resources/WebLibraries/three/addons/shaders/BleachBypassShader.js +68 -0
- package/Resources/WebLibraries/three/addons/shaders/BlendShader.js +56 -0
- package/Resources/WebLibraries/three/addons/shaders/BokehShader.js +151 -0
- package/Resources/WebLibraries/three/addons/shaders/BokehShader2.js +404 -0
- package/Resources/WebLibraries/three/addons/shaders/BrightnessContrastShader.js +62 -0
- package/Resources/WebLibraries/three/addons/shaders/ColorCorrectionShader.js +59 -0
- package/Resources/WebLibraries/three/addons/shaders/ColorifyShader.js +57 -0
- package/Resources/WebLibraries/three/addons/shaders/ConvolutionShader.js +74 -0
- package/Resources/WebLibraries/three/addons/shaders/CopyShader.js +52 -0
- package/Resources/WebLibraries/three/addons/shaders/DOFMipMapShader.js +63 -0
- package/Resources/WebLibraries/three/addons/shaders/DepthLimitedBlurShader.js +183 -0
- package/Resources/WebLibraries/three/addons/shaders/DigitalGlitch.js +104 -0
- package/Resources/WebLibraries/three/addons/shaders/DotScreenShader.js +75 -0
- package/Resources/WebLibraries/three/addons/shaders/ExposureShader.js +51 -0
- package/Resources/WebLibraries/three/addons/shaders/FXAAShader.js +298 -0
- package/Resources/WebLibraries/three/addons/shaders/FilmShader.js +73 -0
- package/Resources/WebLibraries/three/addons/shaders/FocusShader.js +94 -0
- package/Resources/WebLibraries/three/addons/shaders/FreiChenShader.js +103 -0
- package/Resources/WebLibraries/three/addons/shaders/GTAOShader.js +437 -0
- package/Resources/WebLibraries/three/addons/shaders/GammaCorrectionShader.js +52 -0
- package/Resources/WebLibraries/three/addons/shaders/HalftoneShader.js +332 -0
- package/Resources/WebLibraries/three/addons/shaders/HorizontalBlurShader.js +68 -0
- package/Resources/WebLibraries/three/addons/shaders/HorizontalTiltShiftShader.js +70 -0
- package/Resources/WebLibraries/three/addons/shaders/HueSaturationShader.js +74 -0
- package/Resources/WebLibraries/three/addons/shaders/KaleidoShader.js +65 -0
- package/Resources/WebLibraries/three/addons/shaders/LuminosityHighPassShader.js +68 -0
- package/Resources/WebLibraries/three/addons/shaders/LuminosityShader.js +54 -0
- package/Resources/WebLibraries/three/addons/shaders/MirrorShader.js +62 -0
- package/Resources/WebLibraries/three/addons/shaders/NormalMapShader.js +60 -0
- package/Resources/WebLibraries/three/addons/shaders/OutputShader.js +103 -0
- package/Resources/WebLibraries/three/addons/shaders/PoissonDenoiseShader.js +230 -0
- package/Resources/WebLibraries/three/addons/shaders/RGBShiftShader.js +61 -0
- package/Resources/WebLibraries/three/addons/shaders/SAOShader.js +201 -0
- package/Resources/WebLibraries/three/addons/shaders/SMAAShader.js +489 -0
- package/Resources/WebLibraries/three/addons/shaders/SSAOShader.js +331 -0
- package/Resources/WebLibraries/three/addons/shaders/SSRShader.js +396 -0
- package/Resources/WebLibraries/three/addons/shaders/SepiaShader.js +57 -0
- package/Resources/WebLibraries/three/addons/shaders/SobelOperatorShader.js +98 -0
- package/Resources/WebLibraries/three/addons/shaders/SubsurfaceScatteringShader.js +95 -0
- package/Resources/WebLibraries/three/addons/shaders/TechnicolorShader.js +51 -0
- package/Resources/WebLibraries/three/addons/shaders/ToonShader.js +349 -0
- package/Resources/WebLibraries/three/addons/shaders/TriangleBlurShader.js +79 -0
- package/Resources/WebLibraries/three/addons/shaders/UnpackDepthRGBAShader.js +60 -0
- package/Resources/WebLibraries/three/addons/shaders/VelocityShader.js +137 -0
- package/Resources/WebLibraries/three/addons/shaders/VerticalBlurShader.js +66 -0
- package/Resources/WebLibraries/three/addons/shaders/VerticalTiltShiftShader.js +70 -0
- package/Resources/WebLibraries/three/addons/shaders/VignetteShader.js +56 -0
- package/Resources/WebLibraries/three/addons/shaders/VolumeShader.js +285 -0
- package/Resources/WebLibraries/three/addons/shaders/WaterRefractionShader.js +106 -0
- package/Resources/WebLibraries/three/addons/textures/FlakesTexture.js +54 -0
- package/Resources/WebLibraries/three/addons/tsl/WebGLNodesHandler.js +607 -0
- package/Resources/WebLibraries/three/addons/tsl/display/AfterImageNode.js +256 -0
- package/Resources/WebLibraries/three/addons/tsl/display/AnaglyphPassNode.js +550 -0
- package/Resources/WebLibraries/three/addons/tsl/display/BilateralBlurNode.js +377 -0
- package/Resources/WebLibraries/three/addons/tsl/display/BleachBypass.js +33 -0
- package/Resources/WebLibraries/three/addons/tsl/display/BloomNode.js +599 -0
- package/Resources/WebLibraries/three/addons/tsl/display/CRT.js +150 -0
- package/Resources/WebLibraries/three/addons/tsl/display/ChromaticAberrationNode.js +163 -0
- package/Resources/WebLibraries/three/addons/tsl/display/DenoiseNode.js +354 -0
- package/Resources/WebLibraries/three/addons/tsl/display/DepthOfFieldNode.js +572 -0
- package/Resources/WebLibraries/three/addons/tsl/display/DotScreenNode.js +104 -0
- package/Resources/WebLibraries/three/addons/tsl/display/FSR1Node.js +481 -0
- package/Resources/WebLibraries/three/addons/tsl/display/FXAANode.js +357 -0
- package/Resources/WebLibraries/three/addons/tsl/display/FilmNode.js +101 -0
- package/Resources/WebLibraries/three/addons/tsl/display/GTAONode.js +727 -0
- package/Resources/WebLibraries/three/addons/tsl/display/GaussianBlurNode.js +412 -0
- package/Resources/WebLibraries/three/addons/tsl/display/GodraysNode.js +604 -0
- package/Resources/WebLibraries/three/addons/tsl/display/ImportanceSampledEnvironment.js +564 -0
- package/Resources/WebLibraries/three/addons/tsl/display/LensflareNode.js +282 -0
- package/Resources/WebLibraries/three/addons/tsl/display/Lut3DNode.js +109 -0
- package/Resources/WebLibraries/three/addons/tsl/display/MotionBlur.js +33 -0
- package/Resources/WebLibraries/three/addons/tsl/display/OITPassNode.js +341 -0
- package/Resources/WebLibraries/three/addons/tsl/display/OutlineNode.js +916 -0
- package/Resources/WebLibraries/three/addons/tsl/display/ParallaxBarrierPassNode.js +90 -0
- package/Resources/WebLibraries/three/addons/tsl/display/PixelationPassNode.js +227 -0
- package/Resources/WebLibraries/three/addons/tsl/display/RGBShiftNode.js +96 -0
- package/Resources/WebLibraries/three/addons/tsl/display/RecurrentDenoiseNode.js +812 -0
- package/Resources/WebLibraries/three/addons/tsl/display/RetroPassNode.js +263 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SMAANode.js +729 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSAAPassNode.js +344 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSAONode.js +422 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSGINode.js +698 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSRNode.js +1430 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSSNode.js +479 -0
- package/Resources/WebLibraries/three/addons/tsl/display/Sepia.js +24 -0
- package/Resources/WebLibraries/three/addons/tsl/display/Shape.js +29 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SharpenNode.js +284 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SobelOperatorNode.js +168 -0
- package/Resources/WebLibraries/three/addons/tsl/display/StereoCompositePassNode.js +190 -0
- package/Resources/WebLibraries/three/addons/tsl/display/StereoPassNode.js +117 -0
- package/Resources/WebLibraries/three/addons/tsl/display/TAAUNode.js +700 -0
- package/Resources/WebLibraries/three/addons/tsl/display/TRAANode.js +609 -0
- package/Resources/WebLibraries/three/addons/tsl/display/TemporalReprojectNode.js +1024 -0
- package/Resources/WebLibraries/three/addons/tsl/display/TransitionNode.js +141 -0
- package/Resources/WebLibraries/three/addons/tsl/display/boxBlur.js +65 -0
- package/Resources/WebLibraries/three/addons/tsl/display/depthAwareBlend.js +80 -0
- package/Resources/WebLibraries/three/addons/tsl/display/depthAwareBlur.js +63 -0
- package/Resources/WebLibraries/three/addons/tsl/display/hashBlur.js +55 -0
- package/Resources/WebLibraries/three/addons/tsl/display/lensDirt.js +30 -0
- package/Resources/WebLibraries/three/addons/tsl/display/radialBlur.js +68 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/ClusteredLightsNode.js +994 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/DynamicLightsNode.js +201 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/LightProbeGridNode.js +183 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/AmbientLightDataNode.js +67 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/DirectionalLightDataNode.js +117 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/HemisphereLightDataNode.js +105 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/PointLightDataNode.js +140 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/SpotLightDataNode.js +167 -0
- package/Resources/WebLibraries/three/addons/tsl/math/Bayer.js +84 -0
- package/Resources/WebLibraries/three/addons/tsl/math/curlNoise.js +107 -0
- package/Resources/WebLibraries/three/addons/tsl/math/voronoiNoise.js +112 -0
- package/Resources/WebLibraries/three/addons/tsl/shadows/TileShadowNode.js +542 -0
- package/Resources/WebLibraries/three/addons/tsl/shadows/TileShadowNodeHelper.js +212 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/GroundedSkybox.js +62 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/RNoise.js +51 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/Raymarching.js +70 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/SoftParticles.js +53 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/SpecularHelpers.js +302 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/TAAUtils.js +184 -0
- package/Resources/WebLibraries/three/addons/utils/BufferGeometryUtils.js +1523 -0
- package/Resources/WebLibraries/three/addons/utils/CameraUtils.js +82 -0
- package/Resources/WebLibraries/three/addons/utils/ColorUtils.js +76 -0
- package/Resources/WebLibraries/three/addons/utils/GaussianSplatUtils.js +229 -0
- package/Resources/WebLibraries/three/addons/utils/GeometryCompressionUtils.js +547 -0
- package/Resources/WebLibraries/three/addons/utils/GeometryUtils.js +226 -0
- package/Resources/WebLibraries/three/addons/utils/LDrawUtils.js +211 -0
- package/Resources/WebLibraries/three/addons/utils/SceneOptimizer.js +459 -0
- package/Resources/WebLibraries/three/addons/utils/SceneUtils.js +363 -0
- package/Resources/WebLibraries/three/addons/utils/ShadowMapViewer.js +244 -0
- package/Resources/WebLibraries/three/addons/utils/ShadowMapViewerGPU.js +233 -0
- package/Resources/WebLibraries/three/addons/utils/SkeletonUtils.js +496 -0
- package/Resources/WebLibraries/three/addons/utils/SortUtils.js +175 -0
- package/Resources/WebLibraries/three/addons/utils/UVsDebug.js +173 -0
- package/Resources/WebLibraries/three/addons/utils/WebGLTextureUtils.js +115 -0
- package/Resources/WebLibraries/three/addons/utils/WebGPUTextureUtils.js +81 -0
- package/Resources/WebLibraries/three/addons/utils/WorkerPool.js +167 -0
- package/Resources/WebLibraries/three/addons/utils/unwrapUVs.js +865 -0
- package/Resources/WebLibraries/three/three.core.js +61162 -0
- package/Resources/WebLibraries/three/three.module.js +19537 -0
- package/Resources/WebLibraries/three/three.tsl.js +41903 -0
- package/Resources/WebLibraries/three/three.webgpu.js +53714 -0
- package/Resources/skills/2d-game/SKILL.md +305 -0
- package/Resources/skills/3d-game/SKILL.md +274 -0
- package/Resources/skills/accounts/SKILL.md +41 -0
- package/Resources/skills/android-design/SKILL.md +159 -0
- package/Resources/skills/app-ai/SKILL.md +154 -0
- package/Resources/skills/app-intents/SKILL.md +31 -0
- package/Resources/skills/app-kinds/SKILL.md +122 -0
- package/Resources/skills/apple-watch/SKILL.md +43 -0
- package/Resources/skills/assets/SKILL.md +183 -0
- package/Resources/skills/building-blocks/SKILL.md +97 -0
- package/Resources/skills/device-control/SKILL.md +56 -0
- package/Resources/skills/device-integrations/SKILL.md +278 -0
- package/Resources/skills/finder-files/SKILL.md +19 -0
- package/Resources/skills/game/SKILL.md +414 -0
- package/Resources/skills/game-3d/SKILL.md +12 -0
- package/Resources/skills/game-art/SKILL.md +11 -0
- package/Resources/skills/game-feel/SKILL.md +13 -0
- package/Resources/skills/game-levels/SKILL.md +12 -0
- package/Resources/skills/game-live/SKILL.md +12 -0
- package/Resources/skills/game-physics/SKILL.md +13 -0
- package/Resources/skills/game-playtest/SKILL.md +26 -0
- package/Resources/skills/game-ui/SKILL.md +278 -0
- package/Resources/skills/github-projects/SKILL.md +59 -0
- package/Resources/skills/icloud-sync/SKILL.md +30 -0
- package/Resources/skills/iphone-hardware/SKILL.md +57 -0
- package/Resources/skills/iphone-widgets/SKILL.md +53 -0
- package/Resources/skills/lg-developer-mode/SKILL.md +152 -0
- package/Resources/skills/mac-widgets/SKILL.md +60 -0
- package/Resources/skills/maps/SKILL.md +8 -0
- package/Resources/skills/menu-bar/SKILL.md +36 -0
- package/Resources/skills/more-apps/SKILL.md +53 -0
- package/Resources/skills/phone-controllers/SKILL.md +79 -0
- package/Resources/skills/phone-pages/SKILL.md +173 -0
- package/Resources/skills/roku-developer-mode/SKILL.md +106 -0
- package/Resources/skills/scene-3d/SKILL.md +27 -0
- package/Resources/skills/scripting/SKILL.md +17 -0
- package/Resources/skills/share-extension/SKILL.md +22 -0
- package/Resources/skills/tv-design/SKILL.md +72 -0
- package/Resources/skills/web-app/SKILL.md +136 -0
- package/Resources/skills/web-game/SKILL.md +95 -0
- package/Resources/skills/web-or-native/SKILL.md +103 -0
- package/Resources/skills/wish-links/SKILL.md +156 -0
- package/THIRD_PARTY_NOTICES.md +170 -0
- package/cli/android.mjs +941 -0
- package/cli/browser.mjs +268 -0
- package/cli/calls.mjs +390 -0
- package/cli/dev.mjs +773 -0
- package/cli/devices.mjs +984 -0
- package/cli/lamp.mjs +1016 -0
- package/cli/lampapp.mjs +332 -0
- package/cli/lamps/android-host/LampPageGateway.kt +345 -0
- package/cli/lamps/android-host/app/build.gradle.kts +40 -0
- package/cli/lamps/android-host/build.gradle.kts +4 -0
- package/cli/lamps/android-host/game/AndroidManifest.xml +33 -0
- package/cli/lamps/android-host/game/MainActivity.kt +173 -0
- package/cli/lamps/android-host/gradle/libs.versions.toml +39 -0
- package/cli/lamps/android-host/gradle.properties +8 -0
- package/cli/lamps/android-host/page/AndroidManifest.xml +35 -0
- package/cli/lamps/android-host/page/MainActivity.kt +193 -0
- package/cli/lamps/android-host/settings.gradle.kts +30 -0
- package/cli/lamps/android-host/tools.json +43 -0
- package/cli/lamps/lamp-controllers.js +2 -0
- package/cli/lamps/lamp-for-games.js +6863 -0
- package/cli/lamps/lamp.js +7023 -0
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -0
- package/cli/lamps/mac-starter/APP.md +12 -0
- package/cli/lamps/mac-starter/Package.swift +8 -0
- package/cli/lamps/mac-starter/Sources/App.swift +11 -0
- package/cli/lamps/mac-starter/Sources/ContentView.swift +17 -0
- package/cli/lamps/mac-starter/Sources/LampSupport.swift +18097 -0
- package/cli/lamps/mac-starter/build.sh +336 -0
- package/cli/lamps/mac-starter/gitignore +3 -0
- package/cli/lamps/mac-starter/lamp-starter.json +6 -0
- package/cli/lamps/web-starter/APP.md +8 -0
- package/cli/lamps/web-starter/app.js +1 -0
- package/cli/lamps/web-starter/index.html +17 -0
- package/cli/lamps/web-starter/lamp-starter.json +5 -0
- package/cli/lamps/web-starter/lamp.js +7023 -0
- package/cli/lamps/web-starter/style.css +25 -0
- package/cli/link.mjs +138 -0
- package/cli/mac.mjs +621 -0
- package/cli/mdns.mjs +205 -0
- package/cli/msix.mjs +413 -0
- package/cli/network.mjs +209 -0
- package/cli/outside.mjs +140 -0
- package/cli/page.js +165 -0
- package/cli/place.mjs +298 -0
- package/cli/png.mjs +184 -0
- package/cli/share.mjs +202 -0
- package/cli/skill/SKILL.md +255 -0
- package/cli/skills.mjs +71 -0
- package/cli/state.mjs +173 -0
- package/cli/update.mjs +57 -0
- package/package.json +20 -4
- package/site/lib/quota.js +59 -0
- package/site/lib/shared-wishes.js +996 -0
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
import { inflateRawSync } from "node:zlib";
|
|
4
|
+
import { redis, reserve } from "./quota.js";
|
|
5
|
+
|
|
6
|
+
// A wish shared as a link (lamp.dev/w/<id>): the idea, as the sharer confirmed it in Lamp. Its name, its line,
|
|
7
|
+
// its words and, if they kept them, a picture of it and its icon; whoever opens the link makes their own version from
|
|
8
|
+
// the words. If the sharer chose to, the wish itself goes too, as the shared file Lamp makes (`<name>.lampapp`,
|
|
9
|
+
// `createFileHandler`): then the page offers that exact wish to anyone with Lamp, which reviews it before it opens, as
|
|
10
|
+
// any shared file. Lamp's side is Sources/Lamp/WishLink.swift; the two must agree on the limits.
|
|
11
|
+
|
|
12
|
+
/** The most each piece may hold. Lamp holds the sharer to the same numbers, so a longer one is refused, not cut. */
|
|
13
|
+
export const LIMITS = {
|
|
14
|
+
name: 60,
|
|
15
|
+
/** Who shared it, as they want to be named on its page: their name, or nothing. */
|
|
16
|
+
by: 60,
|
|
17
|
+
line: 140,
|
|
18
|
+
words: 2000,
|
|
19
|
+
/** The changes Lamp suggests for the wish (`RemixSuggestions`), each a line; the page offers them to remix with. */
|
|
20
|
+
ideas: 3,
|
|
21
|
+
idea: 100,
|
|
22
|
+
/** The questions the box under the app asks in turn, where people send its maker ideas ("Found a bug?"). */
|
|
23
|
+
asks: 3,
|
|
24
|
+
ask: 80,
|
|
25
|
+
/** A playing page's shape, its width over its height (`shape`), within these; or "fill", a page that takes any. */
|
|
26
|
+
shape: [0.2, 5],
|
|
27
|
+
/** What's new in each version the sharer released (`releases`), newest first: the most kept, the lines each holds
|
|
28
|
+
* and the characters in a line. Lamp writes the lines; the sharer can change them before they go. */
|
|
29
|
+
releases: 30,
|
|
30
|
+
releaseLines: 5,
|
|
31
|
+
releaseLine: 120,
|
|
32
|
+
picture: 1_500_000,
|
|
33
|
+
icon: 300_000,
|
|
34
|
+
/** Pixels on a side, for both pictures. */
|
|
35
|
+
side: 4096,
|
|
36
|
+
};
|
|
37
|
+
/** Both pictures as base64 and the words, with room for the JSON around them. */
|
|
38
|
+
export const MAX_BODY_BYTES = 2_600_000;
|
|
39
|
+
|
|
40
|
+
/** The wish itself, a shared file: sent in parts, since a request to a function holds 4.5 MB at most. */
|
|
41
|
+
export const FILE_LIMITS = { part: 3_000_000, parts: 16, total: 48_000_000 };
|
|
42
|
+
|
|
43
|
+
// Enough to share and reshare a few wishes in an hour; overall, a ceiling on what a flood could store in a day.
|
|
44
|
+
export const QUOTAS = [
|
|
45
|
+
{ name: "share-ip", perIP: true, limit: 10, seconds: 3600 },
|
|
46
|
+
{ name: "share-hour", limit: 200, seconds: 3600 },
|
|
47
|
+
{ name: "share-day", limit: 1000, seconds: 86400 },
|
|
48
|
+
];
|
|
49
|
+
export const DELETE_QUOTAS = [{ name: "unshare-ip", perIP: true, limit: 60, seconds: 3600 }];
|
|
50
|
+
/**
|
|
51
|
+
* Changing a link that's out (its secret shown to the site) and putting its file and page together: kept apart from
|
|
52
|
+
* new links, so someone working on a wish can update it again and again without running out of shares. An update
|
|
53
|
+
* counts up to three here (the link, its file, its page). Overall, a ceiling on what a flood could rewrite in a day.
|
|
54
|
+
*/
|
|
55
|
+
export const UPDATE_QUOTAS = [
|
|
56
|
+
{ name: "update-ip", perIP: true, limit: 90, seconds: 3600 },
|
|
57
|
+
{ name: "update-day", limit: 5000, seconds: 86400 },
|
|
58
|
+
];
|
|
59
|
+
/** A file's parts: a few files' worth an hour from one address. Putting the parts together counts as an update. */
|
|
60
|
+
export const PART_QUOTAS = [
|
|
61
|
+
// A share sends the file and its page, each in up to `FILE_LIMITS.parts` parts.
|
|
62
|
+
{ name: "part-ip", perIP: true, limit: 200, seconds: 3600 },
|
|
63
|
+
{ name: "part-day", limit: 3000, seconds: 86400 },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/** Ids are short enough to read in a link and long enough (about 58 bits) that nobody finds one by guessing. */
|
|
67
|
+
const ALPHABET = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
68
|
+
export const ID_LENGTH = 10;
|
|
69
|
+
export const newID = () => Array.from({ length: ID_LENGTH }, () => ALPHABET[randomInt(ALPHABET.length)]).join("");
|
|
70
|
+
export const isID = (id) => typeof id === "string" && id.length === ID_LENGTH && [...id].every((c) => ALPHABET.includes(c));
|
|
71
|
+
|
|
72
|
+
/** The secret that lets the sharer's Lamp change or remove the link. Only its hash is kept here. */
|
|
73
|
+
export const newSecret = () => randomBytes(32).toString("base64url");
|
|
74
|
+
const hashSecret = (secret) => createHash("sha256").update(secret).digest("hex");
|
|
75
|
+
function secretMatches(secret, hash) {
|
|
76
|
+
if (typeof secret !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(secret) || typeof hash !== "string") return false;
|
|
77
|
+
const a = Buffer.from(hashSecret(secret), "hex"), b = Buffer.from(hash, "hex");
|
|
78
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Words as they're kept: no control characters (a line break only where lines are allowed), trimmed. Null when
|
|
82
|
+
* there's nothing, or more than `max` characters. */
|
|
83
|
+
export function tidy(value, max, { lines = false } = {}) {
|
|
84
|
+
if (typeof value !== "string") return null;
|
|
85
|
+
const spaced = value.replace(/\r\n?/g, "\n").replace(lines ? /\t/g : /[\t\n]/g, " ");
|
|
86
|
+
const kept = [...spaced].filter((c) => (lines && c === "\n") || !/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(c) || c === "\u200d").join("");
|
|
87
|
+
const text = (lines ? kept.replace(/\n{3,}/g, "\n\n") : kept.replace(/\s+/g, " ")).trim();
|
|
88
|
+
return text && characters(text) <= max ? text : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Characters as people see them (and as Swift counts them on Lamp's card): an accented letter or a family emoji
|
|
92
|
+
* made of several code points is one. */
|
|
93
|
+
const graphemes = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
94
|
+
export const characters = (text) => { let n = 0; for (const _ of graphemes.segment(text)) n++; return n; };
|
|
95
|
+
|
|
96
|
+
/** What a picture is, read from its first bytes: only a PNG or a JPEG of a sensible size. */
|
|
97
|
+
export function imageInfo(bytes) {
|
|
98
|
+
if (bytes.length > 24 && bytes.readUInt32BE(0) === 0x89504e47 && bytes.readUInt32BE(4) === 0x0d0a1a0a && bytes.toString("latin1", 12, 16) === "IHDR") {
|
|
99
|
+
return { type: "image/png", extension: "png", width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
100
|
+
}
|
|
101
|
+
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
102
|
+
// The frame's size is in its start-of-frame segment, found by walking the segments before it.
|
|
103
|
+
let at = 2;
|
|
104
|
+
while (at + 9 < bytes.length) {
|
|
105
|
+
if (bytes[at] !== 0xff) return null;
|
|
106
|
+
const marker = bytes[at + 1];
|
|
107
|
+
if (marker === 0xff) { at += 1; continue; }
|
|
108
|
+
const length = bytes.readUInt16BE(at + 2);
|
|
109
|
+
if (length < 2) return null;
|
|
110
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
|
111
|
+
return { type: "image/jpeg", extension: "jpg", width: bytes.readUInt16BE(at + 7), height: bytes.readUInt16BE(at + 5) };
|
|
112
|
+
}
|
|
113
|
+
at += 2 + length;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function picture(value, max, { square = false } = {}) {
|
|
120
|
+
if (value == null) return { bytes: null };
|
|
121
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length > Math.ceil(max / 3) * 4) return null;
|
|
122
|
+
const bytes = Buffer.from(value, "base64");
|
|
123
|
+
const info = imageInfo(bytes);
|
|
124
|
+
if (!info || bytes.length > max || !info.width || !info.height || info.width > LIMITS.side || info.height > LIMITS.side) return null;
|
|
125
|
+
if (square && (info.type !== "image/png" || info.width !== info.height)) return null;
|
|
126
|
+
return { bytes, ...info };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A release as it's kept: when it went out (an ISO time, never more than a day ahead) and its lines. Null when off. */
|
|
130
|
+
function release(value) {
|
|
131
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !["date", "lines"].includes(key))) return null;
|
|
132
|
+
const time = typeof value.date === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value.date) ? Date.parse(value.date) : NaN;
|
|
133
|
+
if (!Number.isFinite(time) || time > Date.now() + 86_400_000) return null;
|
|
134
|
+
if (!Array.isArray(value.lines) || !value.lines.length || value.lines.length > LIMITS.releaseLines) return null;
|
|
135
|
+
const lines = value.lines.map((line) => tidy(line, LIMITS.releaseLine));
|
|
136
|
+
return lines.includes(null) ? null : { date: new Date(time).toISOString(), lines };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What a share sends, checked. Null when anything is off. `releases` is undefined when the share didn't say (a Lamp
|
|
140
|
+
* from before them), so a change keeps the ones the link has. */
|
|
141
|
+
export function readShare(body) {
|
|
142
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
143
|
+
const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon"];
|
|
144
|
+
if (Object.keys(body).some((key) => !known.includes(key))) return null;
|
|
145
|
+
const name = tidy(body.name, LIMITS.name);
|
|
146
|
+
const words = tidy(body.words, LIMITS.words, { lines: true });
|
|
147
|
+
const line = body.line == null || body.line === "" ? "" : tidy(body.line, LIMITS.line);
|
|
148
|
+
const by = body.by == null || body.by === "" ? "" : tidy(body.by, LIMITS.by);
|
|
149
|
+
const ideas = body.ideas == null ? [] : Array.isArray(body.ideas) && body.ideas.length <= LIMITS.ideas
|
|
150
|
+
? body.ideas.map((idea) => tidy(idea, LIMITS.idea)) : [null];
|
|
151
|
+
const asks = body.asks == null ? [] : Array.isArray(body.asks) && body.asks.length <= LIMITS.asks
|
|
152
|
+
? body.asks.map((ask) => tidy(ask, LIMITS.ask)) : [null];
|
|
153
|
+
const shape = body.shape == null ? null : body.shape === "fill" ? "fill"
|
|
154
|
+
: typeof body.shape === "number" && body.shape >= LIMITS.shape[0] && body.shape <= LIMITS.shape[1] ? Math.round(body.shape * 10000) / 10000 : undefined;
|
|
155
|
+
const releases = body.releases === undefined ? undefined : Array.isArray(body.releases) && body.releases.length <= LIMITS.releases
|
|
156
|
+
? body.releases.map(release) : [null];
|
|
157
|
+
const shot = picture(body.picture, LIMITS.picture);
|
|
158
|
+
const icon = picture(body.icon, LIMITS.icon, { square: true });
|
|
159
|
+
if (!name || !words || line == null || by == null || ideas.includes(null) || asks.includes(null) || shape === undefined || releases?.includes(null) || !shot || !icon) return null;
|
|
160
|
+
return { name, by, line, words, ideas, asks, shape, releases: releases?.sort((a, b) => b.date.localeCompare(a.date)),
|
|
161
|
+
picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** What anyone may see of a shared wish: never its secret. */
|
|
165
|
+
export const shown = (record) => record && ({
|
|
166
|
+
id: record.id, name: record.name, by: record.by ?? "", line: record.line, words: record.words, ideas: record.ideas ?? [], asks: record.asks ?? [], shape: record.shape ?? null,
|
|
167
|
+
releases: record.releases ?? [], picture: record.picture, icon: record.icon, file: record.file ?? null, updated: record.updated,
|
|
168
|
+
page: record.page ? { version: record.page.version } : null,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/** Every file kept for a record: its pictures, the wish's file and any parts of one still being sent. */
|
|
172
|
+
const blobsOf = (record) => [record?.picture?.url, record?.icon?.url, record?.file?.url, ...partBlobs(record?.parts),
|
|
173
|
+
...pageBlobs(record), ...partBlobs(record?.pageParts)].filter(Boolean);
|
|
174
|
+
/** A file's parts so far, and those set aside (`spare`): replaced by a part sent again, or left by an earlier count. */
|
|
175
|
+
const partBlobs = (parts) => [...Object.values(parts?.urls ?? {}), ...(parts?.spare ?? [])];
|
|
176
|
+
/** The page's files, and the version before it, kept for whoever was still playing that when this one went up. */
|
|
177
|
+
const pageBlobs = (record) => [...Object.values(record?.page?.files ?? {}), ...Object.values(record?.previousPage?.files ?? {})];
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* What anyone's Lamp asks of a link it follows (`GET /api/wishes?id=`): the format of the answer, and the version of
|
|
181
|
+
* the wish's file, which goes up each time the sharer sends it again (Release Update), with the file's address. Version 0
|
|
182
|
+
* and no file for a link that carries none, and who it's by as the page names them (empty for nobody), which Lamp keeps
|
|
183
|
+
* with what it pulls in from the copy so the change can credit them. Never the secret or anything a follower doesn't need.
|
|
184
|
+
*/
|
|
185
|
+
export const FOLLOW_FORMAT = 1;
|
|
186
|
+
export const latest = (record) => ({
|
|
187
|
+
format: FOLLOW_FORMAT, id: record.id, name: record.name, by: record.by ?? "",
|
|
188
|
+
version: record.file ? (record.file.version ?? 1) : 0, file: record.file?.url ?? null,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The wish's name as its link spells it ("Falling Blocks" → "falling-blocks"), so a link says what it's for. Only for
|
|
193
|
+
* reading: the id finds the wish (`wishIDFrom`), so a link with an old name or none still works. Empty for a name with
|
|
194
|
+
* no letters or digits in plain Latin.
|
|
195
|
+
*/
|
|
196
|
+
export const slugFor = (name) => (name ?? "").normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
|
|
197
|
+
.replace(/[^A-Za-z0-9]+/g, "-").toLowerCase().replace(/^-+/, "").slice(0, 48).replace(/-+$/, "");
|
|
198
|
+
|
|
199
|
+
/** The link people pass around: the wish's id, then its name after a dash (`/w/JP2R862d45-falling-blocks`). */
|
|
200
|
+
export const linkFor = (origin, id, name) => {
|
|
201
|
+
const slug = slugFor(name);
|
|
202
|
+
return `${origin}/w/${id}${slug ? `-${slug}` : ""}`;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** The wish's id in its link's last part, whatever name follows it after a dash. Null when it doesn't start with one. */
|
|
206
|
+
export const wishIDFrom = (part) => {
|
|
207
|
+
const id = typeof part === "string" ? part.slice(0, ID_LENGTH) : null;
|
|
208
|
+
return isID(id) && (part.length === ID_LENGTH || part[ID_LENGTH] === "-") ? id : null;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// MARK: Where shared wishes are kept
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The record of each shared wish in Upstash (the store the site's quotas already use), its pictures in Vercel
|
|
215
|
+
* Blob, public at addresses with a random part. Preview deployments keep theirs apart from production's.
|
|
216
|
+
*/
|
|
217
|
+
export function cloudStore(env, fetcher = fetch) {
|
|
218
|
+
const db = redis(env);
|
|
219
|
+
const scope = env.VERCEL_ENV === "production" ? "production" : "preview";
|
|
220
|
+
const key = (id) => `lamp:shared-wish:{${scope}}:${id}`;
|
|
221
|
+
const ideasKey = (id) => `lamp:shared-wish:{${scope}}:ideas:${id}`;
|
|
222
|
+
const command = async (args) => {
|
|
223
|
+
const response = await fetcher(db.url, {
|
|
224
|
+
method: "POST", redirect: "error", signal: AbortSignal.timeout(3000),
|
|
225
|
+
headers: { Authorization: `Bearer ${db.token}`, "Content-Type": "application/json" },
|
|
226
|
+
body: JSON.stringify(args),
|
|
227
|
+
});
|
|
228
|
+
// Upstash's own words for it (a limit reached, say), which name no key: it gives them with a status that isn't
|
|
229
|
+
// OK too, so the answer is read either way, and the status alone says it when there are none.
|
|
230
|
+
let body = null;
|
|
231
|
+
try { body = await response.json(); } catch {}
|
|
232
|
+
if (body?.error) throw new Error(`Store unavailable (${response.status}): ${String(body.error).slice(0, 200)}`);
|
|
233
|
+
if (!response.ok || !body) throw new Error(`Store unavailable (${response.status})`);
|
|
234
|
+
return body.result;
|
|
235
|
+
};
|
|
236
|
+
// A store connected now gives the project its id (BLOB_STORE_ID), and @vercel/blob signs in with the deployment's
|
|
237
|
+
// own OIDC token; an older connection gave a read-write token instead.
|
|
238
|
+
const auth = env.BLOB_READ_WRITE_TOKEN ? { token: env.BLOB_READ_WRITE_TOKEN } : {};
|
|
239
|
+
return {
|
|
240
|
+
limiter: { ...db, prefix: `lamp:shared-wish:{${scope}}:quota` },
|
|
241
|
+
async get(id) {
|
|
242
|
+
const text = await command(["GET", key(id)]);
|
|
243
|
+
return typeof text === "string" ? JSON.parse(text) : null;
|
|
244
|
+
},
|
|
245
|
+
async create(record) { return (await command(["SET", key(record.id), JSON.stringify(record), "NX"])) === "OK"; },
|
|
246
|
+
/** False when the record is gone (taken down meanwhile): nothing was written. */
|
|
247
|
+
async replace(record) { return (await command(["SET", key(record.id), JSON.stringify(record), "XX"])) === "OK"; },
|
|
248
|
+
/** Writes `after` only while the record is still `before` as read: false when it changed meanwhile, or is gone. */
|
|
249
|
+
async swap(before, after) {
|
|
250
|
+
return (await command(["EVAL", "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('SET', KEYS[1], ARGV[2]) end return false",
|
|
251
|
+
"1", key(after.id), JSON.stringify(before), JSON.stringify(after)])) === "OK";
|
|
252
|
+
},
|
|
253
|
+
async remove(id) { await command(["DEL", key(id), ideasKey(id)]); },
|
|
254
|
+
/** Keeps an idea for the link, numbered in the order they came: null when the link already holds `IDEA_LIMITS.kept`. */
|
|
255
|
+
async addIdea(id, idea) {
|
|
256
|
+
// Its number is its field, given as it's kept, so two at once never share one. The cap counts those still open
|
|
257
|
+
// (`open`, which an outcome counts down), so a maker who deals with them makes room; and, far above it, all kept.
|
|
258
|
+
const n = await command(["EVAL", `if tonumber(redis.call('HGET', KEYS[1], 'open') or '0') >= tonumber(ARGV[1]) then return false end
|
|
259
|
+
if redis.call('HLEN', KEYS[1]) > 10 * tonumber(ARGV[1]) then return false end
|
|
260
|
+
redis.call('HINCRBY', KEYS[1], 'open', 1)
|
|
261
|
+
local n = redis.call('HINCRBY', KEYS[1], 'next', 1)
|
|
262
|
+
redis.call('HSET', KEYS[1], tostring(n), ARGV[2])
|
|
263
|
+
return n`, "1", ideasKey(id), String(IDEA_LIMITS.kept), JSON.stringify(idea)]);
|
|
264
|
+
return Number.isInteger(n) ? n : null;
|
|
265
|
+
},
|
|
266
|
+
async ideas(id) {
|
|
267
|
+
const flat = (await command(["HGETALL", ideasKey(id)])) ?? [];
|
|
268
|
+
const ideas = [];
|
|
269
|
+
for (let i = 0; i < flat.length; i += 2) if (flat[i] !== "next" && flat[i] !== "open") ideas.push({ ...JSON.parse(flat[i + 1]), n: Number(flat[i]) });
|
|
270
|
+
return ideas.sort((a, b) => a.n - b.n);
|
|
271
|
+
},
|
|
272
|
+
/** True when this call took it away: two at once never both do. */
|
|
273
|
+
async removeIdea(id, n) { return (await command(["HDEL", ideasKey(id), String(n)])) === 1; },
|
|
274
|
+
/**
|
|
275
|
+
* Gives idea `n` its outcome ("in" or "dismissed"), in one step, and answers it as it was, its address included, to
|
|
276
|
+
* whichever call gave it: null when it's gone or already has one, so it's told once. The address goes with this step.
|
|
277
|
+
*/
|
|
278
|
+
async finishIdea(id, n, status, at) {
|
|
279
|
+
const text = await command(["EVAL", `local text = redis.call('HGET', KEYS[1], ARGV[1])
|
|
280
|
+
if not text then return false end
|
|
281
|
+
local idea = cjson.decode(text)
|
|
282
|
+
if idea.status then return false end
|
|
283
|
+
idea.status = ARGV[2]
|
|
284
|
+
idea.done = ARGV[3]
|
|
285
|
+
idea.email = nil
|
|
286
|
+
redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(idea))
|
|
287
|
+
redis.call('HINCRBY', KEYS[1], 'open', -1)
|
|
288
|
+
return text`, "1", ideasKey(id), String(n), status, at]);
|
|
289
|
+
return typeof text === "string" ? { ...JSON.parse(text), n } : null;
|
|
290
|
+
},
|
|
291
|
+
async putImage(id, name, image) {
|
|
292
|
+
const { put } = await import("@vercel/blob");
|
|
293
|
+
const blob = await put(`${scope}/w/${id}/${name}.${image.extension}`, image.bytes, {
|
|
294
|
+
access: "public", contentType: image.type, addRandomSuffix: true, cacheControlMaxAge: 300, ...auth,
|
|
295
|
+
});
|
|
296
|
+
return blob.url;
|
|
297
|
+
},
|
|
298
|
+
async removeImages(urls) {
|
|
299
|
+
if (!urls.length) return;
|
|
300
|
+
const { del } = await import("@vercel/blob");
|
|
301
|
+
await del(urls, auth);
|
|
302
|
+
},
|
|
303
|
+
// A part of the wish's file, or the file once its parts are together: public at an address with a random part, as
|
|
304
|
+
// the pictures are, and only ever read back from the store's own host.
|
|
305
|
+
async putFile(id, name, bytes) {
|
|
306
|
+
const { put } = await import("@vercel/blob");
|
|
307
|
+
const blob = await put(`${scope}/w/${id}/${name}`, bytes, {
|
|
308
|
+
access: "public", contentType: "application/octet-stream", addRandomSuffix: true, cacheControlMaxAge: 300, ...auth,
|
|
309
|
+
});
|
|
310
|
+
return blob.url;
|
|
311
|
+
},
|
|
312
|
+
// One file of a playable page, under the version's own random folder: read back by the play handler, never served
|
|
313
|
+
// from Blob itself (which hands HTML out as a download).
|
|
314
|
+
async putPageFile(id, token, path, bytes) {
|
|
315
|
+
const { put } = await import("@vercel/blob");
|
|
316
|
+
const blob = await put(`${scope}/w/${id}/page/${token}/${path}`, bytes, {
|
|
317
|
+
// Kept as what it is, so a browser sent straight to a big one (`DIRECT_FILE`) runs or reads it.
|
|
318
|
+
access: "public", contentType: pageType(path), addRandomSuffix: false, cacheControlMaxAge: 300, ...auth,
|
|
319
|
+
});
|
|
320
|
+
return blob.url;
|
|
321
|
+
},
|
|
322
|
+
async readFile(url) {
|
|
323
|
+
const address = new URL(url);
|
|
324
|
+
if (address.protocol !== "https:" || !address.hostname.endsWith(".public.blob.vercel-storage.com")) throw new Error("Not the store's");
|
|
325
|
+
const response = await fetcher(url, { redirect: "error", signal: AbortSignal.timeout(10000) });
|
|
326
|
+
if (!response.ok) throw new Error("Part unavailable");
|
|
327
|
+
return Buffer.from(await response.arrayBuffer());
|
|
328
|
+
},
|
|
329
|
+
// A page's file as it comes, passed on without being held whole: a big one (a game's map data, tens of MB) is more
|
|
330
|
+
// than a function's answer may hold at once, and more than it can read in the time a whole read is given.
|
|
331
|
+
async openFile(url) {
|
|
332
|
+
const address = new URL(url);
|
|
333
|
+
if (address.protocol !== "https:" || !address.hostname.endsWith(".public.blob.vercel-storage.com")) throw new Error("Not the store's");
|
|
334
|
+
const waiting = new AbortController();
|
|
335
|
+
const timer = setTimeout(() => waiting.abort(), 10000);
|
|
336
|
+
try {
|
|
337
|
+
const response = await fetcher(url, { redirect: "error", signal: waiting.signal });
|
|
338
|
+
if (!response.ok || !response.body) throw new Error(`Part unavailable (${response.status})`);
|
|
339
|
+
return { body: response.body, length: response.headers.get("content-length") };
|
|
340
|
+
} finally { clearTimeout(timer); }
|
|
341
|
+
},
|
|
342
|
+
// The page and its card are cached by Vercel's CDN, tagged with the wish (see `cacheTags`): a change or a removal
|
|
343
|
+
// takes them out at once rather than when they'd have run out.
|
|
344
|
+
async forget(id) {
|
|
345
|
+
try {
|
|
346
|
+
const { dangerouslyDeleteByTag } = await import("@vercel/functions");
|
|
347
|
+
await dangerouslyDeleteByTag(`wish-${id}`);
|
|
348
|
+
} catch {}
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Kept in memory, for `npm run dev` and the tests: pictures as data: URLs, so nothing else needs serving. */
|
|
354
|
+
export function memoryStore() {
|
|
355
|
+
const records = new Map(), pages = new Map(), ideas = new Map();
|
|
356
|
+
return {
|
|
357
|
+
limiter: null,
|
|
358
|
+
records,
|
|
359
|
+
async get(id) { return records.has(id) ? structuredClone(records.get(id)) : null; },
|
|
360
|
+
async create(record) { if (records.has(record.id)) return false; records.set(record.id, structuredClone(record)); return true; },
|
|
361
|
+
async replace(record) { if (!records.has(record.id)) return false; records.set(record.id, structuredClone(record)); return true; },
|
|
362
|
+
async swap(before, after) {
|
|
363
|
+
if (!records.has(after.id) || JSON.stringify(records.get(after.id)) !== JSON.stringify(before)) return false;
|
|
364
|
+
records.set(after.id, structuredClone(after));
|
|
365
|
+
return true;
|
|
366
|
+
},
|
|
367
|
+
async remove(id) { records.delete(id); ideas.delete(id); },
|
|
368
|
+
async addIdea(id, idea) {
|
|
369
|
+
const kept = ideas.get(id) ?? { next: 0, all: new Map() };
|
|
370
|
+
if ([...kept.all.values()].filter((one) => !one.status).length >= IDEA_LIMITS.kept) return null;
|
|
371
|
+
const n = ++kept.next;
|
|
372
|
+
kept.all.set(n, { ...idea, n });
|
|
373
|
+
ideas.set(id, kept);
|
|
374
|
+
return n;
|
|
375
|
+
},
|
|
376
|
+
async ideas(id) { return [...(ideas.get(id)?.all.values() ?? [])].map((idea) => structuredClone(idea)).sort((a, b) => a.n - b.n); },
|
|
377
|
+
async removeIdea(id, n) { return ideas.get(id)?.all.delete(n) ?? false; },
|
|
378
|
+
async finishIdea(id, n, status, at) {
|
|
379
|
+
const idea = ideas.get(id)?.all.get(n);
|
|
380
|
+
if (!idea || idea.status) return null;
|
|
381
|
+
const before = structuredClone(idea);
|
|
382
|
+
Object.assign(idea, { status, done: at });
|
|
383
|
+
delete idea.email;
|
|
384
|
+
return before;
|
|
385
|
+
},
|
|
386
|
+
async putImage(_id, _name, image) { return `data:${image.type};base64,${image.bytes.toString("base64")}`; },
|
|
387
|
+
async removeImages() {},
|
|
388
|
+
async putFile(_id, _name, bytes) { return `data:application/octet-stream;base64,${bytes.toString("base64")}`; },
|
|
389
|
+
async putPageFile(id, token, path, bytes) { const at = `memory:${id}/${token}/${path}`; pages.set(at, Buffer.from(bytes)); return at; },
|
|
390
|
+
async readFile(url) { return url.startsWith("memory:") ? pages.get(url) : Buffer.from(url.slice(url.indexOf(",") + 1), "base64"); },
|
|
391
|
+
async openFile(url) { const bytes = await this.readFile(url); return { body: bytes, length: String(bytes.length) }; },
|
|
392
|
+
async forget() {},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export const cacheTags = (id) => ({ "Vercel-Cache-Tag": `wish-${id}` });
|
|
397
|
+
|
|
398
|
+
// MARK: The API Lamp talks to
|
|
399
|
+
|
|
400
|
+
const reply = (status, body, headers = {}) => (body == null
|
|
401
|
+
? new Response(null, { status, headers: { "Cache-Control": "no-store", ...headers } })
|
|
402
|
+
: Response.json(body, { status, headers: { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...headers } }));
|
|
403
|
+
|
|
404
|
+
async function readBody(request) {
|
|
405
|
+
const length = request.headers.get("content-length");
|
|
406
|
+
if (length && (!/^\d+$/.test(length) || Number(length) > MAX_BODY_BYTES)) throw new RangeError();
|
|
407
|
+
if (!request.body) return "";
|
|
408
|
+
const reader = request.body.getReader();
|
|
409
|
+
const chunks = [];
|
|
410
|
+
let size = 0;
|
|
411
|
+
try {
|
|
412
|
+
while (true) {
|
|
413
|
+
const { value, done } = await reader.read();
|
|
414
|
+
if (done) break;
|
|
415
|
+
size += value.byteLength;
|
|
416
|
+
if (size > MAX_BODY_BYTES) { await reader.cancel(); throw new RangeError(); }
|
|
417
|
+
chunks.push(value);
|
|
418
|
+
}
|
|
419
|
+
} finally { reader.releaseLock(); }
|
|
420
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const bearer = (request) => /^Bearer (\S+)$/.exec(request.headers.get("authorization") || "")?.[1];
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* POST shares a wish and answers its id, link and secret. PUT ?id= with the secret replaces what the link shows,
|
|
427
|
+
* keeping the link; DELETE ?id= with the secret takes it down. Lamp is the only caller: a browser on another site
|
|
428
|
+
* is refused, and nothing a request holds is logged.
|
|
429
|
+
*/
|
|
430
|
+
export function createShareHandler({ env = process.env, fetcher = fetch, store, limited = true, now = () => new Date() } = {}) {
|
|
431
|
+
return async (request) => {
|
|
432
|
+
const method = request.method;
|
|
433
|
+
if (!["GET", "POST", "PUT", "DELETE"].includes(method)) return reply(405, { error: "method_not_allowed" }, { Allow: "GET, POST, PUT, DELETE" });
|
|
434
|
+
const url = new URL(request.url);
|
|
435
|
+
const origin = request.headers.get("origin");
|
|
436
|
+
if (origin && origin !== url.origin) return reply(403, { error: "origin_not_allowed" });
|
|
437
|
+
const id = url.searchParams.get("id");
|
|
438
|
+
if (method !== "POST" && !isID(id)) return reply(404, { error: "not_found" });
|
|
439
|
+
// Asked by the Lamps that follow this link: public, as the page is, and cheap (one read, no quota).
|
|
440
|
+
if (method === "GET") {
|
|
441
|
+
let kept;
|
|
442
|
+
try { kept = store ?? cloudStore(env, fetcher); } catch { return reply(503, { error: "service_unavailable" }); }
|
|
443
|
+
try {
|
|
444
|
+
const record = await kept.get(id);
|
|
445
|
+
return record ? reply(200, latest(record)) : reply(404, { error: "not_found" });
|
|
446
|
+
} catch { return reply(503, { error: "service_unavailable" }); }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
let share = null;
|
|
450
|
+
if (method !== "DELETE") {
|
|
451
|
+
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(request.headers.get("content-type") || "")) return reply(415, { error: "json_required" });
|
|
452
|
+
try { share = readShare(JSON.parse(await readBody(request))); }
|
|
453
|
+
catch (error) { return reply(error instanceof RangeError ? 413 : 400, { error: "invalid_wish" }); }
|
|
454
|
+
if (!share) return reply(400, { error: "invalid_wish" });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let kept;
|
|
458
|
+
try { kept = store ?? cloudStore(env, fetcher); } catch { return reply(503, { error: "service_unavailable" }); }
|
|
459
|
+
if (limited) {
|
|
460
|
+
// Vercel overwrites these headers with the client's address. Only a keyed hash of it is kept, in expiring counters.
|
|
461
|
+
const ip = (request.headers.get("x-vercel-forwarded-for") || request.headers.get("x-forwarded-for") || "").trim();
|
|
462
|
+
if (!isIP(ip) || !kept.limiter) return reply(503, { error: "service_unavailable" });
|
|
463
|
+
let retry;
|
|
464
|
+
try { retry = await reserve(kept.limiter, ip, method === "DELETE" ? DELETE_QUOTAS : method === "PUT" ? UPDATE_QUOTAS : QUOTAS, fetcher); }
|
|
465
|
+
catch { return reply(503, { error: "service_unavailable" }); }
|
|
466
|
+
if (retry > 0) return reply(429, { error: "rate_limited" }, { "Retry-After": String(retry) });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let uploaded = [];
|
|
470
|
+
try {
|
|
471
|
+
const earlier = method === "POST" ? null : await kept.get(id);
|
|
472
|
+
if (method !== "POST" && (!earlier || !secretMatches(bearer(request), earlier.secret))) return reply(404, { error: "not_found" });
|
|
473
|
+
const oldImages = [earlier?.picture?.url, earlier?.icon?.url].filter(Boolean);
|
|
474
|
+
if (method === "DELETE") {
|
|
475
|
+
await kept.remove(id);
|
|
476
|
+
await kept.removeImages(blobsOf(earlier));
|
|
477
|
+
await kept.forget(id);
|
|
478
|
+
return reply(204);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const wishID = method === "POST" ? newID() : id;
|
|
482
|
+
// Pictures go up before the record that names them; until it's kept they're taken back on any failure, or
|
|
483
|
+
// they'd stay public with nothing that leads to them.
|
|
484
|
+
uploaded = [];
|
|
485
|
+
const put = async (name, image) => { const at = await kept.putImage(wishID, name, image); uploaded.push(at); return at; };
|
|
486
|
+
const picture = share.picture && { url: await put("picture", share.picture), width: share.picture.width, height: share.picture.height };
|
|
487
|
+
const icon = share.icon && { url: await put("icon", share.icon) };
|
|
488
|
+
const time = now().toISOString();
|
|
489
|
+
const words = { name: share.name, by: share.by, line: share.line, words: share.words, ideas: share.ideas, asks: share.asks, shape: share.shape,
|
|
490
|
+
...(share.releases ? { releases: share.releases } : {}), picture: picture || null, icon: icon || null };
|
|
491
|
+
if (method === "PUT") {
|
|
492
|
+
await kept.replace({ ...earlier, ...words, updated: time });
|
|
493
|
+
uploaded = [];
|
|
494
|
+
await kept.removeImages(oldImages);
|
|
495
|
+
await kept.forget(id);
|
|
496
|
+
return reply(200, { id, url: linkFor(url.origin, id, words.name) });
|
|
497
|
+
}
|
|
498
|
+
const secret = newSecret();
|
|
499
|
+
if (!(await kept.create({ id: wishID, ...words, secret: hashSecret(secret), created: time, updated: time }))) {
|
|
500
|
+
throw new Error("Id taken");
|
|
501
|
+
}
|
|
502
|
+
uploaded = [];
|
|
503
|
+
return reply(201, { id: wishID, url: linkFor(url.origin, wishID, words.name), secret });
|
|
504
|
+
} catch {
|
|
505
|
+
try { await kept.removeImages(uploaded); } catch {}
|
|
506
|
+
return reply(503, { error: "service_unavailable" });
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// MARK: Ideas for the maker
|
|
512
|
+
|
|
513
|
+
/** What someone playing a shared wish may send its maker: their idea, and an address to hear back at if they want. */
|
|
514
|
+
export const IDEA_LIMITS = { text: 1000, email: 200, kept: 500, asked: 20, keptDone: 90 * 86400_000 };
|
|
515
|
+
// A visitor sends a handful an hour; a link takes a few hundred a day; overall, a ceiling on a flood.
|
|
516
|
+
export const IDEA_QUOTAS = [
|
|
517
|
+
{ name: "idea-ip", perIP: true, limit: 12, seconds: 3600 },
|
|
518
|
+
{ name: "idea-ip-day", perIP: true, limit: 40, seconds: 86400 },
|
|
519
|
+
{ name: "idea-day", limit: 5000, seconds: 86400 },
|
|
520
|
+
];
|
|
521
|
+
const EMAIL = /^[^\s@<>()",;:]+@[^\s@<>()",;:]+\.[^\s@<>()",;:]+$/;
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Writes to someone who asked to hear back that their idea is in, through Resend (`RESEND_API_KEY`, from
|
|
525
|
+
* `LAMP_IDEAS_FROM`, an address on a domain Resend has verified). False, and nothing sent, without them or when Resend
|
|
526
|
+
* refuses: the idea still goes. Their address is used for this one email and goes with the idea.
|
|
527
|
+
*/
|
|
528
|
+
export async function tellItsIn(idea, record, link, { env = process.env, fetcher = fetch, once } = {}) {
|
|
529
|
+
const key = env.RESEND_API_KEY, from = env.LAMP_IDEAS_FROM;
|
|
530
|
+
if (!key || !from || !idea.email) return false;
|
|
531
|
+
const maker = record.by?.trim() || "Whoever made it";
|
|
532
|
+
const text = `${maker} added your idea to ${record.name}:\n\n${idea.text}\n\nPlay it: ${link}\n\n` +
|
|
533
|
+
"You asked to hear when it's in, so this is the only email about it.";
|
|
534
|
+
try {
|
|
535
|
+
const response = await fetcher("https://api.resend.com/emails", {
|
|
536
|
+
method: "POST", redirect: "error", signal: AbortSignal.timeout(5000),
|
|
537
|
+
// Resend sends one email for a key however often it's asked within a day.
|
|
538
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...(once ? { "Idempotency-Key": once } : {}) },
|
|
539
|
+
body: JSON.stringify({ from, to: [idea.email], subject: `Your idea is in ${record.name}`, text }),
|
|
540
|
+
});
|
|
541
|
+
return response.ok;
|
|
542
|
+
} catch { return false; }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** An idea as sent from a link's page, or null: its words, and an address to hear back at only if it looks like one. */
|
|
546
|
+
export function readIdea(body) {
|
|
547
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
548
|
+
const text = tidy(body.text, IDEA_LIMITS.text, { lines: true });
|
|
549
|
+
if (!text) return null;
|
|
550
|
+
if (body.email == null || body.email === "") return { text };
|
|
551
|
+
const email = tidy(body.email, IDEA_LIMITS.email);
|
|
552
|
+
return email && EMAIL.test(email) ? { text, email } : null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Ideas for a link's maker (`/api/wish-ideas?id=`). POST, from the link's page once the sender confirms it, keeps one:
|
|
557
|
+
* what someone using it would change, and an address to hear when it's in, if they gave one. GET, with the link's secret, answers them for Lamp to show the maker: never the keys or the addresses, only
|
|
558
|
+
* whether someone wants to hear back. DELETE ?n= with the secret lets one go: with `built=1` it's in, and the site writes
|
|
559
|
+
* to whoever asked (`tellItsIn`); without, it's dismissed. Either way it's kept a while with its outcome, for the
|
|
560
|
+
* sender's own browser, which kept the number and a token the POST answered, to ask after it on a later visit (GET
|
|
561
|
+
* `?id=&mine=<n>.<token>,…`, answering only those). Never logged, never on the page for anyone else, gone with the link.
|
|
562
|
+
*/
|
|
563
|
+
export function createIdeasHandler({ env = process.env, fetcher = fetch, store, limited = true, now = () => new Date() } = {}) {
|
|
564
|
+
return async (request) => {
|
|
565
|
+
const method = request.method;
|
|
566
|
+
if (!["GET", "POST", "DELETE"].includes(method)) return reply(405, { error: "method_not_allowed" }, { Allow: "GET, POST, DELETE" });
|
|
567
|
+
const url = new URL(request.url);
|
|
568
|
+
const origin = request.headers.get("origin");
|
|
569
|
+
if (origin && origin !== url.origin) return reply(403, { error: "origin_not_allowed" });
|
|
570
|
+
const id = url.searchParams.get("id");
|
|
571
|
+
if (!isID(id)) return reply(404, { error: "not_found" });
|
|
572
|
+
|
|
573
|
+
let idea = null;
|
|
574
|
+
if (method === "POST") {
|
|
575
|
+
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(request.headers.get("content-type") || "")) return reply(415, { error: "json_required" });
|
|
576
|
+
try { idea = readIdea(JSON.parse(await readBody(request))); }
|
|
577
|
+
catch (error) { return reply(error instanceof RangeError ? 413 : 400, { error: "invalid_idea" }); }
|
|
578
|
+
if (!idea) return reply(400, { error: "invalid_idea" });
|
|
579
|
+
}
|
|
580
|
+
const n = method === "DELETE" ? Number(url.searchParams.get("n")) : null;
|
|
581
|
+
if (n !== null && (!Number.isInteger(n) || n < 1)) return reply(400, { error: "invalid_idea" });
|
|
582
|
+
|
|
583
|
+
let kept;
|
|
584
|
+
try { kept = store ?? cloudStore(env, fetcher); } catch { return reply(503, { error: "service_unavailable" }); }
|
|
585
|
+
if (limited && method === "POST") {
|
|
586
|
+
const ip = (request.headers.get("x-vercel-forwarded-for") || request.headers.get("x-forwarded-for") || "").trim();
|
|
587
|
+
if (!isIP(ip) || !kept.limiter) return reply(503, { error: "service_unavailable" });
|
|
588
|
+
let retry;
|
|
589
|
+
try { retry = await reserve(kept.limiter, ip, IDEA_QUOTAS, fetcher); }
|
|
590
|
+
catch { return reply(503, { error: "service_unavailable" }); }
|
|
591
|
+
if (retry > 0) return reply(429, { error: "rate_limited" }, { "Retry-After": String(retry) });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
const record = await kept.get(id);
|
|
596
|
+
if (!record) return reply(404, { error: "not_found" });
|
|
597
|
+
if (method === "POST") {
|
|
598
|
+
const token = randomBytes(12).toString("base64url");
|
|
599
|
+
const number = await kept.addIdea(id, { ...idea, token, at: now().toISOString() });
|
|
600
|
+
return number ? reply(201, { n: number, token }) : reply(429, { error: "full" });
|
|
601
|
+
}
|
|
602
|
+
// The sender's own ideas, asked by number and token from their browser: what became of each, nothing else.
|
|
603
|
+
const mine = method === "GET" ? url.searchParams.get("mine") : null;
|
|
604
|
+
if (mine != null) {
|
|
605
|
+
const asked = new Map(mine.split(",").slice(0, IDEA_LIMITS.asked).map((one) => one.split("."))
|
|
606
|
+
.filter(([n, token]) => /^\d{1,6}$/.test(n ?? "") && /^[A-Za-z0-9_-]{16}$/.test(token ?? "")).map(([n, token]) => [Number(n), token]));
|
|
607
|
+
const all = await kept.ideas(id);
|
|
608
|
+
return reply(200, { ideas: all.filter((one) => one.token && asked.get(one.n) === one.token)
|
|
609
|
+
.map((one) => ({ n: one.n, status: one.status ?? "open", ...(one.done ? { done: one.done } : {}) })) });
|
|
610
|
+
}
|
|
611
|
+
if (!secretMatches(bearer(request), record.secret)) return reply(404, { error: "not_found" });
|
|
612
|
+
// The maker sees the ideas still open, and whether someone wants to hear back, never their address: the site
|
|
613
|
+
// writes to them. Those with an outcome are kept a while for their senders, then go.
|
|
614
|
+
if (method === "GET") {
|
|
615
|
+
const all = await kept.ideas(id);
|
|
616
|
+
const old = all.filter((one) => one.done && now() - new Date(one.done) > IDEA_LIMITS.keptDone);
|
|
617
|
+
for (const one of old) await kept.removeIdea(id, one.n);
|
|
618
|
+
return reply(200, { ideas: all.filter((one) => !one.status).map(({ email, token: _token, ...shown }) => ({ ...shown, ...(email ? { wantsNews: true } : {}) })) });
|
|
619
|
+
}
|
|
620
|
+
// An outcome, given once: only the call that gave it writes to the sender, so two at once, or one tried again,
|
|
621
|
+
// send one email.
|
|
622
|
+
const built = url.searchParams.get("built") === "1";
|
|
623
|
+
const was = await kept.finishIdea(id, n, built ? "in" : "dismissed", now().toISOString());
|
|
624
|
+
const notified = built && was?.email ? await tellItsIn(was, record, linkFor(url.origin, id, record.name), { env, fetcher, once: `idea-${id}-${n}` }) : false;
|
|
625
|
+
return reply(200, { notified });
|
|
626
|
+
} catch { return reply(503, { error: "service_unavailable" }); }
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** A shared wish for the page and its card, or null when there's none by that id (or it can't be read now). */
|
|
631
|
+
export async function findWish(id, { env = process.env, fetcher = fetch, store } = {}) {
|
|
632
|
+
if (!isID(id)) return null;
|
|
633
|
+
try { return shown(await (store ?? cloudStore(env, fetcher)).get(id)); }
|
|
634
|
+
catch { return undefined; }
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Takes a shared wish down without its secret: for the Lamp team, when a link shows what it mustn't. */
|
|
638
|
+
export async function takeDown(id, { env = process.env, fetcher = fetch } = {}) {
|
|
639
|
+
if (!isID(id)) return false;
|
|
640
|
+
const kept = cloudStore(env, fetcher);
|
|
641
|
+
const record = await kept.get(id);
|
|
642
|
+
if (!record) return false;
|
|
643
|
+
await kept.remove(id);
|
|
644
|
+
await kept.removeImages(blobsOf(record));
|
|
645
|
+
await kept.forget(id);
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// MARK: The wish's page
|
|
650
|
+
|
|
651
|
+
/** The most a playable page may hold: as many bytes as the wish's file, in at most this many files. */
|
|
652
|
+
// A page is sent packed within `FILE_LIMITS.total`; what it unpacks to may be more, since a big 3D game's data packs small.
|
|
653
|
+
export const PAGE_LIMITS = { files: 3000, total: 128_000_000 };
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* A zip's files, read from its central directory: stored or deflated, never a link, a folder, an absolute path or one
|
|
657
|
+
* that steps up. Null for anything else, or past the limits, counting what they unpack to.
|
|
658
|
+
*/
|
|
659
|
+
export function readZip(bytes, limits = PAGE_LIMITS) {
|
|
660
|
+
if (bytes.length < 22 || bytes.readUInt32LE(0) !== 0x04034b50) return null;
|
|
661
|
+
const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
|
662
|
+
if (end < Math.max(0, bytes.length - 65_557)) return null;
|
|
663
|
+
const count = bytes.readUInt16LE(end + 10), size = bytes.readUInt32LE(end + 12), offset = bytes.readUInt32LE(end + 16);
|
|
664
|
+
if (offset + size > end || count > limits.files) return null;
|
|
665
|
+
const files = [];
|
|
666
|
+
let total = 0;
|
|
667
|
+
for (let at = offset, n = 0; n < count; n++) {
|
|
668
|
+
if (at + 46 > end || bytes.readUInt32LE(at) !== 0x02014b50) return null;
|
|
669
|
+
const method = bytes.readUInt16LE(at + 10), packed = bytes.readUInt32LE(at + 20), unpacked = bytes.readUInt32LE(at + 24);
|
|
670
|
+
const nameLength = bytes.readUInt16LE(at + 28), extra = bytes.readUInt16LE(at + 30), comment = bytes.readUInt16LE(at + 32);
|
|
671
|
+
const external = bytes.readUInt32LE(at + 38), local = bytes.readUInt32LE(at + 42);
|
|
672
|
+
const path = bytes.toString("utf8", at + 46, at + 46 + nameLength);
|
|
673
|
+
at += 46 + nameLength + extra + comment;
|
|
674
|
+
if (path.endsWith("/")) continue;
|
|
675
|
+
// A link (its Unix mode in the high bits) never goes up.
|
|
676
|
+
if (((external >>> 16) & 0o170000) === 0o120000) return null;
|
|
677
|
+
if (!path || path.startsWith("/") || path.includes("\\") || path.split("/").some((part) => part === ".." || part === "" || part === ".")) return null;
|
|
678
|
+
if (path.split("/").some((part) => part.startsWith(".")) || path.startsWith("__MACOSX/")) continue;
|
|
679
|
+
total += unpacked;
|
|
680
|
+
if (total > limits.total) return null;
|
|
681
|
+
if (local + 30 > bytes.length || bytes.readUInt32LE(local) !== 0x04034b50) return null;
|
|
682
|
+
const start = local + 30 + bytes.readUInt16LE(local + 26) + bytes.readUInt16LE(local + 28);
|
|
683
|
+
const data = bytes.subarray(start, start + packed);
|
|
684
|
+
let content;
|
|
685
|
+
try { content = method === 0 ? Buffer.from(data) : method === 8 ? inflateRawSync(data, { maxOutputLength: Math.max(1, unpacked) }) : null; }
|
|
686
|
+
catch { return null; }
|
|
687
|
+
if (!content || content.length !== unpacked) return null;
|
|
688
|
+
files.push({ path, bytes: content });
|
|
689
|
+
}
|
|
690
|
+
return files;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** A playable page: a zip with index.html at its root (`WishLink.playablePage` in Lamp). Null for anything else. */
|
|
694
|
+
export function readPage(bytes) {
|
|
695
|
+
const files = readZip(bytes);
|
|
696
|
+
return files && files.some((file) => file.path === "index.html") ? files : null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** What a page's file is served as, by its extension: anything unknown as bytes, never guessed from what's in it. */
|
|
700
|
+
export function pageType(path) {
|
|
701
|
+
const types = {
|
|
702
|
+
html: "text/html; charset=utf-8", js: "text/javascript; charset=utf-8", mjs: "text/javascript; charset=utf-8",
|
|
703
|
+
css: "text/css; charset=utf-8", json: "application/json", map: "application/json", txt: "text/plain; charset=utf-8",
|
|
704
|
+
svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
|
|
705
|
+
avif: "image/avif", ico: "image/x-icon", wasm: "application/wasm", mp3: "audio/mpeg", ogg: "audio/ogg", wav: "audio/wav",
|
|
706
|
+
m4a: "audio/mp4", mp4: "video/mp4", webm: "video/webm", woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", otf: "font/otf",
|
|
707
|
+
glb: "model/gltf-binary", gltf: "model/gltf+json", bin: "application/octet-stream", xml: "application/xml",
|
|
708
|
+
};
|
|
709
|
+
return types[path.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream";
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Where a link's page plays: a host of its own under the play host (`LAMP_PLAY_ORIGIN`, say https://play.lamp.dev),
|
|
714
|
+
* one for each link, so no shared page can read what another saved in the browser, nor anything of lamp.dev's. The id
|
|
715
|
+
* is written in hex, since a host name doesn't keep the id's upper and lower case apart. Null without a play host.
|
|
716
|
+
*/
|
|
717
|
+
export function playOrigin(base, id) {
|
|
718
|
+
if (!base || !isID(id)) return null;
|
|
719
|
+
const url = new URL(base);
|
|
720
|
+
return `${url.protocol}//${Buffer.from(id).toString("hex")}.${url.host}`;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The page a link plays, one file at a time (`/w/<id>/play/<version>/<path>`): only on that link's own play host
|
|
725
|
+
* (`playOrigin`), only into a frame on the site's own pages, and only the version the link has now. Each version's
|
|
726
|
+
* files never change, so the CDN keeps them.
|
|
727
|
+
*/
|
|
728
|
+
/**
|
|
729
|
+
* A page's file this big or bigger is played straight from the store instead of through the play host: the CDN keeps
|
|
730
|
+
* nothing over 20 MB from a function, so every visit would pass it through again. Only for a page kept with each
|
|
731
|
+
* file's own type (`page.sizes`), since a browser won't run a script the store calls anything else. Never a page's
|
|
732
|
+
* HTML, which the store hands out as a download.
|
|
733
|
+
*/
|
|
734
|
+
export const DIRECT_FILE = 16_000_000;
|
|
735
|
+
|
|
736
|
+
/** How long a link's record is remembered for serving its page's files, and how many links at once. */
|
|
737
|
+
export const PLAY_MEMORY = { ms: 60_000, links: 200 };
|
|
738
|
+
|
|
739
|
+
export function createPlayHandler({ env = process.env, fetcher = fetch, store, anyHost = false, direct = DIRECT_FILE, now = () => Date.now() } = {}) {
|
|
740
|
+
// A link's record, remembered a minute per instance: a game asks for hundreds of files as it's played, and reading
|
|
741
|
+
// the store for each used up its allowance of requests in a day, which took every link down. A version the memory
|
|
742
|
+
// doesn't have (a release since) is read afresh; a link taken down plays on here for that minute at most.
|
|
743
|
+
// Files asked for together while it's being read share that one read; one that fails is let go, so the next asks again.
|
|
744
|
+
const remembered = new Map(), reading = new Map();
|
|
745
|
+
const recordOf = async (id, version) => {
|
|
746
|
+
const kept = remembered.get(id);
|
|
747
|
+
if (kept && now() - kept.at < PLAY_MEMORY.ms && (kept.record?.page?.version === version || kept.record?.previousPage?.version === version)) return kept.record;
|
|
748
|
+
if (reading.has(id)) return reading.get(id);
|
|
749
|
+
const read = (async () => {
|
|
750
|
+
try {
|
|
751
|
+
const record = await (store ?? cloudStore(env, fetcher)).get(id);
|
|
752
|
+
remembered.delete(id);
|
|
753
|
+
if (remembered.size >= PLAY_MEMORY.links) remembered.delete(remembered.keys().next().value);
|
|
754
|
+
remembered.set(id, { record, at: now() });
|
|
755
|
+
return record;
|
|
756
|
+
} finally { reading.delete(id); }
|
|
757
|
+
})();
|
|
758
|
+
reading.set(id, read);
|
|
759
|
+
return read;
|
|
760
|
+
};
|
|
761
|
+
// Every answer the play host gives, errors too (a browser settles an origin's cluster on its first answer): the
|
|
762
|
+
// play host is the same site as lamp.dev, so without this Chrome runs the framed page in the site's own process,
|
|
763
|
+
// sharing its memory with the page around it and other lamp.dev tabs. A big game ran out there and crashed the tab,
|
|
764
|
+
// while the same game opened on its own didn't. An agent cluster of its own lets Chrome give it a process of its own.
|
|
765
|
+
const isolated = (response) => { response.headers.set("Origin-Agent-Cluster", "?1"); return response; };
|
|
766
|
+
return async (request) => isolated(await play(request));
|
|
767
|
+
async function play(request) {
|
|
768
|
+
if (request.method !== "GET" && request.method !== "HEAD") return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
|
|
769
|
+
const url = new URL(request.url);
|
|
770
|
+
const id = url.searchParams.get("id"), version = Number(url.searchParams.get("v"));
|
|
771
|
+
const own = playOrigin(env.LAMP_PLAY_ORIGIN, id);
|
|
772
|
+
if (!anyHost && (!own || url.host !== new URL(own).host)) return new Response(null, { status: 404 });
|
|
773
|
+
const path = url.searchParams.get("path") || "index.html";
|
|
774
|
+
const missing = () => new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
|
|
775
|
+
if (!isID(id) || !Number.isInteger(version)) return missing();
|
|
776
|
+
let record;
|
|
777
|
+
// Why it failed goes to the log, never the link's id or anything it holds: a 503 here once took every link down
|
|
778
|
+
// with nothing to say which store refused.
|
|
779
|
+
try { record = await recordOf(id, version); } catch (error) {
|
|
780
|
+
console.error("wish play: the record couldn't be read:", error?.name, error?.message);
|
|
781
|
+
return new Response(null, { status: 503 });
|
|
782
|
+
}
|
|
783
|
+
// A page's other .html files are asked for by their clean names (the site's `cleanUrls` redirects to them), and a
|
|
784
|
+
// folder's index.html by the folder, with its slash or without (a rewrite may not keep it): without, the page is
|
|
785
|
+
// given its folder as its base, so its own files resolve there and not one folder up.
|
|
786
|
+
// The page as it is, or the version before it, for a game that was open on that when this one went up.
|
|
787
|
+
const served = record?.page?.version === version ? record.page : record?.previousPage?.version === version ? record.previousPage : null;
|
|
788
|
+
const files = served?.files ?? {};
|
|
789
|
+
const folder = path.replace(/\/$/, "");
|
|
790
|
+
const name = path.endsWith("/") ? (files[`${path}index.html`] ? `${path}index.html` : null)
|
|
791
|
+
: files[path] ? path : files[`${path}.html`] ? `${path}.html` : files[`${folder}/index.html`] ? `${folder}/index.html` : null;
|
|
792
|
+
const rebased = name === `${folder}/index.html` && !path.endsWith("/");
|
|
793
|
+
const at = name ? files[name] : null;
|
|
794
|
+
if (!at) return missing();
|
|
795
|
+
// A big file: the browser goes to it in the store, which serves it to any page. Each version's files never change.
|
|
796
|
+
// Not for a worker's own script, which a browser only takes from the page's own origin, nor from a store a browser
|
|
797
|
+
// can't reach (the dev server's memory). Kept apart by what asked (`Vary`), so a worker never gets the redirect.
|
|
798
|
+
const worker = /worker$/i.test(request.headers.get("sec-fetch-dest") ?? "");
|
|
799
|
+
if (!rebased && !worker && at.startsWith("https://") && !/\.html?$/i.test(name) && (served.sizes?.[name] ?? 0) >= direct) {
|
|
800
|
+
return new Response(null, { status: 302, headers: { Location: at, Vary: "Sec-Fetch-Dest", "Cache-Control": "public, max-age=300, s-maxage=31536000", ...cacheTags(id) } });
|
|
801
|
+
}
|
|
802
|
+
// Passed on as it comes from the store; only a folder's index.html given its base is read whole, to change it.
|
|
803
|
+
const kept = store ?? cloudStore(env, fetcher);
|
|
804
|
+
let bytes, length;
|
|
805
|
+
try {
|
|
806
|
+
if (rebased) {
|
|
807
|
+
const base = `<base href="/w/${id}/play/${version}/${folder.split("/").map(encodeURIComponent).join("/")}/">`;
|
|
808
|
+
const html = (await kept.readFile(at)).toString("utf8");
|
|
809
|
+
const head = html.search(/<head[^>]*>/i);
|
|
810
|
+
bytes = Buffer.from(head < 0 ? base + html : html.replace(/<head[^>]*>/i, (tag) => tag + base));
|
|
811
|
+
} else if (request.method !== "HEAD") {
|
|
812
|
+
({ body: bytes, length } = await kept.openFile(at));
|
|
813
|
+
}
|
|
814
|
+
} catch (error) {
|
|
815
|
+
console.error("wish play: a file couldn't be read:", error?.name, error?.message);
|
|
816
|
+
return new Response(null, { status: 503 });
|
|
817
|
+
}
|
|
818
|
+
const site = env.LAMP_SITE_ORIGIN || (anyHost ? url.origin : "https://lamp.dev");
|
|
819
|
+
return new Response(request.method === "HEAD" ? null : bytes, {
|
|
820
|
+
headers: {
|
|
821
|
+
"Content-Type": pageType(name), "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer",
|
|
822
|
+
...(length ? { "Content-Length": length } : {}),
|
|
823
|
+
// Framed only by the link's page; frame-ancestors takes the place of the site's X-Frame-Options.
|
|
824
|
+
"Content-Security-Policy": `frame-ancestors ${site}`,
|
|
825
|
+
"Cache-Control": "public, max-age=300, s-maxage=31536000", ...cacheTags(id),
|
|
826
|
+
},
|
|
827
|
+
});
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// MARK: The wish itself
|
|
832
|
+
|
|
833
|
+
/** A shared file Lamp made: a zip whose listing names lamp-app.json, the file every shared wish has at its root. */
|
|
834
|
+
export function isWishFile(bytes) {
|
|
835
|
+
if (bytes.length < 22 || bytes.readUInt32LE(0) !== 0x04034b50) return false;
|
|
836
|
+
// The central directory's entries, from the end record backwards: only a name, not what's in it, is read here; Lamp
|
|
837
|
+
// reads and reviews the rest when the file is opened.
|
|
838
|
+
const from = Math.max(0, bytes.length - 65_557);
|
|
839
|
+
const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
|
840
|
+
if (end < from) return false;
|
|
841
|
+
const size = bytes.readUInt32LE(end + 12), offset = bytes.readUInt32LE(end + 16);
|
|
842
|
+
if (offset + size > end) return false;
|
|
843
|
+
for (let at = offset; at + 46 <= offset + size;) {
|
|
844
|
+
if (bytes.readUInt32LE(at) !== 0x02014b50) return false;
|
|
845
|
+
const nameLength = bytes.readUInt16LE(at + 28), extra = bytes.readUInt16LE(at + 30), comment = bytes.readUInt16LE(at + 32);
|
|
846
|
+
if (bytes.toString("utf8", at + 46, at + 46 + nameLength) === "lamp-app.json") return true;
|
|
847
|
+
at += 46 + nameLength + extra + comment;
|
|
848
|
+
}
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** The file's name where it's downloaded: the wish's name, made safe for an address. */
|
|
853
|
+
export const fileName = (name) => `${(name.normalize("NFKD").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").toLowerCase() || "app").slice(0, 60)}.lampapp`;
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* The wish's own file, for a link that has one, only with the link's secret. PUT ?id=&part=<n>&of=<count> sends one part
|
|
857
|
+
* (raw bytes, at most `FILE_LIMITS.part`); POST ?id= puts the parts together, checks that it's a file Lamp made and
|
|
858
|
+
* within `FILE_LIMITS.total`, and gives it to the link in place of any it had; DELETE ?id= takes it away. The parts
|
|
859
|
+
* are taken away once they're together, or with the link.
|
|
860
|
+
*/
|
|
861
|
+
export function createFileHandler({ env = process.env, fetcher = fetch, store, limited = true, now = () => new Date() } = {}) {
|
|
862
|
+
return async (request) => {
|
|
863
|
+
const method = request.method;
|
|
864
|
+
if (!["PUT", "POST", "DELETE"].includes(method)) return reply(405, { error: "method_not_allowed" }, { Allow: "PUT, POST, DELETE" });
|
|
865
|
+
const url = new URL(request.url);
|
|
866
|
+
const origin = request.headers.get("origin");
|
|
867
|
+
if (origin && origin !== url.origin) return reply(403, { error: "origin_not_allowed" });
|
|
868
|
+
const id = url.searchParams.get("id");
|
|
869
|
+
if (!isID(id)) return reply(404, { error: "not_found" });
|
|
870
|
+
// The wish's file, or (`what=page`) its playable page, sent and put together the same way.
|
|
871
|
+
const page = url.searchParams.get("what") === "page";
|
|
872
|
+
const partsKey = page ? "pageParts" : "parts";
|
|
873
|
+
|
|
874
|
+
let part = null;
|
|
875
|
+
if (method === "PUT") {
|
|
876
|
+
const n = Number(url.searchParams.get("part")), of = Number(url.searchParams.get("of"));
|
|
877
|
+
if (!Number.isInteger(of) || of < 1 || of > FILE_LIMITS.parts || !Number.isInteger(n) || n < 0 || n >= of) return reply(400, { error: "invalid_part" });
|
|
878
|
+
if (!/^application\/octet-stream$/i.test(request.headers.get("content-type") || "")) return reply(415, { error: "bytes_required" });
|
|
879
|
+
const length = request.headers.get("content-length");
|
|
880
|
+
if (length && (!/^\d+$/.test(length) || Number(length) > FILE_LIMITS.part)) return reply(413, { error: "part_too_large" });
|
|
881
|
+
const bytes = Buffer.from(await request.arrayBuffer());
|
|
882
|
+
if (!bytes.length || bytes.length > FILE_LIMITS.part) return reply(bytes.length ? 413 : 400, { error: "invalid_part" });
|
|
883
|
+
part = { n, of, bytes };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
let kept;
|
|
887
|
+
try { kept = store ?? cloudStore(env, fetcher); } catch { return reply(503, { error: "service_unavailable" }); }
|
|
888
|
+
if (limited) {
|
|
889
|
+
const ip = (request.headers.get("x-vercel-forwarded-for") || request.headers.get("x-forwarded-for") || "").trim();
|
|
890
|
+
if (!isIP(ip) || !kept.limiter) return reply(503, { error: "service_unavailable" });
|
|
891
|
+
let retry;
|
|
892
|
+
try { retry = await reserve(kept.limiter, ip, method === "PUT" ? PART_QUOTAS : method === "POST" ? UPDATE_QUOTAS : DELETE_QUOTAS, fetcher); }
|
|
893
|
+
catch { return reply(503, { error: "service_unavailable" }); }
|
|
894
|
+
if (retry > 0) return reply(429, { error: "rate_limited" }, { "Retry-After": String(retry) });
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
let uploaded = [];
|
|
898
|
+
try {
|
|
899
|
+
const record = await kept.get(id);
|
|
900
|
+
if (!record || !secretMatches(bearer(request), record.secret)) return reply(404, { error: "not_found" });
|
|
901
|
+
if (method === "PUT") {
|
|
902
|
+
const at = await kept.putFile(id, `${page ? "page-part" : "part"}-${part.n}`, part.bytes);
|
|
903
|
+
uploaded.push(at);
|
|
904
|
+
// The part goes into the record as it is when it's written, never as it was read before the part went up: Lamp
|
|
905
|
+
// sends a part again when its connection drops, and the first try can still be storing then, so another part
|
|
906
|
+
// may have gone in meanwhile, which a write from the old read would lose. What it replaces is only set aside,
|
|
907
|
+
// never removed here: putting the parts together may be reading it right now. It goes when they've been put
|
|
908
|
+
// together, or with the link.
|
|
909
|
+
for (let attempt = 0; ; attempt++) {
|
|
910
|
+
const current = attempt ? await kept.get(id) : record;
|
|
911
|
+
// Taken down while the part went up: nothing leads to it, so it goes now (the catch below takes it away).
|
|
912
|
+
if (!current || !secretMatches(bearer(request), current.secret)) throw new Error("Gone");
|
|
913
|
+
// A new count starts a new file: parts of an earlier one that never came together are set aside.
|
|
914
|
+
const earlier = current[partsKey];
|
|
915
|
+
const same = earlier?.of === part.of;
|
|
916
|
+
const urls = same ? earlier.urls : {};
|
|
917
|
+
const spare = [...(earlier?.spare ?? []), ...(same ? [] : Object.values(earlier?.urls ?? {})),
|
|
918
|
+
...(urls[part.n] && urls[part.n] !== at ? [urls[part.n]] : [])];
|
|
919
|
+
if (await kept.swap(current, { ...current, [partsKey]: { of: part.of, urls: { ...urls, [part.n]: at }, spare } })) break;
|
|
920
|
+
if (attempt === 4) throw new Error("Busy");
|
|
921
|
+
}
|
|
922
|
+
uploaded = [];
|
|
923
|
+
return reply(204);
|
|
924
|
+
}
|
|
925
|
+
if (method === "DELETE") {
|
|
926
|
+
// The page goes with the wish itself: a link that no longer carries the wish has nothing to play.
|
|
927
|
+
const gone = [...pageBlobs(record), ...partBlobs(record.pageParts),
|
|
928
|
+
...(page ? [] : [record.file?.url, ...partBlobs(record.parts)])].filter(Boolean);
|
|
929
|
+
const { page: _page, previousPage: _previousPage, pageParts: _pageParts, ...rest } = record;
|
|
930
|
+
// The count stays with the link, so a file sent again later is newer to its followers than any before.
|
|
931
|
+
const next = { ...(page ? rest : { ...rest, file: null, parts: undefined, fileVersion: record.file?.version ?? record.fileVersion }),
|
|
932
|
+
pageVersion: record.page?.version ?? record.pageVersion };
|
|
933
|
+
await kept.replace({ ...next, updated: now().toISOString() });
|
|
934
|
+
await kept.removeImages(gone);
|
|
935
|
+
await kept.forget(id);
|
|
936
|
+
return reply(204);
|
|
937
|
+
}
|
|
938
|
+
const parts = record[partsKey];
|
|
939
|
+
if (!parts || Object.keys(parts.urls).length !== parts.of) return reply(409, { error: "parts_missing" });
|
|
940
|
+
const pieces = [];
|
|
941
|
+
for (let n = 0; n < parts.of; n++) pieces.push(await kept.readFile(parts.urls[n]));
|
|
942
|
+
const bytes = Buffer.concat(pieces);
|
|
943
|
+
if (bytes.length > FILE_LIMITS.total) return reply(413, { error: "file_too_large" });
|
|
944
|
+
if (page) {
|
|
945
|
+
const files = readPage(bytes);
|
|
946
|
+
if (!files) return reply(400, { error: "invalid_page" });
|
|
947
|
+
// Each version in a folder of its own, under a random part, so an old one never mixes with a new one.
|
|
948
|
+
const token = randomBytes(12).toString("base64url");
|
|
949
|
+
const urls = {}, sizes = {};
|
|
950
|
+
// A few at a time: a page can be hundreds of files.
|
|
951
|
+
for (let n = 0; n < files.length; n += 8) {
|
|
952
|
+
await Promise.all(files.slice(n, n + 8).map(async (file) => {
|
|
953
|
+
urls[file.path] = await kept.putPageFile(id, token, file.path, file.bytes);
|
|
954
|
+
sizes[file.path] = file.bytes.length;
|
|
955
|
+
uploaded.push(urls[file.path]);
|
|
956
|
+
}));
|
|
957
|
+
}
|
|
958
|
+
// The count is the link's (`pageVersion`), so a page taken away and sent again never reuses a play address.
|
|
959
|
+
const version = (record.page?.version ?? record.pageVersion ?? 0) + 1;
|
|
960
|
+
// The page it replaces stays playable as the version before, so a game open on it goes on finding its files
|
|
961
|
+
// (a game loads its map as the player moves); the one before that goes now.
|
|
962
|
+
const { pageParts: _pageParts, ...rest } = record;
|
|
963
|
+
const previousPage = record.page ? { version: record.page.version, files: record.page.files, sizes: record.page.sizes } : undefined;
|
|
964
|
+
if (!(await kept.replace({ ...rest, page: { version, files: urls, sizes }, ...(previousPage ? { previousPage } : {}),
|
|
965
|
+
pageVersion: version, updated: now().toISOString() }))) {
|
|
966
|
+
await kept.removeImages([...uploaded, ...partBlobs(parts)]);
|
|
967
|
+
uploaded = [];
|
|
968
|
+
return reply(404, { error: "not_found" });
|
|
969
|
+
}
|
|
970
|
+
uploaded = [];
|
|
971
|
+
await kept.removeImages([...Object.values(record.previousPage?.files ?? {}), ...partBlobs(parts)]);
|
|
972
|
+
await kept.forget(id);
|
|
973
|
+
return reply(200, { version, files: files.length });
|
|
974
|
+
}
|
|
975
|
+
if (!isWishFile(bytes)) return reply(400, { error: "invalid_file" });
|
|
976
|
+
const at = await kept.putFile(id, fileName(record.name), bytes);
|
|
977
|
+
uploaded.push(at);
|
|
978
|
+
const { parts: _parts, ...rest } = record;
|
|
979
|
+
// Each file sent counts up, so the Lamps that follow the link can tell a newer one (`latest`).
|
|
980
|
+
const version = (record.file?.version ?? (record.file ? 1 : record.fileVersion ?? 0)) + 1;
|
|
981
|
+
if (!(await kept.replace({ ...rest, file: { url: at, size: bytes.length, version }, fileVersion: version, updated: now().toISOString() }))) {
|
|
982
|
+
// Taken down meanwhile: the file and its parts go with it.
|
|
983
|
+
await kept.removeImages([...uploaded, ...partBlobs(parts)]);
|
|
984
|
+
uploaded = [];
|
|
985
|
+
return reply(404, { error: "not_found" });
|
|
986
|
+
}
|
|
987
|
+
uploaded = [];
|
|
988
|
+
await kept.removeImages([record.file?.url, ...partBlobs(parts)].filter(Boolean));
|
|
989
|
+
await kept.forget(id);
|
|
990
|
+
return reply(200, { url: at, size: bytes.length });
|
|
991
|
+
} catch (error) {
|
|
992
|
+
try { await kept.removeImages(uploaded); } catch {}
|
|
993
|
+
return error?.message === "Gone" ? reply(404, { error: "not_found" }) : reply(503, { error: "service_unavailable" });
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
}
|